trips/spatial/embed/main.go
Greg Pomerantz 7efd2567b2 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
2026-09-10 14:08:53 -04:00

212 lines
6.0 KiB
Go

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