← Voltar para o blog
·9 min de leitura

One Database, Two Search Engines: MongoDB Atlas Search for RAG

Part 4 of a 5-part series building a production-shaped RAG API on FastAPI + MongoDB Atlas

One Database, Two Search Engines: MongoDB Atlas Search for RAG

Parts 2 and 3 leaned on MongoDB doing two very different jobs: storing and querying high-dimensional vectors, and running BM25 full-text search — over the same collection, fused in one query. This post is about that substrate. The headline argument for using Atlas here isn't that it's a good vector database; it's that it's a good vector database and a good text search engine and your operational document store, all in one place, so your embeddings live right next to the data they describe and you don't run three systems to ship one feature.

We'll cover the async connection, the architecture under Atlas Search, the two index types the pipeline needs, and the operational sharp edges — including one important detail the repo's setup instructions get almost right.

The connection: Motor, async, and a TLS gotcha

The whole database layer is one client and one accessor:

# app/db/mongo.py
import os, certifi
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
from dotenv import load_dotenv

load_dotenv()

MONGODB_URI = os.getenv("MONGODB_URI", "mongodb://localhost:27017")

client = AsyncIOMotorClient(
    MONGODB_URI,
    uuidRepresentation="standard",
    tlsCAFile=certifi.where(),    # explicit CA bundle for Atlas TLS
)

def get_db() -> AsyncIOMotorDatabase:
    return client[os.getenv("MONGODB_DB", "curso_api")]

Motor is MongoDB's async driver — the non-blocking counterpart to PyMongo — which is what lets FastAPI's async def handlers await database calls without blocking the event loop. (Note the contrast with Part 1: the database calls are properly async; the OpenAI calls in this codebase are not, which is the one real concurrency wart we'll revisit in Part 5.)

Two constructor arguments earn their place. uuidRepresentation="standard" keeps UUID encoding interoperable across drivers. tlsCAFile=certifi.where() hands the driver an explicit, up-to-date certificate-authority bundle — this is the fix for the maddening Atlas connection errors that show up on some Python/OpenSSL builds where the system CA store can't validate Atlas's TLS certificate. If you've ever hit an SSL handshake failure connecting to mongodb+srv://..., this line is the cure.

get_db() is designed to be a FastAPI dependency: db = Depends(get_db) injects the database handle into any route, which keeps the data layer swappable and testable.

What "Atlas Search" actually is

A point that confuses people: regular MongoDB queries and Atlas Search are different engines. A normal find() or $match runs against MongoDB's own B-tree indexes. Atlas Search — both full-text and vector — runs on a companion process called mongot, built on Apache Lucene, that lives alongside the database and maintains its own search indexes. When you write $search or $vectorSearch, the query is handed off to mongot, which is why these are available on Atlas (and on local deployments via the Atlas CLI) rather than on a bare mongod.

This matters for two reasons. First, search indexes are separate objects with their own definitions and lifecycle — you create them through a different API than ordinary indexes, and they build asynchronously. Second, it's why one collection can serve both vector and lexical search: mongot indexes the same documents two ways, and $rankFusion queries both.

The pipeline needs two search indexes on the chunks collection, one of each kind.

Index 1 — the vector index (and the legacy-vs-current trap)

This is the index $vectorSearch queries. And here's the detail worth slowing down for, because the repo's README and the current MongoDB docs disagree — both "work," but only one is the supported path.

The repo's setup instructions create the index using the older Atlas Search syntax, with a mappings block and the knnVector field type:

// ⚠️ legacy syntax — knnVector is deprecated
{
  name: "vector_index",
  definition: {
    mappings: {
      dynamic: true,
      fields: {
        embeddings: {
          type: "knnVector",
          dimensions: 3072,        // for text-embedding-3-large
          similarity: "cosine"
        }
      }
    }
  }
}

MongoDB has since moved vector search to General Availability with a dedicated index type. The knnVector field type (and the old knnBeta operator) are deprecated. The current, supported form is a top-level type: "vectorSearch" index with a flat fields array:

// ✅ current syntax — vectorSearch index type
{
  name: "vector_index",
  type: "vectorSearch",
  fields: [
    {
      type: "vector",
      path: "embeddings",
      numDimensions: 3072,        // note: numDimensions, not dimensions
      similarity: "cosine"        // cosine | euclidean | dotProduct
    },
    {
      type: "filter",            // index document_id so $vectorSearch can pre-filter on it
      path: "document_id"
    }
  ]
}

Three differences to internalize, because they're exactly the things that break a copy-pasted setup: the type is vectorSearch, the dimension key is numDimensions (not dimensions), and filterable fields are declared with type: "filter". That last one matters for this codebase specifically — every query filters by document_id, and for $vectorSearch to pre-filter efficiently, document_id should be indexed as a filter field in the vector index. The legacy dynamic: true mapping indexes everything implicitly; the explicit form makes you declare it, which is the better habit.

The constants that must line up no matter which syntax you use:

  • numDimensions must equal your embedding model's output. 3072 for text-embedding-3-large. Switch to text-embedding-3-small (1536) or a local model like nomic-embed-text (768) and you must drop and recreate this index with the new number — and re-embed every chunk, because vectors from different models aren't comparable. This is the single most common "why does every query 400?" bug.
  • similarity should match how the embeddings were trained. cosine is the right default for OpenAI embeddings (and most others); the alternatives are euclidean and dotProduct.

Under the hood Atlas builds an HNSW graph for approximate nearest-neighbor search, which is what makes vector queries fast without scanning every vector. At larger scale you can also enable scalar or binary quantization to compress the stored vectors and shrink the memory footprint, trading a little recall for big storage and speed wins — a knob you don't need on day one but will want when the collection grows.

Index 2 — the text index (for BM25)

For the lexical half of hybrid search, $search needs a full-text index. The repo creates a focused one over just the fields it queries:

{
  name: "text_index",
  definition: {
    mappings: {
      dynamic: false,
      fields: {
        chunk: { type: "string" },          // BM25 over the chunk text
        document_id: { type: "objectId" }   // exact-match filter
      }
    }
  }
}

dynamic: false is a deliberate, good choice: index only what you search, not every field in every document. The chunk field is indexed as string, which runs it through Lucene's text analysis (tokenization, lowercasing, and so on) so BM25 can score term matches. document_id is indexed as objectId so the equals filter in the $search query can scope results to one document — the lexical-side equivalent of the vector index's filter field.

A note on analyzers: the default analyzer handles general English text fine, but Atlas Search lets you choose language-specific or custom analyzers (stemming, stop-word handling, n-grams). For a multilingual corpus — and this project's author works in Portuguese as well as English — matching the analyzer to the content language measurably improves lexical recall. It's an easy lever that's easy to forget.

How the two indexes coexist under $rankFusion

This is the payoff of putting everything in one database. The chunks collection now carries:

chunks: { _id, document_id, chunk, embeddings }
           │         │         │        └── indexed by vector_index  (HNSW, 3072-dim)
           │         │         └─────────── indexed by text_index    (Lucene BM25)
           │         └───────────────────── filter field in BOTH indexes
           └─────────────────────────────── the MongoDB _id

When $rankFusion runs, its vector sub-pipeline hits vector_index and its text sub-pipeline hits text_index — two engines, one collection, one query, results fused with RRF (Part 3). The same document_id filter is applied on both sides, so document and per-user isolation hold uniformly. No $lookup, no cross-system joins, no second datastore to keep in sync. Your operational data, your full-text index, and your vector index are the same documents.

Operational sharp edges

A few things that separate a demo from something you'd run:

Version requirements. $vectorSearch needs Atlas (or a CLI-managed local deployment) on a recent MongoDB version; the $rankFusion operator needs 8.0+, and fusing $search with $vectorSearch under it needs Atlas 8.1+. Check your cluster version before assuming hybrid search is available — this is the first thing to verify when $rankFusion throws an "unrecognized stage" error.

Indexes build asynchronously. Creating a search index returns immediately, but the index isn't queryable until it finishes building. Right after upload, queries can come back empty simply because the index is still catching up. The repo's own troubleshooting note — "404 No context found: check the vector index exists and chunks were inserted" — is usually this, or a dimension mismatch.

Filter before you search, and index what you filter. Pre-filtering by document_id shrinks the candidate set the expensive similarity search runs over, which is both faster and a hard security boundary. But it only works efficiently if document_id is a declared filter field in the vector index and an indexed field in the text index. Filtering on an unindexed field is slow or silently ignored.

Ordinary indexes still matter. Atlas Search covers retrieval, but the operational queries around it don't run on mongot. The repo's notes flag the missing ones worth adding: a unique index on users.email (so two accounts can't share an email), and plain indexes on documents.owner and chunks.document_id to keep the ownership lookups and chunk deletions fast as data grows.

Quantization and scale. On a small collection, defaults are fine. As you grow into millions of vectors, scalar/binary quantization and careful numCandidates tuning become the levers that keep latency and memory in check.

The bigger picture

The reason this architecture is worth learning isn't any single feature — it's the consolidation. A typical RAG stack bolts a dedicated vector database onto a separate search engine onto your primary datastore, and now you own three systems, three sets of credentials, and the eternal problem of keeping them consistent. Atlas folds vector search, full-text search, and your operational documents into one platform with one security and availability model. For a system like this one — where every chunk needs a vector, a text index, and a foreign key back to a document and an owner, all queried together — that consolidation is the whole point.

With the data layer understood, we have all five pieces: the LLM client, chunking and storage, vector search, hybrid search and reranking, and the Atlas indexes underneath. Part 5 assembles them into the actual request lifecycle of fastapi-basic-rag — the two endpoints, the orchestration, the auth, and the honest list of what you'd harden before production.


Coming up in this series

  1. Putting it all together — the full fastapi-basic-rag request lifecycle, end to end, plus the production hardening checklist.

References