The pure-Go trade-off
suchi’s binary is deliberately CGO-free — modernc.org/sqlite is a pure-Go SQLite. Adding sqlite-vec (a C extension) breaks that invariant unless we:- Option A: load as a runtime extension. Ship a
SUCHI_VEC_PATHenv pointing atlibsqlite_vec.so; the operator installs the extension themselves. Binary stays pure-Go; feature is opt-in. - Option B: fork modernc/sqlite to link vec statically. Enormous lift; not on the table.
- Option C: skip sqlite-vec, use pure-Go BM25 more-like-this on top FTS5 terms. Cheaper recall but zero new deps.
Data model
New migration0026_document_embeddings.sql:
Ingest hook
New pipeline step undercore/pipeline/embed/:
- Runs post-content (after OCR text lands in
documents.content). - Takes the first ~4KB of
content(embeddings on the full text waste tokens on boilerplate — the intro paragraph carries most of the signal). - Hits
POST {LLM_ENDPOINT}/embeddingswith the standard OpenAI shape. - On success:
INSERT OR REPLACE INTO document_embeddings(...).INSERT OR REPLACE INTO vec_documents(rowid, embedding) VALUES (doc_id, ?)— only when vec0 loaded; skip otherwise.
- On endpoint failure: log Warn, skip. Doc still ingests.
suchi embeddings --backfill,
similar to suchi refile.
HTTP surface
SELECT rowid, distance FROM vec_documents WHERE embedding MATCH (SELECT embedding FROM vec_documents WHERE rowid=?) LIMIT ?. Sub-millisecond at homelab scale.
Fallback (no vec): pull the top 20 FTS5 terms from the source doc’s
content, run MATCH for each ORed together, return the top N ranked
by BM25. Not semantic, but a useful “more like this” for docs that
share domain vocabulary.
SPA integration
DocumentDetail route grows a “Similar documents” strip below the metadata card. Every card is a thumbnail (via#127) + title +
score. Card click → navigate to that doc.
No SPA change needed for the search route in v1; a “semantic mode”
toggle on Search lands as follow-up (v2) — that’s the harder UX
question (semantic + FTS blended results with rank fusion).
Config surface
SUCHI_VEC_PATH— path tolibsqlite_vec.so(empty disables).SUCHI_VEC_DIM— embedding dimension; must match the endpoint’s model. Default 768 (Ollama nomic-embed-text).SUCHI_VEC_MODEL— model identifier stored ondocument_embeddings.modelfor audit. Default"nomic-embed-text:v1.5".
suchi doctor prints the vec status: ✓ sqlite-vec loaded (dim 768)
or · sqlite-vec disabled (SUCHI_VEC_PATH unset).
Privacy invariants
- Embeddings never leave the server outside the request to the operator-acknowledged LLM endpoint. Stored embeddings + retrieval are 100% local.
- A doc marked
sensitivity IN ('confidential','restricted')still gets embedded but is excluded from the retrieval pool by default (same rule as the ask endpoint indocs/wishlist/qa-over-archive).
What’s NOT in scope
- Chunk-level embeddings. One embedding per doc is enough for
the “similar docs” affordance. Chunk-per-page + retrieval is the
layer that makes ask (
#120) work well; that lives in that doc. - Semantic + FTS rank fusion. Follow-up when semantic search gets its own UI. v1 exposes vec-only or fts-only, not blended.
- Re-embed on doc.content change. Post-consume script edits +
automations can rewrite content; we don’t re-embed for cost. A
--forceflag on the backfill CLI can re-embed one doc.
Rollout
- Design doc lands ✅ (this file).
GET /api/documents/{id}/similarFTS5 fallback shipped ✅. Pure-Go BM25 more-like-this atcore/api/documents_similar.go; returnsmethod: "fts"on every response. Works today with no new dependency.- Migration +
document_embeddingstable. (Pending.) core/pipeline/embedpackage with the LLM-endpoint call + vec0 insert. BehindSUCHI_VEC_PATHgate. (Pending.)- When vec0 loads at boot, the endpoint prefers vec0 and reports
method: "vec"; missing extension falls back to the FTS5 path already in production. (Pending.) suchi embeddings --backfillCLI. (Pending.)- SPA DocumentDetail “Similar” strip — can consume the FTS5 shape today; the strip’s quality improves silently when vec0 lands.
- Docs: config.mdx new env rows, cli.mdx new verb.