From 7efd2567b2f389b8f8a98bb8a8167400d6ad2572 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Thu, 10 Sep 2026 14:08:53 -0400 Subject: [PATCH] spatial: pgvector semantic POI index + UI map pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - db/Dockerfile: postgis:16-3.4 + postgresql-16-pgvector (works around the EOL bullseye release files: disable Check-Valid-Until, add pgdg repo) - 20-vector.sql: CREATE EXTENSION vector on first boot - embed/main.go (embedpoi): build poi_vec from poi — zembed embeddings of 'name — kind' (2560-d), resumable, HNSW cosine index at the end - main.go: /semantic endpoint (embeds q with the local model, cosine <=> over poi_vec, returns lat/lng/score); add lat/lng to /near /nearest /corridor so the UI can pin results; bind the vector as a text literal + ::vector cast (lib/pq has no []float32 codec) - mock/app.js: poi_semantic tool + poi_near now returns coords; agent loop drops poi_near/poi_semantic results as map pins (showSpatialPins) - mock/styles.css: .sp-dot / .sp-card pin + popup styles - README: document the pgvector stack + embedpoi --- mock/app.js | 51 ++++++++- mock/styles.css | 7 ++ spatial/20-vector.sql | 5 + spatial/README.md | 47 ++++++++- spatial/db/Dockerfile | 15 +++ spatial/db/pgdg.asc | 77 ++++++++++++++ spatial/docker-compose.yml | 5 +- spatial/embed/main.go | 211 +++++++++++++++++++++++++++++++++++++ spatial/main.go | 137 +++++++++++++++++++++++- 9 files changed, 543 insertions(+), 12 deletions(-) create mode 100644 spatial/20-vector.sql create mode 100644 spatial/db/Dockerfile create mode 100644 spatial/db/pgdg.asc create mode 100644 spatial/embed/main.go diff --git a/mock/app.js b/mock/app.js index 6fd79b9..caa2e82 100644 --- a/mock/app.js +++ b/mock/app.js @@ -401,7 +401,7 @@ const TOOLS = { // POI lookup against the local PostGIS extract (native ST_DWithin / KNN / // corridor queries — real OSM data, not the curated demo pools). - poi_near: async (a) => { + poi_near: async (a) => { // lat/lng are also returned — the UI drops pins if (!spatialOn) return { error: 'the spatial database is offline — use search_places instead' }; const lat = +a.lat, lng = +a.lng, r = Math.min(50000, Math.max(100, +a.radius_m || 500)); if (!isFinite(lat) || !isFinite(lng)) return { error: 'lat/lng must be numbers (lat, lng in degrees)' }; @@ -413,7 +413,22 @@ const TOOLS = { 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, dist_m: Math.round(x.dist_m), 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, dist_m: Math.round(x.dist_m), opening_hours: x.opening_hours || null, fee: x.fee || null, website: x.website || null })) }; + }, + + // 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) => { + 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)); + if (a.extract) qs += '&extract=' + encodeURIComponent(a.extract); + const rj = await fetch('/spatial/semantic?' + 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 })) }; }, route_between: async (a) => { @@ -641,7 +656,8 @@ function llmSystemPrompt() { ' search_places(category, region?) — candidate places. category ∈ {' + cats.join(', ') + '} ; region ∈ {' + regions.join(', ') + '} or omit. Returns id, name, price_eur, dur_min, tags, pitch, walk_from_hotel_min.\n' + ' 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 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".\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' + ' 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' + @@ -699,6 +715,10 @@ async function askLLM(v) { let res; 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) { + showSpatialPins(res.results, call.name); + } messages.push({ role: 'user', content: '[tool result for ' + call.name + ']\n' + JSON.stringify(res) }); continue; } @@ -1047,6 +1067,31 @@ function focusPoint(latlng, zoom = 16) { const ring = L.circleMarker(latlng, { radius: 16, color: '#b5533c', weight: 3, fill: false, opacity: .9 }).addTo(map); setTimeout(() => map.removeLayer(ring), 1500); } + +// ---------------- spatial tool pins ---------------- +// poi_near / poi_semantic 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) { + if (!map) return; + if (!pinLayer) { pinLayer = L.layerGroup().addTo(map); } + pinLayer.clearLayers(); + const pts = (points || []).filter(p => isFinite(p.lat) && isFinite(p.lng)).slice(0, 50); + if (!pts.length) return; + pts.forEach(p => { + const mk = L.marker([p.lat, p.lng], { icon: L.divIcon({ className: 'sp-pin', html: '
', iconSize: [12, 12], iconAnchor: [6, 6] }) }); + const f = []; + if (isFinite(p.dist_m)) f.push('📍 ' + (p.dist_m >= 1000 ? (p.dist_m / 1000).toFixed(1) + ' km' : p.dist_m + ' m') + ' away'); + if (isFinite(p.score)) f.push('match ' + (100 * p.score).toFixed(0) + '%'); + if (p.opening_hours) f.push('🕑 ' + p.opening_hours); + if (p.fee) f.push('💶 fee: ' + p.fee); + if (p.website) f.push('🔗 ' + p.website); + mk.bindPopup('
' + esc(p.name) + '' + esc(p.kind || '') + '' + + f.map(x => '
' + esc(x) + '
').join('') + '
'); + pinLayer.addLayer(mk); + }); + map.fitBounds(L.latLngBounds(pts.map(p => [p.lat, p.lng])).pad(0.3), { maxZoom: 16 }); +} const flyTo = s => focusPoint(s.at); function tempShow(s) { if (stopById(s.id)) return; diff --git a/mock/styles.css b/mock/styles.css index 52d5ea7..788226d 100644 --- a/mock/styles.css +++ b/mock/styles.css @@ -413,6 +413,13 @@ 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) */ +.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); } +.sp-card { font-size: 12px; line-height: 1.5; min-width: 160px; max-width: 260px; } +.sp-card .sp-kind { display: block; color: #6b7280; font-size: 11px; margin: 2px 0 4px; } + /* map markers: current-day numbered vs other-day dots */ .mk { background: #b5533c; color: #fff; width: 26px; height: 26px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; diff --git a/spatial/20-vector.sql b/spatial/20-vector.sql new file mode 100644 index 0000000..7954b39 --- /dev/null +++ b/spatial/20-vector.sql @@ -0,0 +1,5 @@ +-- pgvector (runs on first boot, after 10-schema.sql; the `vector` package is +-- baked into the db/ image — see db/Dockerfile). +-- The poi_vec table itself is created by embedpoi (it needs to know the +-- embedding dimension, which is detected from the model's first response). +CREATE EXTENSION IF NOT EXISTS vector; diff --git a/spatial/README.md b/spatial/README.md index 3715b15..f4e0ecd 100644 --- a/spatial/README.md +++ b/spatial/README.md @@ -8,10 +8,13 @@ nearest, bbox)"). Replaces the flat-file/Overpass approximations. | Piece | What it is | |---|---| -| `docker-compose.yml` | `postgis/postgis:16-3.4` (DB, port 5432) + one-shot `iboates/osm2pgsql` importer + `spatiald` (query service, port 5005) | +| `docker-compose.yml` | `postgis/postgis:16-3.4` + `postgresql-16-pgvector` (DB, port 5432) + one-shot `iboates/osm2pgsql` importer + `spatiald` (query service, port 5005) | | `schema.sql` | runs on first boot: `poi` table (named POIs as points, GIST index), `spatial_extract` bookkeeping, `refresh_poi()` | +| `20-vector.sql` | runs on first boot: `CREATE EXTENSION vector` (the `vector` package is baked into the DB image — see `db/Dockerfile`) | +| `db/Dockerfile` | `postgis/postgis:16-3.4` + `postgresql-16-pgvector` (workaround for the EOL bullseye repos — see the file) | | `import.sh` | loads a PBF from `../osm/` via osm2pgsql, refreshes `poi` | -| `main.go` (spatiald) | JSON query service over `poi` | +| `main.go` (spatiald) | JSON query service over `poi` + `poi_vec` (radius / KNN / corridor / **semantic**) | +| `embed/main.go` (embedpoi) | builds the `poi_vec` semantic index from `poi` (zembed embeddings → pgvector, HNSW) | | `Dockerfile` | builds spatiald | ## Prerequisites @@ -50,6 +53,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) | + +`/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. Examples: @@ -57,8 +66,35 @@ 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' ``` +## Semantic index (pgvector) + +`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). + +```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 +``` + +* 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). +* 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. +* 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 + `Check-Valid-Until` and adds the pgdg repo just to fetch `postgresql-16-pgvector`. + ## Data notes * Built for **osm2pgsql 2.x** (`iboates/osm2pgsql`): tables are @@ -77,12 +113,15 @@ curl 'localhost:5005/corridor?r=300&kind=fuel&points=-71.06,42.35;-71.07,42.36' `url.Parse` drops the tail of a value that contains a raw `;`. * Multiple extracts coexist in one DB, disambiguated by `poi.extract` (set automatically by `import.sh`). -* pgvector (semantic POI index, later milestone) gets its own init script + - embedding importer — the base image doesn't ship the `vector` package. +* `lib/pq` has no `[]float32` → `vector` codec, so `/semantic` sends the + embedding as a `[f1,f2,…]` **text** literal and casts with `::vector` in SQL. +* `poi_vec` is rebuilt by `embedpoi` (not `import.sh`); it is keyed + `(extract, osm_type, osm_id)` to match `poi`. ## Roadmap hooks * `bbox` queries: trivial addition (`ST_Contains(ST_MakeEnvelope,…)`). +* Rerun `embedpoi` after a new `import.sh` so `poi_vec` tracks the new `poi` rows. * Routing-graph join: OSRM/GraphHopper geometries can be loaded into the same DB (`route_geom` table) for true along-route analytics instead of the current buffer-over-polyline. diff --git a/spatial/db/Dockerfile b/spatial/db/Dockerfile new file mode 100644 index 0000000..6d34265 --- /dev/null +++ b/spatial/db/Dockerfile @@ -0,0 +1,15 @@ +# PostGIS + pgvector. +# +# postgis/postgis images for PG16 are bullseye-based and their Debian release +# files have expired (EOL), so a plain apt update fails. Two fixes baked in: +# - Acquire::Check-Valid-Until=false (the repos are still served) +# - the pgdg signing key (trusted.gpg.d accepts ASCII-armored keys) +# then postgresql-16-pgvector from pgdg gives us the `vector` extension. +FROM postgis/postgis:16-3.4 +COPY pgdg.asc /etc/apt/trusted.gpg.d/pgdg.asc +RUN echo 'deb http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main' \ + > /etc/apt/sources.list.d/pgdg.list \ + && echo 'Acquire::Check-Valid-Until "false";' > /etc/apt/apt.conf.d/99noexpire \ + && apt-get update -qq \ + && apt-get install -y -qq --no-install-recommends postgresql-16-pgvector \ + && rm -rf /var/lib/apt/lists/* diff --git a/spatial/db/pgdg.asc b/spatial/db/pgdg.asc new file mode 100644 index 0000000..8480576 --- /dev/null +++ b/spatial/db/pgdg.asc @@ -0,0 +1,77 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=DA1T +-----END PGP PUBLIC KEY BLOCK----- diff --git a/spatial/docker-compose.yml b/spatial/docker-compose.yml index 5fa1a80..71d61a1 100644 --- a/spatial/docker-compose.yml +++ b/spatial/docker-compose.yml @@ -14,7 +14,7 @@ version: "3.8" services: postgis: - image: postgis/postgis:16-3.4 + build: ./db # postgis/postgis:16-3.4 + postgresql-16-pgvector (db/Dockerfile) container_name: trips-postgis environment: POSTGRES_DB: trips @@ -26,6 +26,7 @@ services: - pgdata:/var/lib/postgresql/data # initdb scripts run in lexicographic order, once, on an empty volume - ./schema.sql:/docker-entrypoint-initdb.d/10-schema.sql:ro + - ./20-vector.sql:/docker-entrypoint-initdb.d/20-vector.sql:ro restart: unless-stopped # One-shot importer (also usable as: docker-compose run --rm importer @@ -49,6 +50,8 @@ services: container_name: trips-spatiald environment: SPATIAL_DSN: "host=postgis port=5432 user=trips password=trips dbname=trips sslmode=disable" + EMBED_BASE: ${EMBED_BASE:-http://192.168.3.7:1234/v1} + EMBED_MODEL: ${EMBED_MODEL:-zembed-1-Q4_K_M} ports: - "5005:5005" depends_on: diff --git a/spatial/embed/main.go b/spatial/embed/main.go new file mode 100644 index 0000000..2831ab9 --- /dev/null +++ b/spatial/embed/main.go @@ -0,0 +1,211 @@ +// embedpoi — build the semantic POI index (poi_vec) from the poi table. +// +// For every named POI (all families except place=*) it embeds +// "name — kind" with the local zembed model (llama.cpp /embeddings) in +// batches and upserts into poi_vec. Resumable: existing keys are skipped. +// After the run it creates the HNSW cosine index. +// +// go run ./embed -dsn "host=localhost ... dbname=trips sslmode=disable" +package main + +import ( + "bytes" + "database/sql" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" + + _ "github.com/lib/pq" +) + +func getenvDefault(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func must(err error) { + if err != nil { + log.Fatal(err) + } +} + +func exec(db *sql.DB, q string, args ...any) { + if _, e := db.Exec(q, args...); e != nil { + log.Fatal(e) + } +} + +// vecToSQL renders a []float32 as the body of a vector literal: 0.1,-0.2,… +func vecToSQL(v []float32) string { + parts := make([]string, len(v)) + for i, f := range v { + parts[i] = strconv.FormatFloat(float64(f), 'g', 6, 32) + } + return strings.Join(parts, ",") +} + +func main() { + dsnFlag := flag.String("dsn", getenvDefault("SPATIAL_DSN", + "host=localhost port=5432 user=trips password=trips dbname=trips sslmode=disable"), "DSN") + baseFlag := flag.String("embed-base", getenvDefault("EMBED_BASE", "http://192.168.3.7:1234/v1"), "llama.cpp /v1 base") + modelFlag := flag.String("embed-model", getenvDefault("EMBED_MODEL", "zembed-1-Q4_K_M"), "embedding model") + batchFlag := flag.Int("batch", 64, "embeddings per request") + extractFlag := flag.String("extract", "", "restrict to one extract (default: all)") + qpsFlag := flag.Int("qps", 4, "requests per second (politeness toward the shared model server)") + flag.Parse() + + db, err := sql.Open("postgres", *dsnFlag) + must(err) + db.SetMaxOpenConns(4) + must(db.Ping()) + client := &http.Client{Timeout: 120 * time.Second} + + embed := func(texts []string) ([][]float32, error) { + body, _ := json.Marshal(map[string]any{"model": *modelFlag, "input": texts}) + req, err := http.NewRequest("POST", *baseFlag+"/embeddings", bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != 200 { + b, _ := io.ReadAll(io.LimitReader(res.Body, 300)) + return nil, fmt.Errorf("embed http %d: %s", res.StatusCode, b) + } + var j struct { + Data []struct { + Embedding []float32 `json:"embedding"` + } `json:"data"` + } + if err := json.NewDecoder(res.Body).Decode(&j); err != nil { + return nil, err + } + var out [][]float32 + for _, d := range j.Data { + out = append(out, d.Embedding) + } + return out, nil + } + + // ---- collect rows (everything semantic-searchable: named POIs of the + // POI families; place=* localities are not "places to go") + type row struct { + Extract, OsmType, Name, Kind string + OsmID int64 + } + q := `SELECT extract, osm_type, osm_id, name, kind FROM poi + WHERE kind NOT LIKE 'place=%'` + var args []any + if *extractFlag != "" { + q += " AND extract = $1" + args = append(args, *extractFlag) + } + q += " ORDER BY extract, osm_id" + rows, err := db.Query(q, args...) + must(err) + var all []row + for rows.Next() { + var r row + must(rows.Scan(&r.Extract, &r.OsmType, &r.OsmID, &r.Name, &r.Kind)) + all = append(all, r) + } + rows.Close() + log.Printf("poi rows to consider: %d", len(all)) + + // resume: keys already embedded + have := map[string]bool{} + hrows, err := db.Query(`SELECT extract, osm_type, osm_id FROM poi_vec`) + if err != nil { + have = nil // table doesn't exist yet — everything is to do + } else { + for hrows.Next() { + var e, t string + var id int64 + hrows.Scan(&e, &t, &id) + have[e+"|"+t+"|"+strconv.FormatInt(id, 10)] = true + } + hrows.Close() + } + var todo []row + for _, r := range all { + if have == nil || !have[r.Extract+"|"+r.OsmType+"|"+strconv.FormatInt(r.OsmID, 10)] { + todo = append(todo, r) + } + } + log.Printf("already embedded: %d, to do: %d", len(all)-len(todo), len(todo)) + if len(todo) == 0 { + return + } + + var dim int + total := len(todo) + for i := 0; i < total; i += *batchFlag { + j := i + *batchFlag + if j > total { + j = total + } + chunk := todo[i:j] + texts := make([]string, len(chunk)) + for k, r := range chunk { + texts[k] = r.Name + " — " + r.Kind + } + // the shared llama.cpp host evicts zembed when another model loads + // (500 "proxy error") — that clears on the next request, so retry + // patiently rather than dying + var vecs [][]float32 + for attempt := 0; ; attempt++ { + var err error + vecs, err = embed(texts) + if err == nil { + break + } + if attempt >= 20 { + must(err) + } + log.Printf("batch at %d failed (%v) — retry %d/20", i, err, attempt+1) + time.Sleep(time.Duration(5+attempt) * time.Second) + } + if dim == 0 { + dim = len(vecs[0]) + // derived table: safe to rebuild if the model's dim changed + exec(db, `DROP TABLE IF EXISTS poi_vec`) + stmt := fmt.Sprintf(`CREATE TABLE poi_vec ( + extract text NOT NULL, osm_type char NOT NULL, osm_id bigint NOT NULL, + name text NOT NULL, kind text NOT NULL, + vec vector(%d) NOT NULL, + PRIMARY KEY (extract, osm_type, osm_id))`, dim) + log.Printf("embedding dim: %d — creating poi_vec", dim) + exec(db, stmt) + } + for k, r := range chunk { + _, err := db.Exec(`INSERT INTO poi_vec (extract, osm_type, osm_id, name, kind, vec) + VALUES ($1,$2,$3,$4,$5,$6) + ON CONFLICT (extract, osm_type, osm_id) DO UPDATE SET vec = EXCLUDED.vec`, + r.Extract, r.OsmType, r.OsmID, r.Name, r.Kind, + "["+vecToSQL(vecs[k])+"]") + must(err) + } + if i == 0 || i/(*batchFlag*25) > (i-*batchFlag)/(*batchFlag*25) { + log.Printf("%d/%d embedded (%.1f%%)", j, total, 100.0*float64(j)/float64(total)) + } + time.Sleep(time.Second / time.Duration(*qpsFlag)) + } + + log.Printf("creating HNSW index (vector_cosine_ops) on poi_vec…") + exec(db, `CREATE INDEX IF NOT EXISTS poi_vec_hnsw ON poi_vec + USING hnsw (vec vector_cosine_ops)`) + log.Printf("done: %d vectors in poi_vec", len(all)) +} diff --git a/spatial/main.go b/spatial/main.go index 8251969..a17bbf8 100644 --- a/spatial/main.go +++ b/spatial/main.go @@ -20,10 +20,12 @@ package main import ( + "bytes" "database/sql" "encoding/json" "flag" "fmt" + "io" "log" "net/http" "os" @@ -37,8 +39,130 @@ import ( var ( dsn *sql.DB addr string + // /semantic: embed the query with the local zembed model, cosine-search poi_vec + embedBase = getenvDefault("EMBED_BASE", "http://192.168.3.7:1234/v1") + embedModel = getenvDefault("EMBED_MODEL", "zembed-1-Q4_K_M") ) +func getenvDefault(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +// embedOne asks the llama.cpp OpenAI-compatible /embeddings endpoint for a +// single text. Zembed uses a "query: " prompt prefix for search queries. +func embedOne(text string) ([]float32, error) { + body, _ := json.Marshal(map[string]any{"model": embedModel, "input": []string{"query: " + text}}) + req, err := http.NewRequest("POST", embedBase+"/embeddings", bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 60 * time.Second} + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != 200 { + b, _ := io.ReadAll(io.LimitReader(res.Body, 300)) + return nil, fmt.Errorf("embed http %d: %s", res.StatusCode, b) + } + var j struct { + Data []struct { + Embedding []float32 `json:"embedding"` + } `json:"data"` + } + if err := json.NewDecoder(res.Body).Decode(&j); err != nil { + return nil, err + } + if len(j.Data) == 0 || len(j.Data[0].Embedding) == 0 { + return nil, fmt.Errorf("no embedding in response") + } + return j.Data[0].Embedding, nil +} + +// vecLiteral renders [0.1, -0.2] as the pgvector text literal [0.1,-0.2] +func vecLiteral(v []float32) string { + parts := make([]string, len(v)) + for i, f := range v { + parts[i] = strconv.FormatFloat(float64(f), 'g', 6, 32) + } + return "[" + strings.Join(parts, ",") + "]" +} + +func handleSemantic(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + text := strings.TrimSpace(first(q, "q")) + if text == "" { + jerr(w, 400, "q is required") + return + } + vec, err := embedOne(text) + if err != nil { + jerr(w, 502, "embedding failed: "+err.Error()) + return + } + // lib/pq has no []float32 → vector codec: send the literal "[f1,f2,…]" + // as text and cast with ::vector + where, args := filters(q) // extract/kind/name filters still apply + args = append(args, vecLiteral(vec)) + vecTok := "@@" + strconv.Itoa(len(args)) + "@@" + args = append(args, limitOf(q)) + limTok := "@@" + strconv.Itoa(len(args)) + "@@" + // poi_vec carries (extract, osm_type, osm_id, name, kind, vec); the JOIN + // back to poi yields the geometry (for map pins) and the tag facts. + query := `SELECT p.extract, p.name, p.kind, + ST_Y(p.geom) AS lat, ST_X(p.geom) AS lng, + p.opening_hours, p.fee, p.website, + 1 - (v.vec <=> ` + vecTok + `::vector)::float8 AS score + FROM poi_vec v + JOIN poi p ON p.extract = v.extract AND p.osm_type = v.osm_type AND p.osm_id = v.osm_id` + where + ` + ORDER BY v.vec <=> ` + vecTok + `::vector + LIMIT ` + limTok + query = renumber(query, 0) // no body placeholders here; just resolve tokens + rows, err := dsn.Query(query, args...) + if err != nil { + jerr(w, 502, "query: "+err.Error()) + return + } + var out []map[string]any + for rows.Next() { + var extract, name, kind string + var lat, lng float64 + var oh, fee, web *string + var score float64 + if err := rows.Scan(&extract, &name, &kind, &lat, &lng, &oh, &fee, &web, &score); err != nil { + rows.Close() + jerr(w, 502, err.Error()) + return + } + m := map[string]any{"extract": extract, "name": name, "kind": kind, + "lat": lat, "lng": lng, "score": float64(int64(score*10000)) / 10000} + if oh != nil { + m["opening_hours"] = *oh + } + if fee != nil { + m["fee"] = *fee + } + if web != nil { + m["website"] = *web + } + out = append(out, m) + } + rows.Close() + if out == nil { + out = []map[string]any{} + } + cors(w) + b, _ := json.Marshal(out) + fmt.Fprintf(w, `{"count":%d,"results":`, len(out)) + w.Write(b) + w.Write([]byte("}")) +} + type poi struct { Extract string `json:"extract"` OsmID int64 `json:"osm_id"` @@ -49,6 +173,8 @@ type poi struct { Fee *string `json:"fee,omitempty"` Website *string `json:"website,omitempty"` AddrCity *string `json:"addr_city,omitempty"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` DistM float64 `json:"dist_m"` } @@ -79,6 +205,7 @@ func main() { mux.HandleFunc("/near", handleNear) mux.HandleFunc("/nearest", handleNearest) mux.HandleFunc("/corridor", handleCorridor) + mux.HandleFunc("/semantic", handleSemantic) 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 { @@ -169,7 +296,7 @@ func scanPois(rs *sql.Rows) ([]poi, error) { for rs.Next() { var p poi if err := rs.Scan(&p.Extract, &p.OsmID, &p.OsmType, &p.Name, &p.Kind, - &p.OpenH, &p.Fee, &p.Website, &p.AddrCity, &p.DistM); err != nil { + &p.OpenH, &p.Fee, &p.Website, &p.AddrCity, &p.Lat, &p.Lng, &p.DistM); err != nil { return nil, err } out = append(out, p) @@ -217,8 +344,10 @@ func handleHealth(w http.ResponseWriter, r *http.Request) { w.Write([]byte("}")) } +// +lat/lng so the client can drop map pins on the results const poiCols = `poi.extract, poi.osm_id, poi.osm_type, poi.name, poi.kind, - poi.opening_hours, poi.fee, poi.website, poi.addr_city` + poi.opening_hours, poi.fee, poi.website, poi.addr_city, + ST_Y(poi.geom) AS lat, ST_X(poi.geom) AS lng` func handleNear(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() @@ -301,7 +430,7 @@ func handleCorridor(w http.ResponseWriter, r *http.Request) { where, args := filters(q) var coords []string for _, p := range pts { - coords = append(coords, fmt.Sprintf("%s %s", strconv.FormatFloat(p[0], 'f', 6, 64), strconv.FormatFloat(p[1], 'f', 6, 64))) // WKT: lng lat + coords = append(coords, fmt.Sprintf("%s %s", strconv.FormatFloat(p[0], 'f', 6, 64), strconv.FormatFloat(p[1], 'f', 6, 64))) // WKT: lng lat } line := "LINESTRING(" + strings.Join(coords, ",") + ")" args = append(args, line, rad, limitOf(q)) @@ -396,7 +525,7 @@ func renumber(query string, n int) string { if end >= 0 { if d, err := strconv.Atoi(query[i+2 : i+2+end]); err == nil { fmt.Fprintf(&b, "$%d", d) - i += 2 + end + 1 // loop's i++ finishes the last '@' + i += 2 + end + 1 // loop's i++ finishes the last '@' continue } }