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:
parent
e28219d2e7
commit
7efd2567b2
51
mock/app.js
51
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: '<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);
|
||||
function tempShow(s) {
|
||||
if (stopById(s.id)) return;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
5
spatial/20-vector.sql
Normal file
5
spatial/20-vector.sql
Normal 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;
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
15
spatial/db/Dockerfile
Normal file
15
spatial/db/Dockerfile
Normal 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
77
spatial/db/pgdg.asc
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
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
|
||||
=DA1T
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
211
spatial/embed/main.go
Normal file
211
spatial/embed/main.go
Normal 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))
|
||||
}
|
||||
133
spatial/main.go
133
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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user