Skip to content

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.filter clause 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_atlas helper inside MongoVectorBackend ships with a trailing $match stage that catches this mis-configuration at the aggregation level — but you must ALSO add the tenant_id filter field to the index when you create it. The ensure_vector_search_index helper 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.
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:

  1. embedding as a vector field with the supplied dimension + similarity.
  2. tenant_id as a filter field — 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:

numCandidates = max(
    atlas_num_candidates_min,
    atlas_num_candidates_multiplier * top_k,
)

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.