RAG · Build RAG with MongoDB Atlas Vector Search10 / 10

Build RAG with MongoDB Atlas Vector Search

Example on GitHub(packages/sdk/examples/rag/rag-mongodb.ts)

The QVAC RAG chapter has a built-in workspace model, but production setups usually want their own vector store. MongoDB Atlas Vector Search is one option: the SDK contributes the embedding model, and you write the storage layer and the scan.

This lesson builds the RAG flow against MongoDB directly. embed() and loadModel() are the SDK pieces; the MongoClient, the index, and the $vectorSearch aggregation are yours.

Before the SDK can search, give it a local Atlas Vector Search endpoint. Start the single-node mongodb/mongodb-atlas-local container on localhost:27017:

const client = new MongoClient("mongodb://localhost:27017/?directConnection=true");
try {
  await client.connect();
  await client.db("admin").command({ ping: 1 });
  console.log("▸ Connected to MongoDB server");
  return client;
} catch {
  console.error("✖ Failed to connect to MongoDB server");
  process.exit(1);
}

With MongoDB accepting connections, load the embedding model whose output width will define the index. GTE_LARGE_FP16 produces 1024 dimensions:

const modelId = await loadModel({ modelSrc: GTE_LARGE_FP16 });

Now put searchable data behind the connection. Embed each sample and insert the vector with its category into the documents collection:

console.log("▸ Embedding documents...");
const documents = [];
for (const sample of samples) {
  const { embedding } = await embed({ modelId, text: sample.text });
  documents.push({ id: sample.id, category: sample.category, text: sample.text, embedding });
}
await collection.insertMany(documents);

The collection has vectors now, so define the index against their exact width. Set numDimensions to 1024 and expose category as a filter field:

await collection.createSearchIndex({
  name: INDEX_NAME,
  type: "vectorSearch",
  definition: {
    fields: [
      { type: "vector", path: "embedding", numDimensions: 1024, similarity: "cosine" },
      { type: "filter", path: "category" },
    ],
  },
});

Index creation is asynchronous. Poll queryable before searching, otherwise an early aggregation can look empty even though the documents are present. The readiness check is as follows:

for (let attempt = 0; attempt < 60; attempt++) {
  const [index] = (await collection.listSearchIndexes(INDEX_NAME).toArray()) as {
    queryable?: boolean;
  }[];
  if (index?.queryable) break;
  if (attempt === 59) throw new Error(`Index ${INDEX_NAME} did not become queryable`);
  await wait(1000);
}

Once the index reports ready, embed the query and send one $vectorSearch aggregation. numCandidates controls the candidate pool, limit controls top-K, and $project exposes the score for inspection. The search query would look like this:

const { embedding: queryEmbedding } = await embed({ modelId, text: query });

const results = await collection
  .aggregate<{ id: number; category: string; text: string; score: number }>([
    {
      $vectorSearch: {
        index: INDEX_NAME,
        path: "embedding",
        queryVector: queryEmbedding,
        filter: { category: { $eq: category } },
        numCandidates: 100,
        limit: 3,
      },
    },
    {
      $project: {
        _id: 0,
        id: 1,
        category: 1,
        text: 1,
        score: { $meta: "vectorSearchScore" },
      },
    },
  ])
  .toArray();

console.log("▸ Top 3 most similar documents:");
results.forEach((result, index) => {
  console.log(`${index + 1}. (Score: ${result.score.toFixed(4)}, Category: ${result.category})`);
  console.log(`   ${result.text}`);
  console.log();
});

The filter: { category: { $eq: category } } clause restricts the search to documents in the requested category. Without the filter, the top-K is global; with it, only same-category documents are candidates.

unloadModel({ modelId }) and client.close() end the run.

Note: the $vectorSearch stage runs on the MongoDB server, not in the SDK. The candidate enumeration, the cosine similarity scoring, and the limit step are all server-side. The SDK only emits the query embedding; everything past that is a single aggregation round-trip. The desktop runner probes localhost:27017 before this lesson runs; if a server answers, the lesson exercises it directly, otherwise the runner falls back to an in-process MongoClient mock so the lesson still completes.

Questions

Question 1 of 3

What has to be true about the search index before running a query against it?

Question 2 of 3

What does the filter: { category: { $eq: category } } clause change about the search results?

Question 3 of 3

Where does the actual similarity scoring for $vectorSearch happen?

index.ts
Loading editor...

Run your code, check your answer, or ask a question. It all shows up here.