spatial: pgvector semantic POI index + UI map pins

- 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
This commit is contained in:
Greg Pomerantz 2026-09-10 14:08:53 -04:00
parent e28219d2e7
commit 7efd2567b2
9 changed files with 543 additions and 12 deletions

View File

@ -401,7 +401,7 @@ const TOOLS = {
// POI lookup against the local PostGIS extract (native ST_DWithin / KNN / // POI lookup against the local PostGIS extract (native ST_DWithin / KNN /
// corridor queries — real OSM data, not the curated demo pools). // 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' }; 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)); 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)' }; 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(); const j = await rj.json();
if (j.error) return { error: j.error }; if (j.error) return { error: j.error };
return { count: (j.results || []).length, results: (j.results || []).map(x => 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) => { 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' + ' 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' + ' 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' + ' 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' + ' 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' + ' 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' + ' 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; let res;
if (TOOLS[call.name]) res = await Promise.resolve(TOOLS[call.name](call.args || {})); if (TOOLS[call.name]) res = await Promise.resolve(TOOLS[call.name](call.args || {}));
else res = { error: 'unknown tool. available: ' + Object.keys(TOOLS).join(', ') }; 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) }); messages.push({ role: 'user', content: '[tool result for ' + call.name + ']\n' + JSON.stringify(res) });
continue; 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); const ring = L.circleMarker(latlng, { radius: 16, color: '#b5533c', weight: 3, fill: false, opacity: .9 }).addTo(map);
setTimeout(() => map.removeLayer(ring), 1500); 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: '<div class="sp-dot"></div>', 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('<div class="sp-card"><b>' + esc(p.name) + '</b><span class="sp-kind">' + esc(p.kind || '') + '</span>' +
f.map(x => '<div>' + esc(x) + '</div>').join('') + '</div>');
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); const flyTo = s => focusPoint(s.at);
function tempShow(s) { function tempShow(s) {
if (stopById(s.id)) return; if (stopById(s.id)) return;

View File

@ -413,6 +413,13 @@ button { font: inherit; }
.hotel-pin { font-size: 22px; transform: translate(-50%,-90%); transition: transform .15s; } .hotel-pin { font-size: 22px; transform: translate(-50%,-90%); transition: transform .15s; }
.hotel-pin.hotel-alt { filter: grayscale(1); opacity: .75; } .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 */ /* map markers: current-day numbered vs other-day dots */
.mk { background: #b5533c; color: #fff; width: 26px; height: 26px; border-radius: 50%; .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; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px;

5
spatial/20-vector.sql Normal file
View File

@ -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;

View File

@ -8,10 +8,13 @@ nearest, bbox)"). Replaces the flat-file/Overpass approximations.
| Piece | What it is | | 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()` | | `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` | | `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 | | `Dockerfile` | builds spatiald |
## Prerequisites ## 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 /near` | `lat,lng,r(m; default 500)` | `ST_DWithin(geography, …, r)` + KNN ordering |
| `GET /nearest` | `lat,lng` | `ORDER BY geom <-> point` | | `GET /nearest` | `lat,lng` | `ORDER BY geom <-> point` |
| `GET /corridor` | `points=lng,lat;…`, `r` | `ST_DWithin(geom::geography, ST_Buffer(line::geography, r))` | | `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: Examples:
@ -57,8 +66,35 @@ Examples:
curl 'localhost:5005/near?lat=42.35&lng=-71.06&r=1000&kind=restaurant' 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/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/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 ## Data notes
* Built for **osm2pgsql 2.x** (`iboates/osm2pgsql`): tables are * 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 `;`. `url.Parse` drops the tail of a value that contains a raw `;`.
* Multiple extracts coexist in one DB, disambiguated by `poi.extract` * Multiple extracts coexist in one DB, disambiguated by `poi.extract`
(set automatically by `import.sh`). (set automatically by `import.sh`).
* pgvector (semantic POI index, later milestone) gets its own init script + * `lib/pq` has no `[]float32``vector` codec, so `/semantic` sends the
embedding importer — the base image doesn't ship the `vector` package. 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 ## Roadmap hooks
* `bbox` queries: trivial addition (`ST_Contains(ST_MakeEnvelope,…)`). * `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 * Routing-graph join: OSRM/GraphHopper geometries can be loaded into the
same DB (`route_geom` table) for true along-route analytics instead of same DB (`route_geom` table) for true along-route analytics instead of
the current buffer-over-polyline. the current buffer-over-polyline.

15
spatial/db/Dockerfile Normal file
View File

@ -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/*

77
spatial/db/pgdg.asc Normal file
View File

@ -0,0 +1,77 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
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
=DA1T
-----END PGP PUBLIC KEY BLOCK-----

View File

@ -14,7 +14,7 @@
version: "3.8" version: "3.8"
services: services:
postgis: postgis:
image: postgis/postgis:16-3.4 build: ./db # postgis/postgis:16-3.4 + postgresql-16-pgvector (db/Dockerfile)
container_name: trips-postgis container_name: trips-postgis
environment: environment:
POSTGRES_DB: trips POSTGRES_DB: trips
@ -26,6 +26,7 @@ services:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
# initdb scripts run in lexicographic order, once, on an empty volume # initdb scripts run in lexicographic order, once, on an empty volume
- ./schema.sql:/docker-entrypoint-initdb.d/10-schema.sql:ro - ./schema.sql:/docker-entrypoint-initdb.d/10-schema.sql:ro
- ./20-vector.sql:/docker-entrypoint-initdb.d/20-vector.sql:ro
restart: unless-stopped restart: unless-stopped
# One-shot importer (also usable as: docker-compose run --rm importer # One-shot importer (also usable as: docker-compose run --rm importer
@ -49,6 +50,8 @@ services:
container_name: trips-spatiald container_name: trips-spatiald
environment: environment:
SPATIAL_DSN: "host=postgis port=5432 user=trips password=trips dbname=trips sslmode=disable" 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: ports:
- "5005:5005" - "5005:5005"
depends_on: depends_on:

211
spatial/embed/main.go Normal file
View File

@ -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))
}

View File

@ -20,10 +20,12 @@
package main package main
import ( import (
"bytes"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"os" "os"
@ -37,8 +39,130 @@ import (
var ( var (
dsn *sql.DB dsn *sql.DB
addr string 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 { type poi struct {
Extract string `json:"extract"` Extract string `json:"extract"`
OsmID int64 `json:"osm_id"` OsmID int64 `json:"osm_id"`
@ -49,6 +173,8 @@ type poi struct {
Fee *string `json:"fee,omitempty"` Fee *string `json:"fee,omitempty"`
Website *string `json:"website,omitempty"` Website *string `json:"website,omitempty"`
AddrCity *string `json:"addr_city,omitempty"` AddrCity *string `json:"addr_city,omitempty"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
DistM float64 `json:"dist_m"` DistM float64 `json:"dist_m"`
} }
@ -79,6 +205,7 @@ func main() {
mux.HandleFunc("/near", handleNear) mux.HandleFunc("/near", handleNear)
mux.HandleFunc("/nearest", handleNearest) mux.HandleFunc("/nearest", handleNearest)
mux.HandleFunc("/corridor", handleCorridor) mux.HandleFunc("/corridor", handleCorridor)
mux.HandleFunc("/semantic", handleSemantic)
log.Printf("spatiald: listening on %s (dsn: %s)", addr, *dsnFlag) log.Printf("spatiald: listening on %s (dsn: %s)", addr, *dsnFlag)
srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second} srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
if err = srv.ListenAndServe(); err != nil { if err = srv.ListenAndServe(); err != nil {
@ -169,7 +296,7 @@ func scanPois(rs *sql.Rows) ([]poi, error) {
for rs.Next() { for rs.Next() {
var p poi var p poi
if err := rs.Scan(&p.Extract, &p.OsmID, &p.OsmType, &p.Name, &p.Kind, 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 return nil, err
} }
out = append(out, p) out = append(out, p)
@ -217,8 +344,10 @@ func handleHealth(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("}")) 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, 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) { func handleNear(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query() q := r.URL.Query()