MongoDB – Text Search

February 18, 20256 min readUpdated 8/24/2026

MongoDB can do full-text search without another piece of infrastructure. For a search box over a few hundred thousand documents that is a genuinely good answer, and it takes one index. It also has hard limits that you should know before you build on it, because the point where you outgrow it arrives sooner than people expect.

Creating a text index

A text index can cover several fields, and each field can carry a weight that decides how much a match there counts:

db.reels.createIndex(
  { title: "text", tags: "text", description: "text" },
  { weights: { title: 10, tags: 8, description: 5 }, name: "reel_text" }
)

A hit in the title now counts twice as much as one in the description. Weights are relative and the absolute numbers mean nothing on their own — 10/8/5 and 100/80/50 behave identically.

ReelCMS declares the same thing from Java:

reels.createIndex(TextIndexDefinition.builder()
        .onField("title", 10F)
        .onField("tags", 8F)
        .onField("description", 5F)
        .build());

The limit to know first

A collection may have at most one text index. Not one per field — one in total. It can span as many fields as you like, but you cannot later add a second text index for a different field set. Attempting it fails with IndexOptionsConflict.

The practical consequence: decide the full set of searchable fields up front, because changing it means dropping the index and rebuilding it, which on a large collection is a maintenance window.

Searching

$text queries the index. It matches whole words, after stemming:

db.reels.find({ $text: { $search: "buzzer beater" } })     // documents with EITHER word
db.reels.find({ $text: { $search: "\"buzzer beater\"" } })  // the exact phrase
db.reels.find({ $text: { $search: "buzzer -beater" } })     // has buzzer, NOT beater

Multiple words are ORed by default, which surprises people who expect a search box to narrow as they add terms. Quote a phrase to require the sequence; prefix a word with - to exclude it.

To rank results you have to project the score explicitly and sort on it. Both halves are required — sorting on a field you did not project gives you an arbitrary order and no error:

db.reels.find(
  { $text: { $search: "fadeaway" } },
  { score: { $meta: "textScore" }, title: 1 }
).sort({ score: { $meta: "textScore" } })

Stemming, and what it means for partial words

Text search is language-aware. With the default English analyser, running, runs and ran all reduce to the same stem, and stop words like the are dropped entirely. That is why a search for a common word returns nothing rather than everything.

The consequence that actually bites: there is no prefix matching. Typing fade finds nothing at all, because fade is not a word in fadeaway — it is a substring of one. A search-as-you-type box built on $text stays empty until the user finishes the word.

This is exactly why ReelCMS uses two different mechanisms. The public search uses the text index for relevance ranking. The admin filter, where an editor types into a box and expects the table to narrow immediately, uses an escaped regex instead — accepting a collection scan because an admin list is thousands of documents, not millions.

Seeing it behave

Those two rules — whole words only, terms ORed — are easier to trust once you have watched them. Run against ReelCMS’s sixteen reels:

db.reels.countDocuments({ $text: { $search: "fadeaway" } })          // 1
db.reels.countDocuments({ $text: { $search: "fade" } })              // 0  ← no prefix matching
db.reels.countDocuments({ $text: { $search: "buzzer beater" } })     // 2  ← OR, not AND
db.reels.countDocuments({ $text: { $search: "\"buzzer beater\"" } })  // 0  ← no such phrase

The second line is the one to internalise. fade is four letters of a word that exists in the collection, and it matches nothing — because the index stores stems of whole words, not substrings. The fourth shows the phrase form working correctly: two reels contain one of those words, none contains that exact sequence.

And the one-index limit is not advisory. Adding a second text index to the same collection is refused outright:

db.reels.createIndex({ title: "text" })
// MongoServerError: IndexOptionsConflict

Language, and per-document overrides

The analyser is chosen per index, and it decides both the stemming rules and the stop-word list:

db.reels.createIndex(
  { title: "text", description: "text" },
  { default_language: "english", language_override: "lang" }
)

language_override names a field on each document that can specify its own language, which is how one collection holds content in several. A document carrying lang: "french" is stemmed with French rules; documents without the field fall back to the default.

Setting default_language: "none" turns stemming and stop-word removal off entirely, so terms match literally. That is occasionally what you want for identifiers, part numbers or tags — and it is worth knowing exists before you conclude the index is broken because it will not match a product code.

The other constraint: sorting

A $text query cannot be combined with a sort on an unrelated field and still use the index efficiently. Text search always wants to sort by relevance; asking it to sort by date instead means MongoDB matches on the text index and then sorts the results separately.

That trade shows up plainly in the ReelCMS implementation — when there is a search term it sorts by score, and only when there is not does it sort by date:

if (StringUtils.hasText(q)) {
    query.addCriteria(
            TextCriteria.forDefaultLanguage().matchingAny(q.trim().split("\\s+")));
    query.with(Sort.by(Sort.Direction.DESC, "score"));
    query = query.with(pageable.getSort().isSorted() ? pageable.getSort() : Sort.unsorted());
} else {
    query.with(Sort.by(Sort.Direction.DESC, "publishedAt"));
}

Note matchingAny: that is the OR behaviour above, made explicit in the Java API rather than left as a default someone has to remember.

When to stop and use something else

Native text search is the right choice while your requirements stay inside its shape. Reach for a dedicated search engine — Atlas Search, which embeds Lucene, or Elasticsearch — when you need any of:

  • Typo tolerance. There is no fuzzy matching. A user who types fadaway gets nothing.
  • Autocomplete or prefix matching, per the stemming section above.
  • Faceted search — counts per category alongside results.
  • Synonyms, custom analysers, or per-field language settings.
  • More than one searchable field set, which the one-index limit forbids.

If you are on Atlas, Atlas Search is the natural next step rather than a second system: it runs alongside your data with no separate cluster to synchronise, and it removes every limitation on this list. Running your own Elasticsearch means keeping two datastores in step, which is a real ongoing cost and worth avoiding until something forces it.

When a search returns nothing

Four checks, in the order that finds the cause fastest:

  1. Is the term a whole word? Nine times out of ten this is it.
  2. Is it a stop word? the, and, is are dropped from the index entirely, so searching for one matches nothing at all.
  3. Is the field in the index? db.reels.getIndexes() shows the weights object, which is the definitive list of what is searchable.
  4. Is there a text index at all? A $text query against a collection with none fails rather than returning nothing — which is at least a clear signal.

The short version

  • One text index per collection. Total. Choose the field set carefully.
  • Weights make some fields count more; only their ratio matters.
  • Terms are ORed by default; quote for a phrase, - to exclude.
  • Ranking needs $meta: "textScore" both projected and sorted on.
  • Whole words only, after stemming — no prefix matching, so no search-as-you-type.
  • No fuzzy matching, no facets, no synonyms. When you need those, move to Atlas Search.