Semantic search — Design
Date: 2026-07-01
Status: Approved (design); pending implementation plan.
Goal
Let users search documents by meaning, not just keywords, by ranking the existing pgvector
document_embeddings index against an embedded query. Delivered as a Keyword | Smart toggle
on the existing /search page, gated by the existing semantic module (so that module now
covers both smart filing and semantic search — one "Semantic Intelligence" value prop over one
embeddings engine). Fully air-gapped: embeddings only, no LLM.
Decisions (locked during brainstorming)
- Toggle, not replace or blend. A
Keyword | Smartmode switch on/search. Keyword =
today's tsvector search (core, unchanged). Smart = meaning-based (premium). Hybrid
keyword+semantic blending is a documented future, not v1. - Gated by the existing
semanticmodule — reuses the samedocument_embeddingsindex,
fastembed sidecar, and backfill pipeline that module already provisions. Whensemanticis
unlicensed the Smart toggle is hidden and search is keyword-only (no change, no new plumbing). - Reuse the suggest-folder pattern (query →
Embedder.Embed(kind=query)→ cosine rank) — but
this time the HNSW index is usable because we rank stored per-document embeddings, not
on-the-fly centroids. - ACL-filtered to readable docs, mirroring keyword search (
aclPredicate, access_mode ≥ 1). - Filters (Classification, ExpiredOnly) carry into Smart mode; results show a match-%
badge per row.
Reused (existing) systems — do NOT rebuild
- Keyword search:
GET/POST /api/v1/documents/search(Searchtitle-only +SearchAdvanced
structured tsvector over title/content_text/tags/doc_type/classification, ACL-filtered) — stays
as the Keyword mode, untouched. - Search UI: the global
CommandBar(header →/search?q=) andSearchResultsPage.tsx
(useSearch→POST /documents/search, Classification + ExpiredOnly filters,DocumentRow
results table). The toggle is added here. - Embeddings:
document_embeddings(document_id pk, version, model, embedding vector(384), updated_at)with the HNSW cosine indexdocument_embeddings_ann USING hnsw (embedding vector_cosine_ops)—ORDER BY embedding <=> $query::vectoruses it directly. - Semantic context:
internal/semantic/app.Service(Embedder, Store,SuggestFolders,
cosine, the liveEnabled()license predicate) +internal/semantic/adaptersStore + the
formatVector/parseVectorcodec — extended, not duplicated. - ACL:
aclPredicate(subjects, bypass, n)(dms adapters) = theEXISTSoverdocument_acl_read
ataccess_mode >= 1that keyword search already uses; the semantic query reuses the same shape.
Component 1 — Backend: rank documents by embedding
Service method on semantic.Service (mirrors SuggestFolders):
SearchDocuments(ctx, subjects []kernel.Subject, bypass bool, q SearchQuery, k int) ([]domain.Document, map[string]float64, error)
where SearchQuery{Text string; Classification string; ExpiredOnly bool} — it returns the ranked
documents (top-k) plus a document_id → score map. Empty query or empty index → empty.
Flow: embed q.Text (kind=query) → hand the query vector (as pgvector text) to the dms ranking
method below → the caller's readable docs come back ranked by cosine, over-fetched, trimmed to k
in the Service. (The Service owns the embedding step + trim + score shaping; dms owns the
ACL-correct ranked lookup.)
dms Store/Service method (new — dms owns documents + document_acl_read + aclPredicate):
SearchDocumentsByEmbedding(ctx, subjects, bypass, queryVectorText string, q SearchQuery, limit int)
([]domain.Document, []float64, error) — returns the ranked full documents (reusing the existing
documentColumns select list, so there is no N+1 hydration) and a parallel score slice. The SQL
joins document_embeddings → documents, applies the ACL predicate + filters + deleted_at IS NULL,
and orders by cosine distance:
SELECT <documentColumns>, 1 - (e.embedding <=> $query::vector) AS score
FROM documents d
JOIN document_embeddings e ON e.document_id = d.id
WHERE d.deleted_at IS NULL
AND <acl predicate on d> -- access_mode >= 1 for the caller's subjects (or bypass)
AND ($classification = '' OR d.classification = $classification)
AND ($expiredOnly = false OR (d.expires_at IS NOT NULL AND d.expires_at < now()))
ORDER BY e.embedding <=> $query::vector
LIMIT $overfetch
- ANN + restrictive-filter under-fetch (the one real gotcha): HNSW returns the nearest rows by
vector, then the ACL/filters prune some, so a plainLIMIT kcan return fewer than k readable
results. Mitigation for v1: over-fetch (overfetch = min(k * 5, cap)), raise
hnsw.ef_searchfor the query, then trim to k in Go. pgvector iterative-scan is the documented
scale path. - Score =
1 - cosine_distance(cosine similarity), clamped ≥ 0, for a match-% badge.
ACL placement: the ranking query needs both the pgvector table and the dms aclPredicate /
documents table. Cleanest boundary: the query lives where the ACL predicate lives — the
SearchDocumentsByEmbedding method above is a dms method (dms owns documents +
document_acl_read + aclPredicate + documentColumns). The semantic Service reaches it through a
new method on its existing DocSource port (the narrow interface it already uses to call into dms),
so the semantic package never imports dms internals. The semantic Service owns the embedding step +
trim + score map; dms owns the ACL-correct ranked lookup. (Alternative: the semantic adapter imports
the ACL predicate — rejected; keeps ACL logic in one place.)
Endpoint: POST /api/v1/semantic/search (requireModule("semantic")), body
{query: string, classification?: string, expired_only?: bool, k?: int}. Response reuses the
documents DTO plus scores: {documents: [<raw domain.Document>...], scores: {<doc_id>: <float>}}
(documents in ranked order; scores keyed by id for the badge). Reuses nonNilDocs + the same
document serialization keyword search returns, so the frontend reuses ApiDocument → DocumentRow.
Component 2 — Frontend: the toggle + Smart results
SearchResultsPage.tsxgains a CarbonContentSwitcherKeyword | Smart, rendered only when
moduleEnabled(me.data?.enabledModules, 'semantic')(else keyword-only, unchanged). Default =
Keyword. Mode is local state (optionally reflected in the URL as&mode=smart).- New hook
useSemanticSearch(criteria)(mirrorsuseSearch+useSuggestFolder): POSTs
/api/v1/semantic/searchwith{query, classification, expired_only, k: 50}, maps the response
toDocumentRow[](same mapper as keyword) and attachesscoreper row from thescoresmap. - Smart results render in the same table (same columns), ranked by relevance, with a subtle
match-% badge (reuse the folder-suggestion badge style). The Classification + ExpiredOnly
filters apply in both modes. - Empty/short query and empty results show the existing empty state.
Gating & degradation
- Backend:
requireModule("semantic")onPOST /semantic/search; the ranking is a no-op path when
the module is unlicensed (the route 403s). Keyword search is untouched and always available. - Frontend: the Smart toggle is hidden unless
semantic ∈ enabled_modules; keyword remains the
default. This matches the fail-closed posture of the semantic module. - Only documents that have an embedding appear in Smart results; the existing backfill sweep keeps
the index current, and anything not-yet-embedded is still findable via Keyword.
Air-gapped
Uses the embeddings sidecar only (kind=query) — no LLM, no cloud. Fully on-prem, consistent with
the semantic module.
Out of scope (documented futures)
- Hybrid keyword+semantic blend (reciprocal-rank fusion).
- Semantic search over folders / attachments / letter bodies.
- LLM re-ranking or query expansion (would pull in the
aimodule). - Per-version embeddings (embeddings are per-document today).
Testing / verification
Per repo discipline: never go test (writes the live demo Postgres). Verify via
go build ./... && go vet ./...; cd web tsc + vite; and a deployed e2e:
- File a few documents with distinct content into the demo, ensure they embed (backfill), then
POST /semantic/search {query: "<concept matching one doc>"} and assert that doc ranks top with a
sensible score; assert an ACL-restricted doc does NOT appear for a user who can't read it; assert
Classification/ExpiredOnly filters narrow the results; assert the endpoint 403s when semantic is
unlicensed (license hot-swap) and the Smart toggle is hidden.
- After every deploy assert /me enabled_modules unchanged (the 5 modules) and the demo intact;
clean up test docs. Regenerate web/src/api/schema.ts (npm run gen:api) after the OpenAPI edit.