Skip to main content
A “similar documents” affordance on a doc’s detail page is a well-established DMS feature and pairs naturally with sqlite-vec embeddings. suchi already has the LLM classifier wired against an operator-acknowledged endpoint, so the embedding call reuses the same egress the operator already acknowledged — no new external dependency. Status: planned, flag-gated. This doc captures the shape before the code lands so the trade-offs are visible.

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_PATH env pointing at libsqlite_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.
The plan is A for the main feature and C as the always-on fallback. An instance without the vec extension loaded still shows “similar documents” — the results are just less semantic.

Data model

New migration 0026_document_embeddings.sql:

Ingest hook

New pipeline step under core/pipeline/embed/:
  1. Runs post-content (after OCR text lands in documents.content).
  2. Takes the first ~4KB of content (embeddings on the full text waste tokens on boilerplate — the intro paragraph carries most of the signal).
  3. Hits POST {LLM_ENDPOINT}/embeddings with the standard OpenAI shape.
  4. 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.
  5. On endpoint failure: log Warn, skip. Doc still ingests.
Backfill for pre-migration docs: suchi embeddings --backfill, similar to suchi refile.

HTTP surface

Behind vec0: 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 to libsqlite_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 on document_embeddings.model for 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 in docs/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 --force flag on the backfill CLI can re-embed one doc.

Rollout

  1. Design doc lands ✅ (this file).
  2. GET /api/documents/{id}/similar FTS5 fallback shipped ✅. Pure-Go BM25 more-like-this at core/api/documents_similar.go; returns method: "fts" on every response. Works today with no new dependency.
  3. Migration + document_embeddings table. (Pending.)
  4. core/pipeline/embed package with the LLM-endpoint call + vec0 insert. Behind SUCHI_VEC_PATH gate. (Pending.)
  5. 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.)
  6. suchi embeddings --backfill CLI. (Pending.)
  7. SPA DocumentDetail “Similar” strip — can consume the FTS5 shape today; the strip’s quality improves silently when vec0 lands.
  8. Docs: config.mdx new env rows, cli.mdx new verb.
The current bar for merging is: an instance that never loads vec0 still passes every existing test. sqlite-vec doesn’t become a hard dependency of the binary — the day it can’t be built, the archive still works.