A hands-on comparison of classic MongoDB search vs. mongot-powered search (Atlas Search / Vector Search engine), feature by feature, on real local data.
📄 Live report: https://marmelab.github.io/mongot/
This proof of concept runs MongoDB and mongot locally via Docker against a real corpus of 8,779 scientific food-science articles, and compares, feature by feature, a search without mongot (classic MongoDB) against a search with mongot. The same PoC app also supports other corpora, but every example below uses the food dataset.
mongot is the Lucene-based search engine that powers Atlas Search and Atlas Vector Search. It is now source-available (SSPL license) and can run self-managed alongside mongod: it replicates your data to keep an indexed copy (inverted index + vector index), and answers the $search, $searchMeta and $vectorSearch aggregation stages.
| Feature | Without mongot | With mongot |
|---|---|---|
| Full-text | $regex (no score, no fuzzy) |
inverted index, scoring, fuzzy, highlight |
| Autocomplete | $regex ^prefix |
edge n-gram index |
| Facets | costly $group/$unwind/$sortByCount |
$searchMeta, single pass, from the index |
| Semantic | not natively possible | $vectorSearch (kNN on embeddings) |
| Hybrid | — | lexical + vector fusion (RRF) |
| Geo | 2dsphere $geoWithin + separate $regex |
geoWithin + scored text in one $search |
Every example above runs against a single, realistically-sized corpus:
food — scientific articles on food science and nutrition. Large enough to show classic aggregation degrading while mongot's indexed facets and search stay fast.
- 8,779 documents
- 384 embedding dims
The live PoC app also supports other corpora (e.g. a small animalia dataset, ~100 documents), selectable at runtime — this report focuses on food to show mongot at realistic scale. A small places dataset (~150 points of interest) powers the geospatial (geoWithin) demo below.
Two Docker containers (docker/docker-compose.yml), a replica set, and one command (Makefile) to bring it all up:
- mongod —
mongodb-community-server:8.2— replica setrs0, protected by a shared keyfile (docker/mongod.conf). - mongot —
mongodb-community-search:1.70.1— connects to mongod as a dedicatedsearchCoordinatoruser, maintains the Lucene index (docker/mongot.config.yml).
Embeddings are computed locally (model all-MiniLM-L6-v2, 384 dimensions) — no external API key required.
Quickstart:
make all # start mongod + mongot, ingest datasets, create indexes, build the UI
make api # run the API + serve the web UI -> http://localhost:3000
make demo # console proof: one query per featureCreating the search index:
db.articles.createSearchIndex("default", "search", {
mappings: {
dynamic: false,
fields: {
title: [{ type: "string" }, { type: "autocomplete" }],
abstract: { type: "string" }
}
}
});Creating the vector index:
db.articles.createSearchIndex("vector_index", "vectorSearch", {
fields: [
{
type: "vector",
path: "embedding",
numDimensions: 384,
similarity: "cosine"
}
]
});$search — text, fuzzy & highlight:
db.articles.aggregate([
{
$search: {
index: "default",
text: {
query: "fish",
path: ["title", "abstract"],
fuzzy: { maxEdits: 1 }
},
highlight: { path: ["title", "abstract"] }
}
},
{ $limit: 10 },
{ $project: { title: 1, score: { $meta: "searchScore" } } }
]);$vectorSearch — semantic kNN:
db.articles.aggregate([
{
$vectorSearch: {
index: "vector_index",
path: "embedding",
queryVector: queryEmbedding, // 384-dim, computed locally
numCandidates: 100,
limit: 10
}
},
{ $project: { title: 1, score: { $meta: "vectorSearchScore" } } }
]);dataset: food (8,779)
Search for words in titles and abstracts, with relevance ranking, typo tolerance, and highlighting.
Query — classic MongoDB vs. mongot (query: "protien"):
// Classic MongoDB
db.food.aggregate([
{ $match: { $or: [
{ title: { $regex: "protien", $options: "i" } },
{ abstract: { $regex: "protien", $options: "i" } }
] } },
{ $limit: 10 }
])// With mongot
db.food.aggregate([
{ $search: {
index: "default",
text: { query: "protien", path: ["title", "abstract", "keywords"], fuzzy: { maxEdits: 1 } },
highlight: { path: ["title", "abstract"] }
} },
{ $limit: 10 }
])Key parameters:
- Classic:
$regex "protien"— the text is searched as a raw pattern in title/abstract, no relevance score;$options "i"— case-insensitive, but still sensitive to accents/punctuation, no typo tolerance. - mongot:
index "default"— the mongot search index (Lucene inverted index) queried;text.query— the searched text;text.path [title, abstract, keywords]— the indexed fields to search in;fuzzy.maxEdits 1— tolerates 1 typo (edit distance);highlight.path [title, abstract]— fields for which mongot returns excerpts with highlighted terms.
The difference — results:
- Typo tolerance — query "protien" (misspelling of "protein"): Without mongot → 0 results (
$regexneeds an exact substring match). With mongot → 10 results, e.g. "Protein quality of chickpea (Cicer arietinum L.) protein hydrolysate", "Protein quality assessment in cocoa husk". - Typo tolerance — query "fermentaton" (misspelling of "fermentation"): Without mongot → 0 results. With mongot → 10 results, e.g. "A new fuzzy control system for white wine fermentation".
- Relevance ranking — query "protein": Without mongot → matching documents with no ranking (insertion order only). With mongot → ranked by relevance score:
- "Comparison of the concentration-dependent emulsifying properties…" — score
6.59 - "Correlations between saliva protein composition…" — score
6.57 - "Apple pectin complexes with whey protein isolate" — score
6.35
- "Comparison of the concentration-dependent emulsifying properties…" — score
Takeaway: mongot ranks results by relevance and tolerates typos; $regex only gives a binary yes/no.
prefix: "ferm" · dataset: food (8,779)
Suggest titles as you type.
Query — classic MongoDB vs. mongot (prefix: "ferm"):
// Classic MongoDB
db.food.aggregate([
{ $match: { title: { $regex: "^ferm", $options: "i" } } },
{ $limit: 5 }
])// With mongot
db.food.aggregate([
{ $search: { index: "default", autocomplete: { query: "ferm", path: "title" } } },
{ $limit: 5 }
])Key parameters:
- Classic:
$regex "^ferm"— anchored at the start of the string, only matches if the title starts with the exact prefix;$options "i"— case-insensitive;$limit 5— number of suggestions returned. - mongot:
index "default"— the title field is indexed there as edge n-grams;autocomplete.query "ferm"— the prefix typed as the user types;autocomplete.path "title"— field pre-split into n-grams, enabling instant suggestions even mid-word;$limit 5— number of suggestions returned.
The difference — results: Without mongot, $regex ^ferm only matches titles that literally start with "ferm" — far too narrow for a real autocomplete experience. With mongot, the edge n-gram index gives instant suggestions matching anywhere within titles: "The influence of fermentation time…", "Production of fermented milk…", "Characterization of lactic acid bacteria…", "Bifidobacteria fermentation of soybean milk".
Takeaway: mongot's edge-n-gram index is built for type-as-you-go; the prefix regex is rigid.
query: "protein" · dataset: food (8,779)
Count results by category, species, and year to filter.
Query — classic MongoDB vs. mongot (query: "protein"):
// Classic MongoDB
db.food.aggregate([
{ $match: { $or: [
{ title: { $regex: "protein", $options: "i" } },
{ abstract: { $regex: "protein", $options: "i" } },
{ keywords: { $regex: "protein", $options: "i" } }
] } },
{ $unwind: "$categories" },
{ $sortByCount: "$categories" },
{ $limit: 10 }
])// With mongot
db.food.aggregate([
{ $searchMeta: {
index: "default",
facet: {
operator: { text: { query: "protein", path: ["title", "abstract", "keywords"] } },
facets: {
categoriesFacet: { type: "string", path: "categories", numBuckets: 10 },
yearFacet: { type: "number", path: "year",
boundaries: [2000, 2005, 2010, 2015, 2020, 2025] }
}
}
} }
])Key parameters:
- Classic:
$match $regex "protein"— filters the query's documents first, to count over the same population as mongot;$unwind $categories— unwinds the categories array into one row per value;$sortByCount $categories— groups, counts, and sorts by descending frequency (one facet at a time — repeat for species, year…);$limit 10— number of values returned. - mongot:
$searchMeta— returns metadata (counts) instead of documents, computed from the index in one pass;facet.operator text{query:"protein",path}— the search query that contextualizes the facets;facets.*.type string | number— string = distinct values, number = numeric buckets;numBuckets 10— max distinct values per string facet;boundaries [2000,2005,…,2025]— bucket boundaries for the numeric year facet.
The difference — results:
Without mongot ($match + $group, ~40 ms, one facet only) |
With mongot ($searchMeta, ~10 ms, every facet in one pass) |
|---|---|
| categories: Life Sciences 1693 · Agricultural and Biological Sciences 934 · Food Science 892 · Health Sciences 523 | categories: Life Sciences 819 · Agricultural and Biological Sciences 705 · Food Science 705 · Health Sciences 412 |
| — | year: 2000 → 434 · 2005 → 60 · 2010 → 9 |
Takeaway: for the same population (the query's documents), mongot outputs all facets in one pass ($searchMeta); classic MongoDB has to chain $match + $unwind + $group, one facet at a time.
query: "how to stop food from spoiling" · dataset: food (8,779)
Search by meaning rather than exact words.
Query — classic MongoDB vs. mongot (query: "how to stop food from spoiling"):
// Classic MongoDB
db.food.aggregate([
{ $match: { $or: [
{ title: { $regex: "how to stop food from spoiling", $options: "i" } },
{ abstract: { $regex: "how to stop food from spoiling", $options: "i" } }
] } },
{ $limit: 10 }
])
// -> 0 results// With mongot
const queryVector = await embed("how to stop food from spoiling")
// 384-dim, same model as indexing
db.food.aggregate([
{ $vectorSearch: {
index: "vector_index", path: "embedding",
queryVector, numCandidates: 100, limit: 10
} }
])Key parameters:
- Classic:
$regex "how to stop food from spoiling"— semantically impossible, a rephrasing with no word in common with the corpus matches nothing;$options "i"— case-insensitive, but that changes nothing about the underlying problem. - mongot:
index "vector_index"— the vector index (approximate nearest-neighbor search, ANN);path "embedding"— field containing each document's vector;queryVector <embedding 384-dim>— the query converted into an embedding by the same model (all-MiniLM-L6-v2);numCandidates 100— number of approximate neighbors explored before keeping the best ones (higher = more accurate but slower);limit 10— number of final results returned;similarity cosine— proximity measure between vectors.
The difference — results: Without mongot → 0 results (no exact word in common between the query and any document). With mongot → 10 results, ranked by meaning, not keywords:
- "Physiology of food spoilage organisms" — score
0.832 - "The prevalence and control of spoilage yeasts in foods and beverages" — score
0.794 - "The impact of food processing on antioxidants in vegetable oils, fruits…" — score
0.756
It works just as well on other everyday phrasings — "benefits of fermented foods for gut health" finds "Overview of gut flora and probiotics" (0.816), despite sharing no exact words.
Takeaway: mongot understands «what do reef fish eat» ≈ «diet of reef fishes»; the regex sees no common word.
query: "antioxidant activity" · dataset: food (8,779)
Combine lexical relevance and semantic proximity.
Query — classic MongoDB vs. mongot (query: "antioxidant activity"):
// Classic MongoDB — $regex only (lexical, no meaning)
db.food.aggregate([
{ $match: { $or: [
{ title: { $regex: "antioxidant activity", $options: "i" } },
{ abstract: { $regex: "antioxidant activity", $options: "i" } }
] } },
{ $limit: 10 }
])// With mongot
// 1) lexical ranking
db.food.aggregate([
{ $search: { index: "default",
text: { query: "antioxidant activity", path: ["title", "abstract", "keywords"] } } },
{ $limit: 20 }
])
// 2) semantic ranking
db.food.aggregate([
{ $vectorSearch: { index: "vector_index", path: "embedding",
queryVector, numCandidates: 100, limit: 20 } }
])
// 3) fuse both rankings with Reciprocal Rank Fusion (k = 60), API-sideKey parameters:
- Classic:
$regex "antioxidant activity"— only exact word matches, with no notion of meaning. - mongot:
$search (lexical) limit 20— lexical ranking by relevance (keywords);$vectorSearch (semantic) limit 20— ranking by semantic proximity (embeddings);RRF k 60— Reciprocal Rank Fusion: score = Σ 1/(k + rank) over both rankings; k dampens the weight of high ranks so you keep the best of both worlds.
The difference — results: Without mongot → no equivalent (classic MongoDB has no built-in way to fuse lexical and semantic relevance into a single ranked query). With mongot → 10 results, fused with RRF (k = 60):
- "The hydrophilic and lipophilic contribution to total antioxidant activity…" —
rrf 0.0320 - "Natural antioxidants from residual sources" —
rrf 0.0306 - "Antioxidant activity of anthraquinones and anthrone" —
rrf 0.0294
Takeaway: hybrid search captures both the exact keyword and the meaning; regex misses everything that isn't literal.
center: Paris (2.3522, 48.8566) · radius: 1,500 km · dataset: places (~150)
Find places within a radius — and rank them by text relevance, in a single query. Example: "paris museum" within 1,500 km of Paris (a small places dataset of ~150 points of interest).
Query — classic MongoDB vs. mongot (query: "paris museum" within 1,500 km of Paris):
// Classic MongoDB — requires a 2dsphere index on `location`
db.places.aggregate([
{ $match: {
location: { $geoWithin: { $centerSphere: [[2.3522, 48.8566], 1500000 / 6378137] } },
$or: [ { name: { $regex: "paris museum", $options: "i" } }, { category: { $regex: "paris museum", $options: "i" } } ]
} },
{ $limit: 12 }
])// With mongot — `location` indexed as type "geo" in the search index
db.places.aggregate([
{ $search: { index: "geo_index", compound: {
must: [{ text: { query: "paris museum", path: ["name", "category", "city", "country"] } }],
filter: [{ geoWithin: { path: "location",
circle: { center: { type: "Point", coordinates: [2.3522, 48.8566] }, radius: 1500000 } } }]
} } },
{ $limit: 12 }
])Key parameters:
- Classic:
$geoWithin / $centerSphere(2dsphere) — geo filter, needs a separate 2dsphere index;$regex— crude text filter, no relevance ranking. - mongot:
compound.filter.geoWithin circle {center, radius}— keeps only documents whose point is inside the circle;compound.must.text (scored)— ranks the geo-filtered results by text relevance;location (type geo)— field indexed as geo in the search index.
The difference — results:
| Without mongot — 1 result · ~2 ms | With mongot — 8 results · ~16 ms |
|---|---|
$geoWithin + $regex — geo-filtered but not ranked (insertion order only): "Paris Museum" |
$search compound (geoWithin filter + text must) — geo-filtered and ranked by relevance: |
1. "Paris Museum" — score 5.455 |
|
2. "Paris Park" — score 3.350 |
|
3. "Paris Garden" — score 3.350 |
|
4. "Madrid Museum" — score 2.104 |
mongot tokenizes the query: it returns places matching either "paris" or "museum", within the radius, ranked by how well they match. Change the centre to Tokyo (radius 1,000 km) and Paris drops out of the results — the geo filter behaves the same on both sides; the difference is the unified relevance ranking.
Takeaway: mongot tokenizes the text and returns everything matching any term ("paris" OR "museum"), geo-filtered and ranked by relevance — 8 results in one query; classic MongoDB's $regex only matches the literal "paris museum" substring (1 result), unranked, and needs a separate 2dsphere geo filter.
On the full food corpus (8,779 docs), mongot is faster where it matters most for interactive search — faceting — and it unlocks queries classic MongoDB simply cannot answer at all: typo-tolerant full-text, meaning-based semantic search, and relevance-ranked results.
Facets via $searchMeta, query "protein":
| Time | |
|---|---|
With mongot ($searchMeta) |
~10 ms |
Without mongot ($match + $group) |
~40 ms |
~4× faster, returning every facet in one pass instead of one — plus typo tolerance, semantic search and relevance ranking that classic $regex/$match can never provide, at any corpus size.
This same report also exists as a static, bilingual HTML page, live at https://marmelab.github.io/mongot/. It's built from report/index.html, and a workflow at .github/workflows/pages.yml publishes it automatically to GitHub Pages.