- RAG
- MongoDB
- Python
- AI
A Practical RAG Setup with MongoDB Atlas Vector Search
Chunking, embeddings, the vector index and the $vectorSearch query — building retrieval-augmented answers without adding a separate vector database.
Retrieval-augmented generation (RAG) sounds complicated, but the core loop is short: find the passages most relevant to a question, put them in the prompt, and ask the model to answer from them. The hard parts are the details — how you split documents, how you filter, and how you tell whether retrieval is actually working.
If your data already lives in MongoDB, Atlas Vector Search lets you do this without running a separate vector database. That's the setup I use for the AI tutor I work on, alongside Gemini for embeddings and generation.
1. Chunk the source material
Embedding a whole 40-page document as one vector loses almost everything. Split it into chunks that each make sense on their own:
- Split on structure first — headings, sections, paragraphs — and only fall back to a size limit for long sections.
- Aim for a few hundred tokens per chunk. Too small and a chunk lacks context; too large and the vector averages several topics into mush.
- Overlap slightly (a sentence or two) so an idea cut at a boundary still appears whole in one chunk.
- Store metadata with each chunk: source, section title, and anything you'll filter by later, such as subject or course.
Each chunk becomes one document:
{
"_id": "...",
"courseId": "physics-101",
"source": "chapter-3.pdf",
"section": "Newton's second law",
"text": "The acceleration of an object depends on...",
"embedding": [0.0123, -0.0456, ...]
}2. Create embeddings
Use the same embedding model for documents and queries — vectors from different models aren't comparable.
from google import genai
from google.genai import types
client = genai.Client()
def embed(texts: list[str], task: str) -> list[list[float]]:
result = client.models.embed_content(
model="gemini-embedding-001",
contents=texts,
config=types.EmbedContentConfig(
task_type=task,
output_dimensionality=768,
),
)
return [e.values for e in result.embeddings]Two notes:
- Task types matter. Embedding documents as
RETRIEVAL_DOCUMENTand questions asRETRIEVAL_QUERYtunes the vectors for search. - Fewer dimensions are cheaper. 768 dimensions takes a quarter of the storage of 3072, and for most retrieval tasks the quality difference is small. Whatever you pick, the index must match.
3. Define the vector index
In Atlas, create a Vector Search index on the collection. Every field you want to filter on must be declared as a filter field:
{
"fields": [
{
"type": "vector",
"path": "embedding",
"numDimensions": 768,
"similarity": "cosine"
},
{ "type": "filter", "path": "courseId" }
]
}4. Query with $vectorSearch
Retrieval is a single aggregation pipeline. $vectorSearch must be the first stage:
async def retrieve(question: str, course_id: str, k: int = 5):
[query_vector] = embed([question], "RETRIEVAL_QUERY")
pipeline = [
{
"$vectorSearch": {
"index": "chunks_vector",
"path": "embedding",
"queryVector": query_vector,
"numCandidates": 150,
"limit": k,
"filter": {"courseId": course_id},
}
},
{
"$project": {
"_id": 0,
"text": 1,
"source": 1,
"section": 1,
"score": {"$meta": "vectorSearchScore"},
}
},
]
return await chunks.aggregate(pipeline).to_list(k)numCandidatesis how many neighbors the approximate search considers before returning the toplimit. Roughly 10–20×limitis a sensible starting point; raise it if relevant chunks go missing.- Filter inside
$vectorSearch, not in a later$match. A later$matchfilters after the topkare picked, so you can end up with fewer results than you asked for — or none.
5. Build the prompt
Give the model the retrieved chunks with their sources, and tell it what to do when they don't contain the answer:
def build_prompt(question: str, chunks: list[dict]) -> str:
context = "\n\n".join(
f"[{i + 1}] ({c['source']} — {c['section']})\n{c['text']}"
for i, c in enumerate(chunks)
)
return f"""Answer the student's question using only the context below.
Cite sources as [1], [2]. If the context doesn't cover the question,
say so instead of guessing.
Context:
{context}
Question: {question}"""The "say so instead of guessing" line does more for answer quality than most prompt tuning. Without it, the model fills gaps from its own knowledge and the answers drift away from the course material.
6. Watch what retrieval actually returns
Most bad RAG answers are retrieval problems, not generation problems: the right chunk never reached the prompt. Log every request with the question, the retrieved chunks and their scores, and the final answer. I use Langfuse for this; seeing the retrieved context next to a bad answer usually makes the fix obvious:
- The right chunk exists but ranks too low → improve chunking or raise
numCandidates. - The right chunk doesn't exist → the source material has a gap.
- The chunks are right but the answer is wrong → that's a prompt problem.
Takeaways
- Keep vectors next to your data: Atlas Vector Search avoids syncing a second database.
- Chunk on document structure, and store the metadata you'll filter on.
- Declare filter fields in the index and filter inside
$vectorSearch. - Tell the model to admit when the context is missing.
- Trace retrieval. You can't fix what you can't see.