How search works (hybrid keyword + semantic)

Type natural language, quoted phrases, or "-word" exclusions. Kodori combines exact-term matching with concept matching.

Updated 2026-04-30

/search is the fastest way to find a document. Kodori runs two retrieval paths in parallel and fuses the results:

- keyword (Postgres FTS) — exact-term and quoted-phrase matching, matches over names, metadata, and extracted text. Supports "phrase queries", -exclusion, and OR keyword. - semantic (vector embeddings) — concept matching. "agreements about confidentiality" surfaces an NDA even if the word "NDA" doesn't appear.

Results are combined via Reciprocal Rank Fusion. Each hit shows a "source" badge — keyword / semantic / both — so you can see why a result surfaced.

Filters above the result list narrow by sensitivity (public → regulated) or MIME family (PDF / image / Office / text / email). Narrowing applies to the fused list, so you don't lose semantic-only matches by filtering. The sensitivity filter is index-backed at scale (D285) — selecting confidential / restricted / regulated narrows to a partial GIN index that's roughly 1/10th the size of the full FTS index for the typical sensitivity distribution, making compliance queries ~10× faster at 100M-doc-tenant scale. The keyword leg of hybrid uses the partial directly; the semantic leg post-filters using metadata that the hybrid layer already loads.

If your workspace has more than 1M live documents and you run an unfiltered text-only query, /search surfaces a non-blocking inline tip (D286) suggesting that adding a sensitivity or MIME filter will return faster, more relevant results. The query still runs unfiltered — the tip is a suggestion, not a block. Operators sometimes legitimately need to scan everything (auditor evidence packets, retention sweeps); the tip respects that.

Workspace owners and admins can see the actual P95 search latency for their tenant on /admin/queue-depth (D290) — sampled per query kind (searchKeyword / hybridSearch / semanticSearch) over the last 7 days, with amber / red banding when latency drifts above 500ms / 2000ms. Sustained red P95 means search is over the comfort zone for the tenant's volume — narrowing queries with sensitivity / MIME filters (D285) is the first lever; tier upgrade is the second.

**Recency-narrowed semantic search (D291).** When a search call sets the optional `recencyWindowDays` parameter, semanticSearch + hybridSearch narrow the chunk lookup to embeddings created within the last N days — Postgres picks a partial HNSW index (`document_chunks_embedding_recent_idx`) that's dramatically smaller than the global index. Most matter / project / "what changed?" queries land in the trailing year of activity; recency narrowing keeps those queries sub-second at 100M-doc-tenant scale. Full-history retrieval still works without the filter — auditor evidence packets and FRCP discovery use the unfiltered path.

Save a query and its filters as a personal "saved search" by clicking "Save this search". Saved entries reappear as one-click chips at the top of /search.

**CSV export.** Whenever a query is active, the "Export CSV ↓" button next to "Save this search" streams the matching docs as RFC 4180 CSV (up to 1000 rows). Same column shape as the saved-search export: documentId, displayName, mimeType, sensitivityLabel, sizeBytes, currentVersionHash, createdAt, lastModifiedAt. Permission-trimmed (you only see rows you can read). Postgres FTS only — semantic-only matches that don't hit the FTS query don't appear in the export. Active sensitivity + mime-family filters carry through. Useful when the dominant flow is "I just typed this query, give me the rows" — no need to save the search first. For recurring exports against the same criterion, save the search and use the "Export matching docs to CSV ↓" link on /search/alerts (D172) so the audit chain captures the recurring activity.