spatial: replace pgvector semantic search with targeted tag+name search
The embedding approach was overkill: it embedded only 'name — kind' (short strings), spent a 4B model + ~2.5GB VRAM + 4.6GB of vectors + a one-time 450k-row job to do what indexed SQL does directly. New default — no embedding model, no extra VRAM: - The LLM agent is the semantic layer: it maps the user's concept to OSM kinds + local-language name keywords. It is already in VRAM for chat, so this costs nothing. - /kinds: returns the tag vocabulary that actually exists (GROUP BY kind with counts) so the agent grounds its choices in real data. - /search: indexed retrieval — kind IN/ILIKE (poi_kind), name FTS (to_tsvector) + trigram (pg_trgm) for fuzzy/substring, optional ST_DWithin radius. Ranked by trigram similarity then distance. - schema.sql: real trigram GIN index (poi_name_trgm_ops); renamed the misnamed FTS index to poi_name_fts. - Agent tools: poi_semantic -> poi_kinds + poi_search (both pin results on the map). pgvector demoted to an opt-in path (embed/ + /semantic) — still works if poi_vec is built, but no longer the default. Dropped the half-built poi_vec and stopped the background embed run.
This commit is contained in:
parent
7efd2567b2
commit
d192b1dcab
44
mock/app.js
44
mock/app.js
|
|
@ -418,17 +418,42 @@ const TOOLS = {
|
|||
|
||||
// semantic POI search over the pgvector index (zembed embeddings, cosine).
|
||||
// "museums of ancient Etruscan art near the city center" style queries.
|
||||
poi_semantic: async (a) => {
|
||||
// browse the OSM tag vocabulary that actually exists in this extract —
|
||||
// grounds the agent's concept→tag choices in real data instead of guesses.
|
||||
poi_kinds: async (a) => {
|
||||
if (!spatialOn) return { error: 'the spatial database is offline — use search_places instead' };
|
||||
const q = String(a.query || '').trim();
|
||||
if (!q) return { error: 'query is required' };
|
||||
let qs = 'q=' + encodeURIComponent(q) + '&limit=' + Math.min(30, Math.max(1, +a.limit || 10));
|
||||
let qs = 'limit=' + Math.min(50, Math.max(1, +a.limit || 30));
|
||||
if (a.q) qs += '&q=' + encodeURIComponent(a.q);
|
||||
if (a.extract) qs += '&extract=' + encodeURIComponent(a.extract);
|
||||
const rj = await fetch('/spatial/semantic?' + qs);
|
||||
const rj = await fetch('/spatial/kinds?' + qs);
|
||||
const j = await rj.json();
|
||||
if (j.error) return { error: j.error };
|
||||
return { count: (j.kinds || []).length, kinds: j.kinds || [] };
|
||||
},
|
||||
|
||||
// Targeted POI search: the agent maps the user's concept to OSM kinds +
|
||||
// name keywords (local-language variants); Postgres retrieves with indexed
|
||||
// kind/FTS/trigram filters — no embedding model. kinds: "historic=fort",
|
||||
// a family "tourism", or a bare value; terms: ["wine","vino","cava"].
|
||||
poi_search: async (a) => {
|
||||
if (!spatialOn) return { error: 'the spatial database is offline — use search_places instead' };
|
||||
const kinds = (Array.isArray(a.kinds) ? a.kinds : [a.kinds]).filter(Boolean);
|
||||
const terms = (Array.isArray(a.terms) ? a.terms : [a.terms]).filter(Boolean);
|
||||
if (!kinds.length && !terms.length) return { error: 'pass kinds and/or terms' };
|
||||
let qs = '';
|
||||
if (kinds.length) qs += 'kinds=' + encodeURIComponent(kinds.join('|')) + '&';
|
||||
if (terms.length) qs += 'terms=' + encodeURIComponent(terms.join('|')) + '&';
|
||||
if (a.lat != null && a.lng != null) {
|
||||
qs += 'lat=' + a.lat + '&lng=' + a.lng;
|
||||
if (a.r) qs += '&r=' + Math.min(50000, Math.max(50, +a.r));
|
||||
}
|
||||
qs += 'limit=' + Math.min(30, Math.max(1, +a.limit || 10));
|
||||
if (a.extract) qs += '&extract=' + encodeURIComponent(a.extract);
|
||||
const rj = await fetch('/spatial/search?' + qs);
|
||||
const j = await rj.json();
|
||||
if (j.error) return { error: j.error };
|
||||
return { count: (j.results || []).length, results: (j.results || []).map(x =>
|
||||
({ name: x.name, kind: x.kind, lat: x.lat, lng: x.lng, score: x.score, opening_hours: x.opening_hours || null, fee: x.fee || null, website: x.website || null })) };
|
||||
({ name: x.name, kind: x.kind, lat: x.lat, lng: x.lng, score: x.score, dist_m: Math.round(x.dist_m), opening_hours: x.opening_hours || null, fee: x.fee || null, website: x.website || null })) };
|
||||
},
|
||||
|
||||
route_between: async (a) => {
|
||||
|
|
@ -657,7 +682,8 @@ function llmSystemPrompt() {
|
|||
' place_facts(id) — full facts for one place (id or name): price, duration, tags, pitch, walk from hotel, summary.\n' +
|
||||
' web_search(query) — LIVE web search. Use for anything that can change since the data snapshot: opening hours, entry fees, seasonal events, current prices, whether a place still exists. Returns results with source URLs — always cite the URL(s) you relied on in your answer.\n' +
|
||||
' poi_near(lat, lng, radius_m?, kind?, name?) — real POIs within a radius of a point from the local OSM/PostGIS extract (e.g. kind "amenity=restaurant"). Use when the curated pools are empty or the user asks "what is around X". Results appear as map pins.\n' +
|
||||
' poi_semantic(query, limit?) — fuzzy/semantic POI search over the local OSM extract (embedding cosine, e.g. "medieval fortresses with panoramic views"). Better than kind filters when the OSM tag for the idea is unclear. Results appear as map pins.\n' +
|
||||
' poi_kinds(q?, limit?) — list the OSM POI tags that actually exist in the local extract, with counts (q filters, e.g. q="wine"). Call this FIRST when you are not sure which tag fits the user\'s idea.\n' +
|
||||
' poi_search(kinds?, terms?, lat?, lng?, r?, limit?) — targeted POI search over the local OSM extract. kinds = OSM tags (e.g. "historic=fort", or a family like "tourism"); terms = name keywords (give local-language variants, e.g. ["wine","vino","cava"]). Translate the user\'s concept into tags + keywords — YOU are the semantic layer; Postgres does the exact retrieval. Optional lat/lng/r restricts to a radius (default 10 km). Results appear as map pins.\n' +
|
||||
' route_between(from, to) — real walking minutes + km between two places ("hotel" or a place id/name).\n' +
|
||||
' review_plan(dayId?) — deterministic checks: day overrun vs the waking window, meals at implausible hours, duplicates. dayId optional.\n' +
|
||||
' add_stop(dayId, slot, candidateId, state?) — put a candidate on a day. slot ∈ lunch,dinner,visit. If the slot is taken the current stop is demoted to a backup (a swap). state ∈ planned,idea,backup (default planned). candidateId comes from search_places.\n' +
|
||||
|
|
@ -716,7 +742,7 @@ async function askLLM(v) {
|
|||
if (TOOLS[call.name]) res = await Promise.resolve(TOOLS[call.name](call.args || {}));
|
||||
else res = { error: 'unknown tool. available: ' + Object.keys(TOOLS).join(', ') };
|
||||
// surface spatial lookups as map pins, not just JSON in the transcript
|
||||
if ((call.name === 'poi_near' || call.name === 'poi_semantic') && res && Array.isArray(res.results) && res.results.length) {
|
||||
if ((call.name === 'poi_near' || call.name === 'poi_search') && res && Array.isArray(res.results) && res.results.length) {
|
||||
showSpatialPins(res.results, call.name);
|
||||
}
|
||||
messages.push({ role: 'user', content: '[tool result for ' + call.name + ']\n' + JSON.stringify(res) });
|
||||
|
|
@ -1069,7 +1095,7 @@ function focusPoint(latlng, zoom = 16) {
|
|||
}
|
||||
|
||||
// ---------------- spatial tool pins ----------------
|
||||
// poi_near / poi_semantic results land here as pins (one layer group, so a
|
||||
// poi_near / poi_search results land here as pins (one layer group, so a
|
||||
// new search replaces the old set). Clicking a pin pops its facts card.
|
||||
let pinLayer = null;
|
||||
function showSpatialPins(points, title) {
|
||||
|
|
|
|||
|
|
@ -413,7 +413,7 @@ button { font: inherit; }
|
|||
.hotel-pin { font-size: 22px; transform: translate(-50%,-90%); transition: transform .15s; }
|
||||
.hotel-pin.hotel-alt { filter: grayscale(1); opacity: .75; }
|
||||
|
||||
/* spatial-tool result pins (poi_near / poi_semantic) */
|
||||
/* spatial-tool result pins (poi_near / poi_search) */
|
||||
.sp-dot { width: 12px; height: 12px; border-radius: 50%; background: #1d7a68;
|
||||
border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.45); transition: transform .15s; }
|
||||
.sp-pin:hover .sp-dot { transform: scale(1.35); }
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ docker-compose up -d spatiald # query service on :5005
|
|||
|
||||
The mock server (`mock/server.js`) proxies it: `GET /spatial/*` → `:5005`,
|
||||
plus `GET /spatial-status` for the UI badge. When the service is up, the
|
||||
chat assistant gains the `poi_near` tool automatically.
|
||||
chat assistant gains the `poi_near`, `poi_search` and `poi_kinds` tools
|
||||
automatically.
|
||||
|
||||
## spatiald endpoints
|
||||
|
||||
|
|
@ -53,12 +54,12 @@ fee,website,addr_city,dist_m},…]}`. Common filters:
|
|||
| `GET /near` | `lat,lng,r(m; default 500)` | `ST_DWithin(geography, …, r)` + KNN ordering |
|
||||
| `GET /nearest` | `lat,lng` | `ORDER BY geom <-> point` |
|
||||
| `GET /corridor` | `points=lng,lat;…`, `r` | `ST_DWithin(geom::geography, ST_Buffer(line::geography, r))` |
|
||||
| `GET /semantic` | `q` (free text), `limit` | cosine `<=>` over `poi_vec` (pgvector HNSW) |
|
||||
| `GET /search` | `kinds=a\|b`, `terms=x\|y`, optional `lat,lng,r` | indexed `kind` + FTS/trigram `name` match |
|
||||
| `GET /kinds` | `q?`, `extract?`, `limit` | `GROUP BY kind` — the tag vocabulary |
|
||||
| `GET /semantic` | `q` (free text), `limit` | **opt-in**: cosine `<=>` over `poi_vec` (see below) |
|
||||
|
||||
`/semantic` embeds `q` with the local zembed model (`EMBED_BASE` / `EMBED_MODEL`,
|
||||
defaults `http://192.168.3.7:1234/v1` + `zembed-1-Q4_K_M`) and ranks `poi_vec` by
|
||||
cosine distance. Each result also carries `lat`, `lng` and `score` (1 − cosine
|
||||
dist) so the UI can drop map pins. The same `extract`/`kind`/`name` filters apply.
|
||||
`/search` and `/kinds` are the agent's main POI lookups (see the next section).
|
||||
All spatial results carry `lat`/`lng` so the UI can drop map pins.
|
||||
|
||||
Examples:
|
||||
|
||||
|
|
@ -66,30 +67,57 @@ Examples:
|
|||
curl 'localhost:5005/near?lat=42.35&lng=-71.06&r=1000&kind=restaurant'
|
||||
curl 'localhost:5005/nearest?lat=10.40&lng=-75.54&kind=tourism&limit=5'
|
||||
curl 'localhost:5005/corridor?r=300&kind=fuel&points=-71.06,42.35;-71.07,42.36'
|
||||
curl 'localhost:5005/semantic?q=medieval%20museums&limit=5'
|
||||
curl 'localhost:5005/kinds?q=wine'
|
||||
curl 'localhost:5005/search?kinds=amenity=bar&terms=wine%7Cvino%7Ccava&lat=4.65&lng=-74.08&r=8000'
|
||||
```
|
||||
|
||||
## Semantic index (pgvector)
|
||||
## Targeted POI search (the default — no embeddings)
|
||||
|
||||
`poi_vec` mirrors `poi` for every **named POI family except `place=*`** and
|
||||
stores a 2560-dim zembed embedding of `"name — kind"`. It is a *derived* table
|
||||
— rebuild it any time with `embedpoi` (it is resumable: existing keys are
|
||||
skipped, and it drops/recreates the table only if the embedding dimension
|
||||
changed).
|
||||
The OSM `kind` column is a **closed, standardized vocabulary** (~1.3k distinct
|
||||
tags: `amenity=restaurant`, `tourism=museum`, `historic=fort`, …), and `name` is
|
||||
a short free-form string. That's enough structure to skip vector embeddings
|
||||
entirely:
|
||||
|
||||
* **The LLM agent is the semantic layer.** It translates the user's concept into
|
||||
OSM `kind`s + local-language `name` keywords ("fortress with a view" →
|
||||
`historic=fort` / `tourism=viewpoint` + `view|panoramic|mirador`). It is
|
||||
already resident in VRAM for chat, so this costs nothing extra.
|
||||
* **`/kinds`** returns the tags that actually exist in the extract (`GROUP BY
|
||||
kind` with counts), so the agent grounds its choices in real data instead of
|
||||
guessing tags that aren't there.
|
||||
* **`/search`** retrieves with indexed SQL: `kind IN/ILIKE` (the `poi_kind`
|
||||
index), `name` full-text (`poi_name_fts`, `to_tsvector('simple')`) and trigram
|
||||
(`poi_name_trgm_ops`, `pg_trgm`) for fuzzy/substring matches, plus an optional
|
||||
`ST_DWithin` radius. Ranked by trigram similarity, then distance.
|
||||
|
||||
This replaces the old pgvector approach for the agent: no embedding model, no
|
||||
extra VRAM, no ~4.6 GB vector table, no one-time 450k-row embed job — and it is
|
||||
arguably more accurate here, because `kind` is ground truth.
|
||||
|
||||
### Optional: pgvector semantic index (off by default)
|
||||
|
||||
If you ever *do* want true free-text semantic search, the machinery is still
|
||||
here but **not wired to the agent** by default. `poi_vec` mirrors `poi` for
|
||||
named POIs and stores a 2560-dim zembed embedding of `"name — kind"`; build it
|
||||
with `embedpoi` (resumable; rebuilds only if the embedding dim changed):
|
||||
|
||||
```bash
|
||||
go run ./embed -dsn "host=localhost port=5432 user=trips password=trips dbname=trips sslmode=disable"
|
||||
# restrict to one extract, or be gentler on the shared model host:
|
||||
go run ./embed -extract colombia -qps 2 -batch 64
|
||||
go run ./embed -extract colombia -qps 2 -batch 64 # one extract, gentler on the host
|
||||
```
|
||||
|
||||
* Embeddings come from the shared llama.cpp host (`/v1/embeddings`), so the
|
||||
host must be up. That host swaps models in/out and 500s briefly while zembed
|
||||
is (re)loaded — `embedpoi` retries patiently (up to 20× with backoff).
|
||||
Then `GET /semantic?q=…` works (it embeds `q` with the local model —
|
||||
`EMBED_BASE` / `EMBED_MODEL` env, defaults `http://192.168.3.7:1234/v1` +
|
||||
`zembed-1-Q4_K_M` — and cosine-ranks `poi_vec`). Note it needs the embedding
|
||||
model resident in GPU VRAM while running, which is why targeted `/search` is
|
||||
the default.
|
||||
|
||||
* `embedpoi` retries patiently (up to 20× with backoff) because the shared
|
||||
llama.cpp host swaps models in/out and 500s briefly while zembed reloads.
|
||||
* Zembed uses a `query: ` prompt prefix for search-time queries — `spatiald`
|
||||
adds it in `/semantic`; `embedpoi` stores raw `"name — kind"` passages.
|
||||
* After the run it builds `poi_vec_hnsw` (`hnsw (vec vector_cosine_ops)`),
|
||||
which is what makes `/semantic` fast at full scale.
|
||||
* After the run it builds `poi_vec_hnsw` (`hnsw (vec vector_cosine_ops)`) so
|
||||
`/semantic` stays fast at full scale.
|
||||
* The DB image is `postgis/postgis:16-3.4` + `postgresql-16-pgvector` (see
|
||||
`db/Dockerfile`). The stock postgis images for PG16 are bullseye-based and
|
||||
their Debian release files are expired, so the image disables
|
||||
|
|
|
|||
225
spatial/main.go
225
spatial/main.go
|
|
@ -6,8 +6,11 @@
|
|||
// GET /near?lat=&lng=&r= ST_DWithin radius search
|
||||
// GET /nearest?lat=&lng= KNN (ORDER BY geom <-> point)
|
||||
// GET /corridor?points=&r= corridor buffering along a route
|
||||
// GET /search?kinds=&terms=&lat=&lng=&r= targeted tag+name search
|
||||
// GET /kinds?q=&extract= the OSM tag vocabulary of the extracts
|
||||
// GET /semantic?q= (opt-in; needs the poi_vec index, see embed/)
|
||||
//
|
||||
// Optional filters on all three:
|
||||
// Optional filters on the spatial three:
|
||||
//
|
||||
// extract=nh restrict to one loaded extract
|
||||
// kind=amenity=restaurant exact kind match, or one of the bare values
|
||||
|
|
@ -15,6 +18,10 @@
|
|||
// name=café ILIKE substring on the POI name
|
||||
// limit=20 default 20, max 200
|
||||
//
|
||||
// /search is the agent's main POI lookup: the LLM translates the user's
|
||||
// concept into kinds (OSM tags) + terms (local-language name keywords) and
|
||||
// the GIN kind/FTS/trigram indexes do the retrieval — no embedding model.
|
||||
//
|
||||
// /corridor's `points` is "lng,lat;lng,lat;…" (a router polyline, downsampled
|
||||
// is fine — the buffer does the smoothing work).
|
||||
package main
|
||||
|
|
@ -178,6 +185,218 @@ type poi struct {
|
|||
DistM float64 `json:"dist_m"`
|
||||
}
|
||||
|
||||
// ---- targeted search (/search, /kinds) ---------------------------------
|
||||
// The LLM agent is the semantic layer: it translates the user's concept into
|
||||
// OSM kinds ("historic=fort") and local-language name terms ("vino"). Postgres
|
||||
// does the retrieval with indexed kind/FTS/trigram filters — no embedding
|
||||
// model, no extra VRAM. /kinds exposes the real tag vocabulary so the agent
|
||||
// grounds its choices in this extract instead of guessing.
|
||||
|
||||
// splitList: "a|b|c" → [a b c] (empty segments dropped)
|
||||
func splitList(s string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, "|") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// kindCond → WHERE fragment for one kind token: "amenity=restaurant" (exact),
|
||||
// a family name ("tourism"), or a bare tag value ("restaurant" → any family).
|
||||
func kindCond(k string, param func(any) string) string {
|
||||
if strings.Contains(k, "=") {
|
||||
return "poi.kind = " + param(k)
|
||||
}
|
||||
if isFamily(k) {
|
||||
return "poi.kind LIKE '" + k + "=%'" // family names are a closed list
|
||||
}
|
||||
v := param(k)
|
||||
var parts []string
|
||||
for _, fam := range []string{"amenity", "tourism", "shop", "leisure", "historic", "place"} {
|
||||
parts = append(parts, "poi.kind = '"+fam+"='||"+v)
|
||||
}
|
||||
return "(" + strings.Join(parts, " OR ") + ")"
|
||||
}
|
||||
|
||||
type searchHit struct {
|
||||
poi
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
func handleKinds(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
var conds []string
|
||||
var args []any
|
||||
param := func(a any) string {
|
||||
args = append(args, a)
|
||||
return "@@" + strconv.Itoa(len(args)) + "@@"
|
||||
}
|
||||
if e := first(q, "extract"); e != "" {
|
||||
conds = append(conds, "poi.extract = "+param(e))
|
||||
}
|
||||
if s := first(q, "q"); s != "" { // substring filter on the tag string
|
||||
conds = append(conds, "poi.kind ILIKE "+param("%"+s+"%"))
|
||||
}
|
||||
l, err := strconv.Atoi(first(q, "limit"))
|
||||
if err != nil || l < 1 {
|
||||
l = 40
|
||||
}
|
||||
if l > 200 {
|
||||
l = 200
|
||||
}
|
||||
args = append(args, l)
|
||||
limTok := "@@" + strconv.Itoa(len(args)) + "@@"
|
||||
query := `SELECT kind, count(*) FROM poi` + whereJoin(conds) + `
|
||||
GROUP BY kind ORDER BY count(*) DESC, kind LIMIT ` + limTok
|
||||
rows, err := dsn.Query(renumber(query, 0), args...)
|
||||
if err != nil {
|
||||
jerr(w, 502, "query: "+err.Error())
|
||||
return
|
||||
}
|
||||
type kc struct {
|
||||
Kind string `json:"kind"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
var out []kc
|
||||
for rows.Next() {
|
||||
var k kc
|
||||
if err := rows.Scan(&k.Kind, &k.Count); err != nil {
|
||||
rows.Close()
|
||||
jerr(w, 502, err.Error())
|
||||
return
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
rows.Close()
|
||||
if out == nil {
|
||||
out = []kc{}
|
||||
}
|
||||
cors(w)
|
||||
b, _ := json.Marshal(out)
|
||||
fmt.Fprintf(w, `{"count":%d,"kinds":`, len(out))
|
||||
w.Write(b)
|
||||
w.Write([]byte("}"))
|
||||
}
|
||||
|
||||
func whereJoin(conds []string) string {
|
||||
if len(conds) == 0 {
|
||||
return ""
|
||||
}
|
||||
return " WHERE " + strings.Join(conds, " AND ")
|
||||
}
|
||||
|
||||
// /search?kinds=a|b&terms=x|y&lat=&lng=&r=&extract=&limit=
|
||||
//
|
||||
// kinds: OSM tags ("historic=fort", family "tourism", or bare "fort")
|
||||
// terms: name keywords — give local-language variants ("wine|vino|cava")
|
||||
// lat,lng: optional radius search (r default 10 km, max 50 km)
|
||||
func handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
kinds := splitList(first(q, "kinds"))
|
||||
terms := splitList(first(q, "terms"))
|
||||
if len(kinds) == 0 && len(terms) == 0 {
|
||||
jerr(w, 400, "kinds and/or terms is required")
|
||||
return
|
||||
}
|
||||
var conds []string
|
||||
var args []any
|
||||
param := func(a any) string {
|
||||
args = append(args, a)
|
||||
return "@@" + strconv.Itoa(len(args)) + "@@"
|
||||
}
|
||||
if e := first(q, "extract"); e != "" {
|
||||
conds = append(conds, "poi.extract = "+param(e))
|
||||
}
|
||||
if len(kinds) > 0 {
|
||||
parts := make([]string, len(kinds))
|
||||
for i, k := range kinds {
|
||||
parts[i] = kindCond(k, param)
|
||||
}
|
||||
conds = append(conds, "("+strings.Join(parts, " OR ")+")")
|
||||
}
|
||||
if len(terms) > 0 {
|
||||
parts := make([]string, len(terms))
|
||||
for i, t := range terms {
|
||||
parts[i] = "poi.name ILIKE " + param("%"+t+"%") // gin_trgm index
|
||||
}
|
||||
conds = append(conds, "("+strings.Join(parts, " OR ")+")")
|
||||
}
|
||||
// score: max trigram similarity across the terms (1.0 when there are none)
|
||||
var scoreExpr = "1.0"
|
||||
if len(terms) > 0 {
|
||||
parts := make([]string, len(terms))
|
||||
for i, t := range terms {
|
||||
parts[i] = "similarity(poi.name, " + param(t) + ")"
|
||||
}
|
||||
scoreExpr = "GREATEST(" + strings.Join(parts, ",") + ")"
|
||||
}
|
||||
// optional radius: rank by relevance first, distance as tiebreaker
|
||||
spatial := first(q, "lat") != "" && first(q, "lng") != ""
|
||||
if spatial {
|
||||
lat, lng, err := point(q)
|
||||
if err != nil {
|
||||
jerr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
var rad float64 = 10000
|
||||
if s := first(q, "r"); s != "" {
|
||||
if rad, err = strconv.ParseFloat(s, 64); err != nil || rad < 50 || rad > 50000 {
|
||||
jerr(w, 400, "r must be 50..50000 metres")
|
||||
return
|
||||
}
|
||||
}
|
||||
args = append(args, lat, lng, rad) // positions i-2, i-1, i
|
||||
i := len(args)
|
||||
// ST_MakePoint wants (lng, lat)
|
||||
ptTok := "@@" + strconv.Itoa(i-1) + "@@" + ", @@" + strconv.Itoa(i-2) + "@@"
|
||||
conds = append(conds, "ST_DWithin(poi.geom::geography, "+
|
||||
"ST_SetSRID(ST_MakePoint("+ptTok+"), 4326)::geography, @@"+strconv.Itoa(i)+"@@)")
|
||||
}
|
||||
args = append(args, limitOf(q))
|
||||
limTok := "@@" + strconv.Itoa(len(args)) + "@@"
|
||||
var distExpr = "0 AS dist_m"
|
||||
if spatial {
|
||||
// limit was appended after lat/lng → they sit at len-3/len-2; MakePoint wants (lng, lat)
|
||||
i := len(args) // limit is the last arg
|
||||
distExpr = "ST_Distance(poi.geom::geography, ST_SetSRID(ST_MakePoint(@@" + strconv.Itoa(i-2) + "@@, @@" + strconv.Itoa(i-3) + "@@), 4326)::geography) AS dist_m"
|
||||
}
|
||||
order := " ORDER BY score DESC"
|
||||
if spatial {
|
||||
order += ", dist_m"
|
||||
}
|
||||
order += ", poi.kind, poi.name LIMIT " + limTok
|
||||
query := `SELECT ` + poiCols + `, ` + scoreExpr + ` AS score, ` + distExpr + `
|
||||
FROM poi` + whereJoin(conds) + order
|
||||
rows, err := dsn.Query(renumber(query, 0), args...)
|
||||
if err != nil {
|
||||
jerr(w, 502, "query: "+err.Error())
|
||||
return
|
||||
}
|
||||
var out []searchHit
|
||||
for rows.Next() {
|
||||
var h searchHit
|
||||
if err := rows.Scan(&h.Extract, &h.OsmID, &h.OsmType, &h.Name, &h.Kind,
|
||||
&h.OpenH, &h.Fee, &h.Website, &h.AddrCity, &h.Lat, &h.Lng, &h.Score, &h.DistM); err != nil {
|
||||
rows.Close()
|
||||
jerr(w, 502, err.Error())
|
||||
return
|
||||
}
|
||||
h.Score = float64(int64(h.Score*1000)) / 1000
|
||||
out = append(out, h)
|
||||
}
|
||||
rows.Close()
|
||||
if out == nil {
|
||||
out = []searchHit{}
|
||||
}
|
||||
cors(w)
|
||||
b, _ := json.Marshal(out)
|
||||
fmt.Fprintf(w, `{"count":%d,"results":`, len(out))
|
||||
w.Write(b)
|
||||
w.Write([]byte("}"))
|
||||
}
|
||||
|
||||
func main() {
|
||||
dsnFlag := flag.String("dsn",
|
||||
"host=localhost port=5432 user=trips password=trips dbname=trips sslmode=disable",
|
||||
|
|
@ -205,7 +424,9 @@ func main() {
|
|||
mux.HandleFunc("/near", handleNear)
|
||||
mux.HandleFunc("/nearest", handleNearest)
|
||||
mux.HandleFunc("/corridor", handleCorridor)
|
||||
mux.HandleFunc("/semantic", handleSemantic)
|
||||
mux.HandleFunc("/search", handleSearch)
|
||||
mux.HandleFunc("/kinds", handleKinds)
|
||||
mux.HandleFunc("/semantic", handleSemantic) // opt-in; needs poi_vec (see embed/)
|
||||
log.Printf("spatiald: listening on %s (dsn: %s)", addr, *dsnFlag)
|
||||
srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||
if err = srv.ListenAndServe(); err != nil {
|
||||
|
|
|
|||
|
|
@ -41,11 +41,14 @@ CREATE TABLE IF NOT EXISTS poi (
|
|||
|
||||
CREATE INDEX IF NOT EXISTS poi_geom_gist ON poi USING GIST (geom);
|
||||
CREATE INDEX IF NOT EXISTS poi_kind ON poi (extract, kind);
|
||||
CREATE INDEX IF NOT EXISTS poi_name_trgm ON poi USING GIN (to_tsvector('simple', name));
|
||||
-- name matching for the /search endpoint, two complementary indexes:
|
||||
-- * word-level full-text (to_tsvector) — exact word hits
|
||||
CREATE INDEX IF NOT EXISTS poi_name_fts ON poi USING GIN (to_tsvector('simple', name));
|
||||
-- * trigram (fuzzy / substring, language-agnostic) — needs pg_trgm
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE INDEX IF NOT EXISTS poi_name_trgm_ops ON poi USING gin (name gin_trgm_ops);
|
||||
|
||||
-- pgvector (semantic POI index) is a later milestone: it needs the vector
|
||||
-- package in the image (postgis/postgis doesn't ship it), so it gets its own
|
||||
-- init script + embedding importer instead of living in this base schema.
|
||||
-- pgvector (opt-in semantic index) is separate: 20-vector.sql + embed/.
|
||||
|
||||
-- Helper: refresh the poi table from a freshly loaded extract.
|
||||
-- (Invoked by import.sh; kept here so the SQL lives in one place.)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user