Repository navigation
ADR 005 Cosmos Full Text Search Ruled Out
Rejected 2026-07-31. Azure AI Search serves search instead — see Search-and-Retrieval.
Traps 1-5 below are properties of the Cosmos SDK, not of this decision, and they apply to any ranked query.
Cosmos DB for NoSQL added full-text search. DEMI already stores every chunk's content in the
chunks container, so serving search from the same database would have meant no second service, no
second bill and no sync lag.
Base FTS works and is fast: 0.3–0.9 s, ~25 RU per ranked query, roughly $9/month. By far the cheapest option costed.
Rejected, because fuzzy matching is a hard requirement and Cosmos cannot serve it.
The backfill never ran. chunks_fts held 0 rows and is deleted, along with COSMOS_FTS_FUZZY,
searchText, queryRanked/drainRanked and buildSnippet.
demi-cosmos-dev carries EnableNoSQLFullTextSearchPreviewFeatures, the enrolment Microsoft's docs
require. It made no difference. The syntax parses, the term matches, and distance does nothing.
| query | fuzzy | hits |
|---|---|---|
quokkafluxion |
true | 1 — exact still matches |
quokafluxion (1 deletion) |
true | 0 |
riparia / riparians
|
true | 1 — that is the stemmer, not fuzzy |
The decisive case is a nonsense token one edit from an indexed one, which no stemmer can bridge.
Eliminated by measurement, in order: not enrolment; not the app flag; not parameter binding (rewritten
as an inline SQL literal, verified in wwwroot, identical); not query shape; and not a stale index —
the container was deleted and recreated from scratch after enrolment and behaved identically. What
remains is server-side: region gating (canadacentral) or the preview not honouring distance yet.
The first spike reported that fuzzy on real typos (rivver, propnent, climat) returned all 20
hits. 20 was the TOP cap, and three different typos all returning exactly the cap is the signature of
trap 4 below — a ranked query whose full-text predicate matched nothing returns the whole container,
ranked but unfiltered. That reads as "fuzzy works brilliantly" and is in fact "the predicate did
nothing".
A fuzzy probe must use a rare or nonsense term and assert an exact expected count, never a capped one.
None of these raise an error. All return zero rows, and none are documented by Microsoft.
-
fetchNext()returns empty early pages for ranked queries. Measured:fetchAllgave 5 hits at 24.72 RU; onefetchNextgave 0 hits at 4.83 RU withhasMoreResults: true; draining 3 pages gave 5 hits.src/db/cosmos-nosql.js:query()runs exactly onefetchNext()whenevermaxItemCountis set, so every repository method built on it returns nothing for a ranked query. This produced three wrong diagnoses during the spike. -
Index lag after a bulk load. Ranked queries return 0 until
x-ms-documentdb-collection-index-transformation-progress(a container read withpopulateQuotaInfo) reaches 100.FULLTEXTCONTAINSmasks it by scanning;ORDER BY RANKcannot. Poll it before any cutover. -
distance: 3returns 0 rows and does not error. The documented maximum is 2. -
ORDER BY RANKwithout aWHEREreturns everything, ranked but unfiltered. -
A ranked query matching zero rows against a populated container blocks the Node event loop.
FULLTEXTCONTAINSALLwithORDER BY RANKthat matches nothing spins synchronously inside the SDK's client-side merge: the whole app stops answering,/api/configincluded, for minutes, with no error, no crash, no container restart and nothing in any log. It then recovers on its own, which makes it look like a network fault.Verified at index progress 100, and it reproduces with
fuzzy=false, so it is GA surface rather than preview. No client-side timeout can defend against it —AbortSignal.timeout()and elapsed-time checks between pages are both timers, and a blocked event loop runs no timers. Two fixes built on that premise were deployed and failed. The only defence is to not issue the query: run a COUNT with the identical predicate and noORDER BY RANKfirst, and return empty on zero. -
Fuzzy
{term, distance}is a no-op, as above.
These hold regardless of the search backend.
-
Container creation is control-plane. The app's managed identity gets
403 ... cannot be authorized by AAD token in data plane. Containers come from ARM/Bicep only. - A full-text policy is immutable. Enabling FTS means a new container plus a copy. Vector policies are narrower than they read: adding one to an existing but empty container succeeded in place. What cannot change is an existing embedding's dimensions or metric.
-
CONTAINSmatches mid-word. Queryenvreturned "Grenville to Kincolith Road".STARTSWITHmatches only the start of a field, so neither one gives token-prefix behaviour on its own; that needsSTARTSWITH(f,q) OR CONTAINS(f,' '+q). - Ranked queries cannot page by continuation token, so chunk search is TOP-N with no pagination.
-
Metadata search on live containers with no FTS policy (pure
STARTSWITH/CONTAINS) is cheap, measured 2026-07-31: 393 projects at 3–6.5 RU / 61–70 ms; 60,578 documentsSTARTSWITH9.23 RU / 75 ms,CONTAINS67.7 RU / 81 ms, multi-field OR with ACL 39.3 RU / 62 ms, project-scoped 15.1 RU / 34 ms. -
Chunks are durable in Cosmos regardless of backend.
src/controllers/nosql/document.jswrites the fullcontentinto thechunkscontainer. The search backend is a query layer over it, which is what made replacing it a configuration change rather than a data migration.
- Search-and-Retrieval — what was chosen instead
- ADR-001-Typesense-for-DFL-Search — the backend before this one, also superseded
- Environment Reality & Gotchas
- Architecture
- Seeding — filling Cosmos from Track and Eagle
- Sources and Status — every source, one state
- Search & Retrieval — overview
- Search Index Reference
- Search Query Construction
- Search Measurements
- Extraction Pipeline
- Extraction Quality — coverage, word-joining, tiling
- Identity Model — which id means what
- Attribute-Level Access — field-visibility levels, catalog
- Sync Architecture — how records get in
- Eagle Mirror Reference — mirrored kinds, refusals, drift classes
- ENGAGE Rows Reference — ENGAGE-owned comment periods, reconcile
- Public-Read Backfill — filling and repairing the mirror containers
-
Short Links —
/s/{code}redirect and/api/links -
Recent Uploads —
/api/documents/recent-uploads, eagle-public homepage panel - Reports — comment export and BCGW CSV reads
-
User Data — saved map areas, preferences,
/meroutes -
Bulk Download —
/api/bulk-downloads, zip jobs - Bulk Download: Operations — enable, poison-queue runbook
- Document Intake — upload link, malware scan, release
- Connecting an Application to DEMI
- Azure Environments
- Document Backup — archive copy of originals, run and check
- Document Backup: Revert and Drill — put documents back from the archive
- PDF Titles — public name written into stored PDFs, run, restore
- Secret Sync — Key Vault, secret-sync Function app, required names
-
Operating DEMI in Azure — deploy, verify
- Operating the DEMI Data Plane — devbox access, probes, audit and cost reads
- Edge ban detector — hourly timer, modes, kill switch, soak and promote
-
CI Workflows — every GitHub Actions workflow, trigger, identity, roles
- CI Deploy Workflows — eagle-demi staging and production deploys
- CI Admin Frontend Workflows — eagle-demi-admin checks and deploys
- Release Process — cutting, tagging, shipping and rolling back a version
- Runbook: Search Outage — index/app schema drift, diagnose, fix, roll back
- BC Gov Landing Zone
- Business Rules
- EPIC.search Business Rules — where DEMI differs from EPIC.search
- 001: Typesense for DFL Search — superseded
- 002: Async Processing (MongoDB Queue) — superseded
- 003: Rule-Based Classification
- 004: Read ACL Authorization Model
- 005: Cosmos Full-Text Search Ruled Out
- 006: AI Summarization over BM25
- 007: Service-to-Service Credentials
- 008: Audit and Usage Analytics Store
- 009: Track Feed — proposed
- 010: Attribute-Level Access Control
- 011: Public Read Surface
- 012: Public Config Surface
- 013: Stored Project Summaries — proposed
- 014: Sync-out to Eagle
- 015: Malware scanning on intake