There are the moments where regular text search falls short. The following is what that looks like in practice:
- A customer support portal returns no results when a user types "won't turn on" instead of the exact phrase used in the help article.
- An e-commerce platform finds nothing when a shopper searches "something warm for winter" because no product description contains those exact words.
- A knowledge base misses the right document because the user phrased their question differently from how the document was written.
Semantic search solves this. It understands the meaning and intent behind a query rather than matching exact words.
This tutorial shows you how to implement semantic search in MongoDB using Python and the free nomic-embed-text-v1 embedding model.
Note: nomic-embed-text-v1 is approximately 0.27 GB when loaded into memory, so make sure your machine has at least 1 GB of available RAM before running any script in this tutorial. The first run downloads the model from Hugging Face, so allow extra time depending on your internet connection. All subsequent runs load the model from your local cache. CPU inference is significantly slower than GPU inference. On machines without a dedicated GPU, the model runs on CPU and uses RAM rather than VRAM, so embedding generation may take longer.
You'll learn the following in this tutorial:
- What vector embeddings are and how they represent meaning
- How to generate and store embeddings in MongoDB
- How to create a MongoDB Vector Search index
- How to convert a user query into a vector
- How to run a
$vectorSearch aggregation pipeline and interpret the results
You can find all the code samples for this tutorial in the GitHub repository.
Text Search vs Semantic Search
Text search matches documents that contain the exact words in your query. Semantic search matches documents that carry the same meaning as your query, even if they use entirely different words.
The following table shows what each approach returns for the search query "heart problems":
| Document text | Text search | Semantic search | Why text search returns it |
|---|
| "...heart problems in adults..." | Yes | Yes | Contains the exact words "heart problems" |
| "...cardiac conditions and symptoms..." | No | Yes | Doesn't contain the exact words "heart problems" |
| "...risk factors for heart disease..." | No | Yes | Doesn't contain the exact words "heart problems" |
| "...chest pain and shortness of breath..." | No | Yes | Doesn't contain the exact words "heart problems" |
Semantic Search Concepts
Three concepts are central to semantic search in MongoDB. They'll help you understand why each step in this tutorial works the way it does.
Vector embeddings
An embedding model converts text into a fixed-length list of numbers called a vector. Each number in the vector represents a dimension of meaning.
Two texts with similar meanings produce vectors that are numerically close to each other. "cardiac conditions" and "heart problems" land near each other in vector space, even though they share no words. That closeness is what makes semantic search possible.
This tutorial uses the nomic-embed-text-v1 embedding model, which is free, open-source, and runs entirely on your local machine. The model automatically downloads from Hugging Face the first time you run the script and saves locally for all future runs. The model produces 768 numbers per input.
Vector search indexes
A vector search index tells MongoDB which field holds the embeddings, how many dimensions to expect, and which similarity function to use for comparison. You must create this index before you can run any $vectorSearch queries.
The $vectorSearch aggregation stage
$vectorSearch is the aggregation stage that runs the search. It accepts a query vector, searches the indexed field, and returns documents ranked by semantic similarity. You chain it with $project and other stages just like any other aggregation pipeline.
Now let's get started.
Prerequisites
Before you start, make sure you have the following ready:
- Python 3.8 or later installed
- A MongoDB Atlas account with an M0 (Free tier) cluster set up
pymongo 4.7 or later, sentence-transformers, and einops Python packages installed
- Basic familiarity with Python and MongoDB collections
- Your Atlas connection string, available in the Atlas UI under **Database > Connect > Drivers**
- Your IP address is allowed in Atlas before you run any scripts. Go to **Security > Network Access** in the Atlas UI and add your current IP address.
Set Up Your Project
Create a folder for your project and navigate into it in your terminal:
mkdir mongodb-semantic-search
cd mongodb-semantic-search
Create and activate a virtual environment to keep your project dependencies isolated:
python -m venv venv
source venv/bin/activate
On Windows, activate the virtual environment with:
Now install the required packages:
pip install pymongo sentence-transformers einops
You'll create one Python file for each step of this tutorial. All files go in the mongodb-semantic-search folder.
Create the Embedding Utilities File
Create a file named embedding_utils.py. This file holds both embedding functions used in this tutorial:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("nomic-ai/nomic-embed-text-v1", trust_remote_code=True)
def get_embedding(text, precision="float32"):
return model.encode(text, precision=precision).tolist()
def get_query_embedding(text, precision="float32"):
return model.encode(text, precision=precision).tolist()
Warning: trust_remote_code=True allows the model to execute model-specific Python code downloaded from Hugging Face onto your machine. If the model's source code on Hugging Face is compromised or updated with malicious changes, that code runs automatically in your environment. Exercise caution before using this parameter in a production deployment.
Generate and Store Vector Embeddings
Create a file named generate_embeddings.py. This file imports the embedding function from embedding_utils.py, generates a vector for each document's text field, and stores the full document, including the embedding, in MongoDB:
from embedding_utils import get_embedding
from pymongo import MongoClient
mongodb_client = MongoClient(
"mongodb+srv://<USERNAME>:<PASSWORD>@<HOST>/",
appname="devrel-tutorial-python-semantic-search"
)
collection = mongodb_client["sample_db"]["documents"]
sample_documents = [
{
"title": "MongoDB Atlas",
"text": "MongoDB Atlas is a fully managed cloud database."
},
{
"title": "Vector Search",
"text": "Vector search finds results based on semantic meaning."
},
{
"title": "Nomic AI",
"text": "nomic-embed-text-v1 is a free, open-source embedding model."
},
]
docs_to_insert = []
for doc in sample_documents:
embedding = get_embedding(doc["text"])
docs_to_insert.append({
"title": doc["title"],
"text": doc["text"],
"embedding": embedding
})
collection.drop()
result = collection.insert_many(docs_to_insert)
print(f"Inserted {len(result.inserted_ids)} documents with embeddings.")
mongodb_client.close()
In the preceding code, collection.drop() deletes all documents and indexes on the collection before each run. Re-running generate_embeddings.py without this line inserts duplicate documents, which causes $vectorSearch to return the same document multiple times. Dropping the collection also deletes the vector search index, so you must re-run create_vector_index.py whenever you re-run generate_embeddings.py.
Run the script:
python generate_embeddings.py
You'll get the following output in your terminal:
<All keys matched successfully>
Inserted 3 documents with embeddings.
Each document in MongoDB now holds both the original text and its 768-dimensional vector in the embedding field. The vector lives alongside your data in the same document, so no separate storage or lookup is needed at query time.
Create a MongoDB Vector Search Index
Create a file named create_vector_index.py. This file defines and creates a vector search index on the embedding field so MongoDB can run $vectorSearch queries against your collection.
The index definition requires three fields:
path: the field that holds the embeddings (embedding in this tutorial)
numDimensions: must match the model's output size (768 for nomic-embed-text-v1)
similarity: the comparison function (cosine is correct for this model)
The numDimensions value must match your embedding model exactly. A mismatch causes the index creation to fail:
from pymongo.mongo_client import MongoClient
from pymongo.operations import SearchIndexModel
import time
mongodb_client = MongoClient(
"mongodb+srv://<USERNAME>:<PASSWORD>@<HOST>/",
appname="devrel-tutorial-python-semantic-search"
)
database = mongodb_client["sample_db"]
collection = database["documents"]
search_index_model = SearchIndexModel(
definition={
"fields": [
{
"type": "vector",
"path": "embedding",
"numDimensions": 768,
"similarity": "cosine"
}
]
},
name="vector_index",
type="vectorSearch"
)
result = collection.create_search_index(model=search_index_model)
print("New search index named " + result + " is building.")
print("Polling to check if the index is ready. This may take up to a minute.")
predicate = lambda index: index.get("queryable") is True
while True:
indices = list(collection.list_search_indexes(result))
if len(indices) and predicate(indices[0]):
break
time.sleep(5)
print(result + " is ready for querying.")
mongodb_client.close()
Run the script:
python create_vector_index.py
You'll get the following output in your terminal:
New search index named vector_index is building.
Polling to check if the index is ready. This may take up to a minute.
vector_index is ready for querying.
The index takes up to a minute to build. The polling loop checks every 5 seconds and 5 exits only when MongoDB confirms the index is queryable. Don't move to the next step until you see "vector_index is ready for querying."
Convert Your Search Query Into a Vector
Create a file named generate_query_vector.py. This file imports the get_query_embedding() function from the embedding_utils.py file. Run the generate_query_vector.py file to verify that the embedding model loads correctly and produces a 768-dimensional vector:
- It defines the
get_query_embedding() function that the next step imports.
- You can run it on its own to verify that the model is working correctly.
You must use the same model here as you did in the first step. A different model produces vectors that occupy a different numerical space, making comparison meaningless:
from embedding_utils import get_query_embedding
user_query = "I need an automated, scalable system for serious information storage"
query_vector = get_query_embedding(user_query)
print(f"Query: '{user_query}'")
print(f"Vector dimensions: {len(query_vector)}")
print(f"First 5 values: {query_vector[:5]}")
Run the script:
python generate_query_vector.py
You'll get an output similar to this:
<All keys matched successfully>
Query: 'I need an automated, scalable system for serious information storage'
Vector dimensions: 768
First 5 values: [0.0025914530269801617, 0.09862980246543884, -0.023092379793524742, -0.0171672236174345, -0.05548065900802612]
In the preceding output, Vector dimensions: 768 confirms that the model output matches your stored embeddings.
Run a $vectorSearch Query
Create a file named run_vector_search.py. This file imports the get_query_embedding() function from the embedding_utils.py file and converts a user's search term into a vector. run_vector_search.py runs a $vectorSearch aggregation pipeline, and prints the results ranked by semantic similarity:
from embedding_utils import get_query_embedding
from pymongo import MongoClient
mongodb_client = MongoClient(
"mongodb+srv://<USERNAME>:<PASSWORD>@<HOST>/",
appname="devrel-tutorial-python-semantic-search"
)
collection = mongodb_client["sample_db"]["documents"]
user_query = "I need an automated, scalable system for serious information storage"
query_vector = get_query_embedding(user_query)
pipeline = [
{
"$vectorSearch": {
"index": "vector_index",
"path": "embedding",
"queryVector": query_vector,
"numCandidates": 150,
"limit": 3
}
},
{
"$project": {
"_id": 0,
"title": 1,
"text": 1,
"score": {
"$meta": "vectorSearchScore"
}
}
}
]
results = collection.aggregate(pipeline)
print(f"\nTop results for query: '{user_query}'\n")
for doc in results:
print(f"Title: {doc['title']}")
print(f"Text: {doc['text']}")
print(f"Score: {doc['score']:.4f}")
print()
mongodb_client.close()
In the preceding code, two parameters control the search behavior:
numCandidates sets the size of the initial search pool that MongoDB examines before narrowing to the final results. A larger value improves recall but takes slightly longer.
limit sets how many results you get back. In this tutorial, limit is set to 3, and numCandidates is set to 150. A common starting point is to set numCandidates to 10-15 times your limit.
Now run the script:
python run_vector_search.py
You'll get an output similar to this:
<All keys matched successfully>
Top results for query: 'I need an automated, scalable system for serious information storage.'
Title: MongoDB Atlas
Text: MongoDB Atlas is a fully managed cloud database.
Score: 0.7211
Title: Nomic AI
Text: nomic-embed-text-v1 is a free, open-source embedding model.
Score: 0.6818
Title: Vector Search
Text: Vector search finds results based on semantic meaning.
Score: 0.6642
"MongoDB Atlas" scores highest even though the query "I need an automated, scalable system for serious information storage" shares no words with "MongoDB Atlas is a fully managed cloud database." The search returns it because their vectors are close in meaning: "...an automated, scalable system for information storage" maps semantically to "...a fully managed cloud database". A text search for the same query would return zero results, because none of the exact query words appear in any document. That is semantic search working as intended.
Key Takeaways
- MongoDB Vector Search runs on an M0 Free tier cluster. No paid plan is required.
- You must use the same embedding model for both generating stored embeddings and generating query vectors. Mixing models produces meaningless results.
- The
numDimensions value in your index definition must match the output size of your embedding model exactly. nomic-embed-text-v1 always produces 768 dimensions.
input_type="document" optimizes embeddings for storage. input_type="query" optimizes them for retrieval. Use the correct type at each stage.
numCandidates controls the size of the search net MongoDB casts before narrowing to the final limit results. A larger value improves recall at the cost of query time.
vectorSearchScore ranks results by semantic similarity. Results don't need to contain any of the exact words from the query. Score ranges vary by model and dataset, but the following boundaries are a useful starting point for nomic-embed-text-v1 with cosine similarity:
- 0.9 and above: Near-identical meaning. The document and query are semantically almost the same.
- 0.7 to 0.9: Strong relevance. The document clearly relates to the query intent.
- 0.5 to 0.7: Moderate relevance. The document is topically related but uses different framing or context.
- Below 0.5: Weak relevance. The connection is loose, and the result may not be useful.
You can find all the code samples for this tutorial in the GitHub repository.
Further Reading
There are the moments where regular text search falls short. The following is what that looks like in practice:
Semantic search solves this. It understands the meaning and intent behind a query rather than matching exact words.
This tutorial shows you how to implement semantic search in MongoDB using Python and the free nomic-embed-text-v1 embedding model.
Note:
nomic-embed-text-v1is approximately 0.27 GB when loaded into memory, so make sure your machine has at least 1 GB of available RAM before running any script in this tutorial. The first run downloads the model from Hugging Face, so allow extra time depending on your internet connection. All subsequent runs load the model from your local cache. CPU inference is significantly slower than GPU inference. On machines without a dedicated GPU, the model runs on CPU and uses RAM rather than VRAM, so embedding generation may take longer.You'll learn the following in this tutorial:
$vectorSearchaggregation pipeline and interpret the resultsYou can find all the code samples for this tutorial in the GitHub repository.
Text Search vs Semantic Search
Text search matches documents that contain the exact words in your query. Semantic search matches documents that carry the same meaning as your query, even if they use entirely different words.
The following table shows what each approach returns for the search query "heart problems":
Semantic Search Concepts
Three concepts are central to semantic search in MongoDB. They'll help you understand why each step in this tutorial works the way it does.
Vector embeddings
An embedding model converts text into a fixed-length list of numbers called a vector. Each number in the vector represents a dimension of meaning.
Two texts with similar meanings produce vectors that are numerically close to each other. "cardiac conditions" and "heart problems" land near each other in vector space, even though they share no words. That closeness is what makes semantic search possible.
This tutorial uses the
nomic-embed-text-v1embedding model, which is free, open-source, and runs entirely on your local machine. The model automatically downloads from Hugging Face the first time you run the script and saves locally for all future runs. The model produces 768 numbers per input.Vector search indexes
A vector search index tells MongoDB which field holds the embeddings, how many dimensions to expect, and which similarity function to use for comparison. You must create this index before you can run any
$vectorSearchqueries.The
$vectorSearchaggregation stage$vectorSearchis the aggregation stage that runs the search. It accepts a query vector, searches the indexed field, and returns documents ranked by semantic similarity. You chain it with$projectand other stages just like any other aggregation pipeline.Now let's get started.
Prerequisites
Before you start, make sure you have the following ready:
pymongo4.7 or later,sentence-transformers, andeinopsPython packages installedSet Up Your Project
Create a folder for your project and navigate into it in your terminal:
Create and activate a virtual environment to keep your project dependencies isolated:
On Windows, activate the virtual environment with:
Now install the required packages:
You'll create one Python file for each step of this tutorial. All files go in the
mongodb-semantic-searchfolder.Create the Embedding Utilities File
Create a file named
embedding_utils.py. This file holds both embedding functions used in this tutorial:Warning:
trust_remote_code=Trueallows the model to execute model-specific Python code downloaded from Hugging Face onto your machine. If the model's source code on Hugging Face is compromised or updated with malicious changes, that code runs automatically in your environment. Exercise caution before using this parameter in a production deployment.Generate and Store Vector Embeddings
Create a file named
generate_embeddings.py. This file imports the embedding function fromembedding_utils.py, generates a vector for each document's text field, and stores the full document, including the embedding, in MongoDB:In the preceding code,
collection.drop()deletes all documents and indexes on the collection before each run. Re-runninggenerate_embeddings.pywithout this line inserts duplicate documents, which causes$vectorSearchto return the same document multiple times. Dropping the collection also deletes the vector search index, so you must re-runcreate_vector_index.pywhenever you re-rungenerate_embeddings.py.Run the script:
You'll get the following output in your terminal:
Each document in MongoDB now holds both the original text and its 768-dimensional vector in the
embeddingfield. The vector lives alongside your data in the same document, so no separate storage or lookup is needed at query time.Create a MongoDB Vector Search Index
Create a file named
create_vector_index.py. This file defines and creates a vector search index on theembeddingfield so MongoDB can run$vectorSearchqueries against your collection.The index definition requires three fields:
path: the field that holds the embeddings (embeddingin this tutorial)numDimensions: must match the model's output size (768fornomic-embed-text-v1)similarity: the comparison function (cosineis correct for this model)The
numDimensionsvalue must match your embedding model exactly. A mismatch causes the index creation to fail:Run the script:
You'll get the following output in your terminal:
The index takes up to a minute to build. The polling loop checks every 5 seconds and 5 exits only when MongoDB confirms the index is queryable. Don't move to the next step until you see "vector_index is ready for querying."
Convert Your Search Query Into a Vector
Create a file named
generate_query_vector.py. This file imports theget_query_embedding()function from theembedding_utils.pyfile. Run thegenerate_query_vector.pyfile to verify that the embedding model loads correctly and produces a 768-dimensional vector:get_query_embedding()function that the next step imports.You must use the same model here as you did in the first step. A different model produces vectors that occupy a different numerical space, making comparison meaningless:
Run the script:
You'll get an output similar to this:
In the preceding output,
Vector dimensions: 768confirms that the model output matches your stored embeddings.Run a
$vectorSearchQueryCreate a file named
run_vector_search.py. This file imports theget_query_embedding()function from theembedding_utils.pyfile and converts a user's search term into a vector.run_vector_search.pyruns a$vectorSearchaggregation pipeline, and prints the results ranked by semantic similarity:In the preceding code, two parameters control the search behavior:
numCandidatessets the size of the initial search pool that MongoDB examines before narrowing to the final results. A larger value improves recall but takes slightly longer.limitsets how many results you get back. In this tutorial,limitis set to 3, and numCandidates is set to 150. A common starting point is to setnumCandidatesto 10-15 times your limit.Now run the script:
You'll get an output similar to this:
"MongoDB Atlas" scores highest even though the query "I need an automated, scalable system for serious information storage" shares no words with "MongoDB Atlas is a fully managed cloud database." The search returns it because their vectors are close in meaning: "...an automated, scalable system for information storage" maps semantically to "...a fully managed cloud database". A text search for the same query would return zero results, because none of the exact query words appear in any document. That is semantic search working as intended.
Key Takeaways
numDimensionsvalue in your index definition must match the output size of your embedding model exactly.nomic-embed-text-v1always produces 768 dimensions.input_type="document"optimizes embeddings for storage.input_type="query"optimizes them for retrieval. Use the correct type at each stage.numCandidatescontrols the size of the search net MongoDB casts before narrowing to the finallimitresults. A larger value improves recall at the cost of query time.vectorSearchScoreranks results by semantic similarity. Results don't need to contain any of the exact words from the query. Score ranges vary by model and dataset, but the following boundaries are a useful starting point fornomic-embed-text-v1with cosine similarity:You can find all the code samples for this tutorial in the GitHub repository.
Further Reading