Guide 15 — MongoDB Atlas Vector Search¶
This guide walks the production switchover from Python cosine search to
MongoDB Atlas $vectorSearch, including a load-bearing nuance that adopters
who miss it will silently leak results across tenants.
READ THIS FIRST. Atlas's
$vectorSearch.filterclause is index-based pre-filtering. If the index does not register the filtered field, the filter silently becomes a no-op. Adopters routinely create the index via the Atlas UI wizard, which does not add filter fields by default. The_search_atlashelper insideMongoVectorBackendships with a trailing$matchstage that catches this mis-configuration at the aggregation level — but you must ALSO add thetenant_idfilter field to the index when you create it. Theensure_vector_search_indexhelper does this for you; hand-rolled UI flows do not. See § Load-bearing $match below.
1. Cluster + index setup¶
Atlas Vector Search requires:
- An Atlas cluster on tier M10 or higher (Vector Search is unsupported on M0/M2/M5/Shared).
- A vector-search index on the collection holding your embeddings.
Option A — ensure_vector_search_index (recommended)¶
import asyncio
from symfonic.memory.backends.mongodb import MongoVectorBackend
backend = MongoVectorBackend(
uri="mongodb+srv://...mongodb.net/",
database="symfonic",
collection="vectors",
vector_search_mode="atlas",
atlas_index_name="symfonic_vector_idx",
)
asyncio.run(backend.ensure_vector_search_index(
dimensions=1536, # e.g. OpenAI text-embedding-3-small
similarity="cosine", # match your embedding model's training objective
))
The helper is idempotent. Safe to call on every boot. It reads
list_search_indexes() and short-circuits if atlas_index_name already
exists.
When the index is missing, it creates one with two fields:
embeddingas avectorfield with the supplied dimension + similarity.tenant_idas afilterfield — this is the load-bearing piece.
Option B — Atlas UI¶
If you must create the index via the Atlas UI, ensure the index definition JSON includes BOTH fields:
{
"name": "symfonic_vector_idx",
"type": "vectorSearch",
"definition": {
"fields": [
{
"type": "vector",
"path": "embedding",
"numDimensions": 1536,
"similarity": "cosine"
},
{
"type": "filter",
"path": "tenant_id"
}
]
}
}
The default wizard fills in only the vector field. Add the filter field explicitly or the next section bites you.
2. Load-bearing $match¶
MongoVectorBackend._search_atlas issues a three-stage aggregation pipeline:
[
{
"$vectorSearch": {
"index": atlas_index_name,
"path": "embedding",
"queryVector": query_embedding,
"filter": {"tenant_id": scope.tenant_id},
"numCandidates": ...,
"limit": top_k,
},
},
{"$match": {"tenant_id": scope.tenant_id}}, # LOAD-BEARING
{"$project": {...}},
]
The second stage looks redundant — $vectorSearch.filter already scoped by
tenant_id. Why repeat it?
Because $vectorSearch.filter is index-based pre-filtering. It requires
tenant_id to be registered as a filter field on the Atlas index. If the
index ships without tenant_id in its fields array:
- No error is raised. The query succeeds.
- The filter clause silently becomes a no-op.
- Results leak across tenants.
The trailing $match stage scopes by tenant_id at the aggregation level,
after $vectorSearch returns its candidates. Even when the index is
mis-configured, no cross-tenant document survives the pipeline. The pre-
filter is the performance optimisation; the $match is the correctness
guarantee.
Do not remove the $match stage under any pressure to "simplify" the
pipeline. The risk is encoded as R-V7.21-C in the v7.21 risk register.
A regression test
(tests/memory/unit/test_mongodb_atlas_mode.py::test_source_contains_match_defence_comment)
parses the source to verify the inline comment is present — re-engaging this
discussion before any future refactor.
3. Tuning numCandidates¶
Atlas's $vectorSearch evaluates numCandidates approximate-nearest-
neighbours server-side, then returns the top limit. Higher numCandidates
trades query latency for recall.
MongoVectorBackend computes:
Defaults:
| Parameter | Default | Effect at top_k=5 |
Effect at top_k=100 |
|---|---|---|---|
atlas_num_candidates_multiplier |
10 | floor wins → 100 | 1000 |
atlas_num_candidates_min |
100 | 100 | 1000 |
Atlas's documentation recommends numCandidates >= 10 × limit for good
recall on production-sized corpora. The defaults match this guideline.
If recall is too low, raise atlas_num_candidates_multiplier. Latency
grows roughly linearly with numCandidates; budget accordingly.
4. Switching over¶
backend = MongoVectorBackend(
uri=os.environ["MONGO_URI"],
vector_search_mode=os.environ.get("SYMFONIC_VECTOR_MODE", "python"),
atlas_index_name=os.environ.get(
"SYMFONIC_ATLAS_INDEX", "symfonic_vector_idx",
),
)
Wire the mode through an env var so dev / staging keep python while
production runs atlas. The search method dispatches on the mode at call
time, so no other code changes are required.
5. Failure modes catalogue¶
| Symptom | Likely cause | Fix |
|---|---|---|
Cross-tenant results in atlas mode |
Atlas index missing tenant_id filter field |
Drop the index, recreate via ensure_vector_search_index or add the filter field via UI. |
ValueError: vector_search_mode must be ... at boot |
Typo in the env var | Set to "python" or "atlas" exactly. |
OperationFailure: index XYZ not found at query time |
Atlas index name mismatch | Verify atlas_index_name matches the actual index. |
| Low recall | numCandidates too small |
Raise atlas_num_candidates_multiplier. |
| High latency | numCandidates too large for cluster size |
Lower atlas_num_candidates_multiplier; upgrade Atlas tier. |
| Scores differ between modes | Atlas's ANN is approximate by construction | Acceptable. If exact scoring matters, stay on python mode. |
6. Related references¶
- Concept: MongoDB Vector Search — Python and Atlas Modes
- v7.21 risk register: R-V7.21-C (load-bearing
$match). - Atlas docs:
$vectorSearchoperator.