MongoDB has no LIKE '%foo%' equivalent that uses an index. A regex with a leading wildcard — { name: /foo/ } — forces a full collection scan every time. For small collections that's fine. For anything over a few hundred thousand documents, it's a production incident waiting to happen.
Atlas Search solves this with a proper full-text index. But Atlas Search costs money, requires Atlas, and is overkill if you just need "does this string contain X?" on one field.
Here's how to get sub-20ms contains queries on 8M documents using only a multikey index and a bit of write-time preprocessing.
The core idea: trigrams
A trigram is a sliding window of 3 characters over a string. The word "hello" produces the trigrams: hel, ell, llo.
If you pre-compute every trigram for every searchable field and store them in a _tokens array, a contains("ell") search becomes a standard equality lookup against a multikey index on _tokens. Equality on a multikey index is fast — it's the same B-tree walk as any other equality.
Implementing the tokeniser
function trigrams(str, minLen = 3) {
const s = str.toLowerCase().trim();
const tokens = new Set();
for (let i = 0; i <= s.length - minLen; i++) {
tokens.add(s.slice(i, i + minLen));
}
return [...tokens];
}
// "hello world" → ["hel","ell","llo","lo ","o w"," wo","wor","orl","rld"]
At write time, before inserting or updating a document, compute trigrams(doc.name) and store the result in doc._tokens.
The index
db.products.createIndex(
{ _tokens: 1 },
{ name: "products_trigram_search" }
);
Multikey indexes (arrays) work exactly like regular indexes — MongoDB creates one B-tree entry per array element. With 8M documents averaging 40 trigrams each, that's 320M index entries. At roughly 20 bytes each, the index is ~6GB. Budget for that in RAM.
Querying
async function containsSearch(db, collection, field, term) {
const tokens = trigrams(term);
// All tokens must be present for a true "contains"
return db[collection].find({
_tokens: { $all: tokens }
}).limit(20);
}
For terms shorter than 3 characters, trigrams don't help. Fall back to a regex for 1–2 character terms, or simply require a minimum search length in your UI.
Performance
On an 8M document collection with a 6GB trigram index fully in RAM:
| Query length | p50 | p99 |
|---|---|---|
| 3 chars | 4ms | 18ms |
| 5 chars | 2ms | 8ms |
| 8 chars | 1ms | 4ms |
Longer terms are faster because more trigrams = tighter intersection = fewer documents to score.
The trade-offs
The write amplification is real — every insert and update must recompute _tokens. At high write throughput, this adds latency to your mutation path. If your collection is write-heavy, consider computing _tokens in a background job rather than inline.
Storage is also a consideration. A 6GB index on a collection you might otherwise index with a 200MB single-field index is a meaningful cost. But it's still far cheaper than Atlas Search for a single-field use case.