DEV Community

Cover image for How to Create a Vector Search Index in MongoDB
MongoDB Guests for MongoDB

Posted on

How to Create a Vector Search Index in MongoDB

This tutorial was written by Damilola Oladele.


This tutorial shows you how to define, create, and verify a MongoDB Atlas Vector Search index, field by field, in Python. To give the index something real to search, you'll also build a small tool that uses AI to answer questions about your own text documents.

In this tutorial, you'll learn:

  • How to generate and store vector embeddings with Voyage AI's free tier.
  • How a MongoDB Atlas Vector Search index works, field by field: dimensions, similarity, quantization, and indexing method.
  • How to create a Vector Search index with PyMongo, and why your code should check queryable instead of the status.
  • How to verify the index with a $vectorSearch query.
  • How to use the index to generate AI-powered answers from your own documents.
  • How to edit, monitor, and delete a Vector Search index.

You can find all the code samples for this tutorial in the GitHub repository.

Prerequisites

Before you start, make sure you have the following ready:

  • Python 3.10 or later.
  • A MongoDB Atlas account with a free M0 cluster.
  • Your Atlas connection string, from Database > Connect > Drivers.
  • Your current IP address added to the Atlas access list, from Security > Network Access.
  • A Voyage AI model API key, created from AI Model APIs in the same Atlas project as your cluster. To get the API key, follow these steps:
1. Sign in to your Atlas account, the same one as your M0 cluster.
2. In your Atlas project, select *AI Model APIs* from the left navigation.
3. Select *Create model API key*.
4. Give it a name, then select *Create*.
5. Copy the key immediately and store it somewhere safe. You'll add it to `.env` when you set up the project.
Enter fullscreen mode Exit fullscreen mode
  • Ollama installed on your machine.

  • A small folder of your own .txt files to analyze. This tutorial's GitHub repository also includes a sample set, if you would rather use those.

What Is a MongoDB Vector Search Index and Why Do You Need One?

Before diving into the setup, it helps to know what a vector index is.

A regular MongoDB index can't answer "find documents similar in meaning to this one." It can only match exact or ranged values, not semantic closeness.

A vector index solves this differently. It operates on embeddings, which are lists of numbers produced by an embedding model that capture the meaning of a piece of text. Two passages with similar meaning produce embeddings that sit close together in that number space, even if they don't share a single word.

At a high level, MongoDB Vector Search performs an approximate nearest neighbor search over these embeddings.

mongot is the separate process that Atlas uses to build and serve Search and Vector Search indexes. It's distinct from mongod, the process that stores your documents. mongot builds and serves the index in the background. That's why "created" and "queryable" are two different moments for a Vector Search index. This tutorial covers that in depth later, when you create one.

The rest of this tutorial follows one path. Embed and store your documents first, then build the index on top of those stored embeddings.

Set Up the Project and Connect to Atlas

This section prepares the local language model, your Python environment, and the files that connect your code to Atlas.

Create a project folder and move into it:

mkdir vector-search-index-tutorial
cd vector-search-index-tutorial
Enter fullscreen mode Exit fullscreen mode

Pull the language model

Ollama runs the language model on your own machine, so no request leaves your computer.

Confirm Ollama is installed:

ollama --version
Enter fullscreen mode Exit fullscreen mode

If you use the Ollama desktop application, open it before continuing. If you use Ollama from the command line instead, start the server in its own terminal:

ollama serve
Enter fullscreen mode Exit fullscreen mode

Leave that terminal running. Open a second terminal, then pull the model:

cd vector-search-index-tutorial
ollama pull llama3.2:3b
Enter fullscreen mode Exit fullscreen mode

Confirm the pull succeeded:

ollama list
Enter fullscreen mode Exit fullscreen mode

llama3.2:3b should appear in the list.

Create a virtual environment

Create a virtual environment, then activate it:

python3 -m venv venv
source venv/bin/activate
Enter fullscreen mode Exit fullscreen mode

On Windows, create it with python -m venv venv and activate it with venv\Scripts\activate instead.

Install the packages

Install the tutorial's dependencies:

pip install pymongo voyageai ollama python-dotenv
Enter fullscreen mode Exit fullscreen mode

The ollama package you installed with the preceding command is the Python client library that calls the Ollama application you already set up. It's a separate install from the application itself, the same way pymongo is the Python client for MongoDB rather than MongoDB itself.

Add your connection details to an environment file

Create a file named .env in your project folder, and add the following to it:

MONGODB_URI="<your-connection-string>"
VOYAGE_API_KEY="<your-voyage-api-key>"
Enter fullscreen mode Exit fullscreen mode

Replace the placeholders with their actual values. python-dotenv loads both into your environment at runtime.

Create the configuration file

Create a config.py file and add the following code, which holds your project's constants and loads .env:

import os

from dotenv import load_dotenv

load_dotenv()

APP_NAME = "devrel-tutorial-python-vectorsearch-content"
MONGODB_URI = os.environ["MONGODB_URI"]
VOYAGE_API_KEY = os.environ["VOYAGE_API_KEY"]

DATABASE_NAME = "document_insights"
COLLECTION_NAME = "chunks"
EMBEDDING_MODEL = "voyage-4"
EMBEDDING_DIMENSIONS = 1024
VECTOR_INDEX_NAME = "vector_index"
Enter fullscreen mode Exit fullscreen mode

Create the database access file

Create a db.py file and add the following code, which defines get_client() and get_collection(), which are helpers every other file reuses:

from functools import cache

from pymongo import MongoClient

from config import APP_NAME, COLLECTION_NAME, DATABASE_NAME, MONGODB_URI


@cache
def get_client():
    return MongoClient(MONGODB_URI, appname=APP_NAME)


def get_collection():
    client = get_client()
    return client[DATABASE_NAME][COLLECTION_NAME]
Enter fullscreen mode Exit fullscreen mode

A MongoClient manages its own pool of connections, so a program needs only one. The @cache decorator makes get_client() create the client on its first call and return the same client on every later call.

Generate Embeddings with Voyage AI

An embed call takes text in and returns a fixed-length vector out. This tutorial splits each document into smaller passages, called chunks, and embeds each chunk. The next section covers the splitting.

This tutorial uses voyage-4, which defaults to 1,024 dimensions. Voyage models also support flexible output sizes. This is useful if you later want to trade size for speed. This tutorial sticks with the default.

Each embed call also sets an input_type. Voyage applies a different task-specific prompt depending on whether text is being stored (input_type="document") or used as a search query (input_type="query"). Document and query embeddings still land in the same embedding space, so they stay comparable for retrieval either way. The different prompts exist only to improve retrieval quality.

Accounts without a payment method on file get reduced rate limits: 3 requests per minute and 10,000 tokens per minute. A single document's full chunk list can easily exceed that in a single call, so embed_documents() needs to batch requests and pace itself.

Create an embeddings.py file and add the following code to it:

import time

import voyageai

from config import EMBEDDING_MODEL, VOYAGE_API_KEY

vo = voyageai.Client(api_key=VOYAGE_API_KEY)

BATCH_SIZE = 30
SECONDS_BETWEEN_CALLS = 21


def embed_documents(texts):
    embeddings = []
    for start in range(0, len(texts), BATCH_SIZE):
        batch = texts[start : start + BATCH_SIZE]
        result = vo.embed(batch, model=EMBEDDING_MODEL, input_type="document")
        embeddings.extend(result.embeddings)
        time.sleep(SECONDS_BETWEEN_CALLS)
    return embeddings


def embed_query(text):
    result = vo.embed([text], model=EMBEDDING_MODEL, input_type="query")
    return result.embeddings[0]
Enter fullscreen mode Exit fullscreen mode

embed_documents() splits a chunk list into batches of 30. That stays well under the 10,000-token limit, even at the free tier. It also pauses 21 seconds after every call. This keeps the entire ingest run under 3 requests per minute, across every file, not just one.

embed_query() embeds a single question at search time. One small request never comes close to either limit.

Split and Store Documents With Their Embeddings

Chunks retrieve better than whole documents. A model matches a question to a focused passage more precisely than against an entire file. A chunk also lets a citation point to a specific passage rather than a whole document. This tutorial keeps chunking simple. For a deeper look at chunking, see How to Build an AI PDF Analyzer with MongoDB, RAG, and Ollama.

Each stored chunk ends up shaped like this, once its embedding is attached:

{
  "source_file": "notes.txt",
  "chunk_index": 0,
  "text": "...",
  "embedding": [0.0128, -0.0341, "..."]
}
Enter fullscreen mode Exit fullscreen mode

Add the following save_chunks() helper to db.py, after the two functions already there:

def save_chunks(filename, chunks, embeddings):
    collection = get_collection()
    for index, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
        collection.update_one(
            {"source_file": filename, "chunk_index": index},
            {
                "$set": {
                    "source_file": filename,
                    "chunk_index": index,
                    "text": chunk,
                    "embedding": embedding,
                }
            },
            upsert=True,
        )
    collection.delete_many({"source_file": filename, "chunk_index": {"$gte": len(chunks)}})
Enter fullscreen mode Exit fullscreen mode

The filter matches on source_file and chunk_index to identify each chunk. Because upsert=True, rerunning the ingest script updates the matching chunk rather than inserting a duplicate. The final delete_many() removes chunks left over from an earlier run, for a file that now produces fewer chunks than before.

Create an ingest.py file and add the following code to it:

import os

from db import save_chunks
from embeddings import embed_documents

SAMPLE_DOCS_DIR = "sample_documents"
CHUNK_SIZE = 900


def chunk_text(text):
    paragraphs = [paragraph.strip() for paragraph in text.split("\n\n") if paragraph.strip()]
    chunks = []
    current = ""
    for paragraph in paragraphs:
        if current and len(current) + len(paragraph) + 2 > CHUNK_SIZE:
            chunks.append(current)
            current = paragraph
        else:
            current = f"{current}\n\n{paragraph}" if current else paragraph
    if current:
        chunks.append(current)
    return chunks


def ingest_documents():
    for filename in sorted(os.listdir(SAMPLE_DOCS_DIR)):
        if not filename.endswith(".txt"):
            continue

        path = os.path.join(SAMPLE_DOCS_DIR, filename)
        with open(path, encoding="utf-8") as file:
            text = file.read()

        chunks = chunk_text(text)
        embeddings = embed_documents(chunks)
        save_chunks(filename, chunks, embeddings)
        print(f"Ingested {len(chunks)} chunks from {filename}.")


if __name__ == "__main__":
    ingest_documents()
Enter fullscreen mode Exit fullscreen mode

chunk_text() splits a file on blank lines into paragraphs, then groups whole paragraphs into chunks of up to CHUNK_SIZE characters. A chunk never splits a word or sentence, and no text appears in two chunks. A single paragraph longer than CHUNK_SIZE becomes a chunk on its own.

Put a few .txt files in a sample_documents folder, then run the script. Keep each file to a few thousand words on the free trial tier, so a single file's chunks fit in one paced batch. You can find sample documents in the GitHub repository which hosts this tutorial's code samples.

python ingest.py
Enter fullscreen mode Exit fullscreen mode

You should get a response similar to the following in your terminal:

Ingested 25 chunks from a_modest_proposal.txt.
Ingested 13 chunks from declaration_of_independence.txt.
Ingested 17 chunks from the_cask_of_amontillado.txt.
Enter fullscreen mode Exit fullscreen mode

You can also confirm that the chunks landed in Atlas using the Atlas UI or with a quick count query in mongosh.

Create the Vector Search Index

A MongoDB Vector Search index tells mongot how to organize your stored embeddings for fast similarity search. Instead of scanning every document, mongot builds a searchable structure over the embedding field. The index definition controls how mongot builds and searches that structure.

The definition's fields array holds one entry for each indexed field. This tutorial indexes two fields:

  • embedding: a vector field that holds each chunk's embedding.
  • source_file: a filter field that lets a query narrow results to one document.

The vector field needs the most configuration. Its type is vector, and its path names the document field that contains the embedding:

{
  "type": "vector",
  "path": "embedding"
}
Enter fullscreen mode Exit fullscreen mode

Four more settings on the vector field control how mongot stores and compares your embeddings:

  • numDimensions: the size of each embedding.
  • similarity: how the index measures closeness between two vectors.
  • quantization: how much precision each stored vector keeps.
  • indexingMethod: the structure the index searches through.

The first setting, numDimensions, must exactly match the number of dimensions in your embedding model's output. This tutorial uses voyage-4, which produces 1,024-dimensional embeddings. If the value doesn't match, Atlas rejects the index definition or the query fails.

With the vector size set, similarity decides how the index compares two vectors. It accepts one of three values:

  • euclidean: measures the distance between vectors.
  • cosine: measures the angle between vectors.
  • dotProduct: suits normalized vectors, and is what MongoDB's own reference examples pair with Voyage embeddings.

This tutorial follows that pairing and uses dotProduct.

Where similarity shapes how vectors are compared, quantization shapes how precisely they're stored. quantization accepts one of three values:

  • none: full precision, the default.
  • scalar: trades some precision for lower memory use and faster search.
  • binary: compresses much further, at a greater cost to precision.

This tutorial uses scalar, since it cuts resource usage without binary's much more aggressive compression. scalar isn't always the right default. The best setting depends on your own retrieval-quality needs.

The last setting, indexingMethod, decides how the index finds the closest vectors. It accepts hnsw, the default graph-based method, or flat. HNSW builds a multi-layered graph that connects each embedding to its nearest neighbors. A query then skips most of the collection and jumps straight toward the closest matches.

flat skips that graph and compares a query directly against every vector, which stays fast with only a few vectors to check. flat is a Public Preview option built for multitenant workloads, where many tenants each have few vectors.

This tutorial's collection uses the default hnsw, with its default hnswOptions.

The second entry in fields indexes source_file as a filter field:

{
  "type": "filter",
  "path": "source_file"
}
Enter fullscreen mode Exit fullscreen mode

A field indexed as filter only becomes available to filter on. It doesn't filter anything by itself. A query does the actual filtering, with a filter clause in its $vectorSearch stage, such as "filter": {"source_file": {"$eq": "declaration_of_independence.txt"}}. That restricts a search to one document.

Now create a create_index.py file and add the following code, which combines both entries and their settings into one index definition:

import time

from pymongo.operations import SearchIndexModel

from config import EMBEDDING_DIMENSIONS, VECTOR_INDEX_NAME
from db import get_collection


def create_vector_index():
    collection = get_collection()

    search_index_model = SearchIndexModel(
        definition={
            "fields": [
                {
                    "type": "vector",
                    "path": "embedding",
                    "numDimensions": EMBEDDING_DIMENSIONS,
                    "similarity": "dotProduct",
                    "quantization": "scalar",
                },
                {
                    "type": "filter",
                    "path": "source_file",
                },
            ]
        },
        name=VECTOR_INDEX_NAME,
        type="vectorSearch",
    )

    result = collection.create_search_index(model=search_index_model)
    print(f"Creating index '{result}'. Waiting for it to become queryable...")

    while True:
        indexes = list(collection.list_search_indexes(result))
        if indexes and indexes[0].get("queryable"):
            break
        time.sleep(5)

    print(f"Index '{result}' is ready to query.")


if __name__ == "__main__":
    create_vector_index()
Enter fullscreen mode Exit fullscreen mode

Run the script:

python create_index.py
Enter fullscreen mode Exit fullscreen mode

The script returns almost immediately, but the index isn't searchable until Atlas finishes building it in the background. Build time depends on your collection, deployment, and current cluster conditions, so this script polls for queryable instead of assuming a fixed wait.

While it builds, list_search_indexes() reports one of these status values:

Status Meaning
PENDING Not yet started. Always non-queryable.
BUILDING Building or rebuilding. May or may not be queryable.
READY Ready to query. Always queryable.
STALE Queryable, but has stopped replicating and may serve outdated data.
FAILED The build failed. May or may not be queryable.
DELETING Being deleted. Always non-queryable.
DOES_NOT_EXIST The index doesn't exist. Always non-queryable.

queryable is the field your application code should check. BUILDING and FAILED don't tell you whether the index is currently queryable.

M0 clusters allow a maximum of three Search and Vector Search indexes combined. This tutorial creates only one index, so it stays within that limit.

Verify the Index With a Vector Search Query

It's important to check your index to be sure it works before you build anything on top of it.

limit can't exceed numCandidates in a $vectorSearch query, and MongoDB recommends setting numCandidates to at least 20 times limit for good recall. Use that ratio here instead of an arbitrary pair of numbers.

Create a search.py file and add the following code to it:

from config import VECTOR_INDEX_NAME
from db import get_collection
from embeddings import embed_query

NUM_CANDIDATES_MULTIPLIER = 20


def search(question, limit=5):
    collection = get_collection()
    query_vector = embed_query(question)

    pipeline = [
        {
            "$vectorSearch": {
                "index": VECTOR_INDEX_NAME,
                "path": "embedding",
                "queryVector": query_vector,
                "numCandidates": limit * NUM_CANDIDATES_MULTIPLIER,
                "limit": limit,
            }
        },
        {
            "$project": {
                "_id": 0,
                "text": 1,
                "source_file": 1,
                "chunk_index": 1,
                "score": {"$meta": "vectorSearchScore"},
            }
        },
    ]

    return list(collection.aggregate(pipeline))


if __name__ == "__main__":
    for result in search("What unalienable rights does the Declaration list?"):
        print(f"{result['source_file']} (chunk {result['chunk_index']}, score {result['score']:.3f})")
        print(result["text"][:200])
        print()
Enter fullscreen mode Exit fullscreen mode

Run the script:

python search.py
Enter fullscreen mode Exit fullscreen mode

The script searches with a sample question about the Declaration of Independence. The top matching chunks print with their source file, chunk number, and score. This confirms the index returns results. To test a question about your own documents, edit the string passed to search().

Answer Questions About Your Documents With AI

The index now powers a small interactive tool.

The tool embeds your question and uses $vectorSearch to retrieve the top matching chunks. It passes those chunks to a local Ollama model, which generates an answer using only the retrieved text. Ollama returns the answer text, while the application builds the citation list from each retrieved chunk's source_file and chunk_index.

Create an ask.py file and add the following code to it:

import ollama

from config import VECTOR_INDEX_NAME
from db import get_collection
from embeddings import embed_query

OLLAMA_MODEL = "llama3.2:3b"
NUM_CANDIDATES_MULTIPLIER = 20
RESULT_LIMIT = 5


def retrieve(question):
    collection = get_collection()
    query_vector = embed_query(question)

    pipeline = [
        {
            "$vectorSearch": {
                "index": VECTOR_INDEX_NAME,
                "path": "embedding",
                "queryVector": query_vector,
                "numCandidates": RESULT_LIMIT * NUM_CANDIDATES_MULTIPLIER,
                "limit": RESULT_LIMIT,
            }
        },
        {"$project": {"_id": 0, "text": 1, "source_file": 1, "chunk_index": 1}},
    ]

    return list(collection.aggregate(pipeline))


def build_prompt(question, chunks):
    ordered = sorted(chunks, key=lambda chunk: (chunk["source_file"], chunk["chunk_index"]))
    context = "\n\n".join(
        f"[{chunk['source_file']}, chunk {chunk['chunk_index']}]\n{chunk['text']}"
        for chunk in ordered
    )
    return (
        "Answer the question using only the context below. "
        "If the context doesn't contain the answer, say so.\n\n"
        f"Context:\n{context}\n\nQuestion: {question}"
    )


def ask(question):
    chunks = retrieve(question)
    prompt = build_prompt(question, chunks)

    response = ollama.chat(
        model=OLLAMA_MODEL,
        messages=[{"role": "user", "content": prompt}],
        options={"temperature": 0},
    )
    answer = response["message"]["content"]

    citations = [f"{chunk['source_file']} (chunk {chunk['chunk_index']})" for chunk in chunks]
    return answer, citations


def main():
    print("Ask a question about your documents. Type 'quit' to exit.\n")
    while True:
        question = input("> ").strip()
        if question.lower() in ("quit", ""):
            break

        answer, citations = ask(question)
        print(f"\n{answer}\n")
        print("Sources:")
        for citation in citations:
            print(f"  - {citation}")
        print()


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

$vectorSearch returns chunks ordered by similarity score, so build_prompt() sorts them back into document order. The model then reads each passage in the sequence it appears in the source. The temperature option set to 0 makes the model return the same wording for the same question on every run.

Make sure Ollama is running, then run the script:

python ask.py
Enter fullscreen mode Exit fullscreen mode

Type a question at the prompt, then press Enter. Try "What unalienable rights does the Declaration list?" Then try "What wine does the narrator say he bought, and why does he want Fortunato's opinion?" You should get an answer, along with a Sources list naming the file and chunk behind it.

The quality of the answer depends first on which chunks the index retrieves. The Vector Search index controls how MongoDB searches the embeddings. Its dimensions, similarity function, and filtering configuration determine how those vectors are indexed and compared.

Other parts of the pipeline also affect the result:

  • Chunking determines what information each embedding represents.
  • Query formulation determines what the application searches for.
  • Prompt construction determines how the retrieved text is presented to the model.

The questions you ask matter too. If a question requires more of a document than RESULT_LIMIT chunks cover, the retrieved context may not contain enough information to support a complete answer. The prompt tells the model to use only the given context, but a small local model doesn't always follow that instruction reliably. It can fill a gap with a plausible-sounding guess instead of admitting the context doesn't cover it.

Edit, Monitor, and Delete the Index

An index needs care beyond the moment you create it. Create a manage_index.py file and add the following code to it:

from config import EMBEDDING_DIMENSIONS, VECTOR_INDEX_NAME
from db import get_collection


def update_quantization(quantization="none"):
    collection = get_collection()
    definition = {
        "fields": [
            {
                "type": "vector",
                "path": "embedding",
                "numDimensions": EMBEDDING_DIMENSIONS,
                "similarity": "dotProduct",
                "quantization": quantization,
            },
            {"type": "filter", "path": "source_file"},
        ]
    }
    collection.update_search_index(VECTOR_INDEX_NAME, definition)
    print(f"Updated '{VECTOR_INDEX_NAME}' to use quantization='{quantization}'.")


def show_index_status():
    collection = get_collection()
    for index in collection.list_search_indexes():
        print(f"{index['name']}: status={index['status']}, queryable={index['queryable']}")


def delete_index():
    collection = get_collection()
    collection.drop_search_index(VECTOR_INDEX_NAME)
    print(f"Deleted '{VECTOR_INDEX_NAME}'.")


if __name__ == "__main__":
    show_index_status()
Enter fullscreen mode Exit fullscreen mode

update_search_index() changes settings like quantization after the fact. list_search_indexes() checks status or definition at any time. The status table shown earlier explains what each value means. drop_search_index() deletes the index outright.

Run the script to print the index's current status:

python manage_index.py
Enter fullscreen mode Exit fullscreen mode

You will get a response similar to the following in your terminal:

vector_index: status=READY, queryable=True
Enter fullscreen mode Exit fullscreen mode

To edit or delete the index instead, replace show_index_status() in the __main__ block with update_quantization() or delete_index(), then run the script again.

An edited index triggers a rebuild in the background. The old index keeps serving queries until the rebuild finishes, so an edit isn't a service interruption.

A deleted index is no longer available, so search.py and ask.py stop working until you run create_index.py again.

Key Takeaways

Here's what to take away from this tutorial:

  • A vector index needs at least one vector-type field, with numDimensions matching your embedding model and a similarity function suited to it. Everything else (quantization, indexingMethod, filter fields) is tunable on top of that baseline.
  • Index creation and index queryability are two different moments. Always check queryable before querying a brand-new index.
  • Free M0 clusters fully support MongoDB Vector Search, capped at three Search and Vector Search indexes combined.
  • Voyage AI's free tier (200 million tokens, no credit card) and a local Ollama model keep this tutorial's entire pipeline free.
  • The quality of any AI-generated answer is bounded by what the vector index retrieves. That's why the index's own configuration is worth understanding in depth.

You can find all the code samples for this tutorial in the GitHub repository.

Further Reading

For more on the concepts this tutorial covers:

Top comments (0)