#mongodb #mongodb#search#trigrams

N-gram contains search in MongoDB without paying Atlas Search

How to fake LIKE '%foo%' on Mongo with trigrams, a pre-computed token array, and a clever multikey index. ~12ms p99 on 8M documents.

AD
Admin
DevDash Editor
Published
Apr 28, 2026
Read time
18 min
Difficulty
Advanced
N-gram contains search in MongoDB without paying Atlas Search

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

JavaScript
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

JavaScript
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

JavaScript
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);
}
Note

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.

AD
Written by
Admin
DevDash Editor

Deep technical writing on databases, performance engineering, and the infrastructure that keeps production running. All articles are reviewed and published by the DevDash editorial team.