System design / Search & storage

How Search Engines Work: Inverted Index, Text Analysis, TF-IDF and BM25

How a full-text search engine finds and ranks results: the inverted index, text analysis (tokenising, stemming, stop words), postings lists and boolean queries, TF-IDF and BM25 scoring, Lucene segments and merges, and near-real-time refresh, explained from the book-index analogy up to system design interview answers.

On this page
  1. Why LIKE '%shoe%' falls over
  2. The book index: what an inverted index is
  3. Text analysis: turning messy text into terms
  4. Postings lists and boolean queries: finding the matches
  5. Scoring, part 1: TF-IDF in plain words
  6. Scoring, part 2: BM25, the default everywhere
  7. Real systems: Lucene, and everything built on it
  8. Segments: why Lucene never edits a file
  9. Near-real-time: why a new doc isn't searchable instantly
  10. Relevance tuning basics
  11. How to say it in the interview

Flip to the back of any textbook. That list of words with page numbers next to them is an inverted index, and it is the same idea that powers Elasticsearch, Lucene and Postgres full-text search. Once you see the book index inside a search engine, the rest (analysis, postings, TF-IDF, BM25, segments, refresh) becomes a series of small, sensible decisions.

Where this fits: Part of the Search & storage series. This is the "look it up in the index" stop on the journey of a query. Here we open that box.

Before you start: Data structures for system design (the inverted index and FST in brief) and Reads and writes (the LSM idea behind segments). Disk I/O and durability is nice to have, not required.

After reading this you will be able to

  • Explain why LIKE '%shoe%' breaks down, and what an inverted index does instead.
  • Walk a sentence through an analyzer (tokenise, lowercase, stop words, stem) and say why the query must use the same one.
  • Intersect two postings lists by hand and explain why sorted doc IDs make boolean queries cheap.
  • Explain TF-IDF and BM25 in plain words, including what k1 and b control.
  • Describe Lucene segments, merges and near-real-time refresh, and why "searchable" and "durable" are different moments.
  • Give a 30-second interview answer to "how would you add search to this product?"

For this blog, we'll stay on one machine, one index, text only. Shards, replicas and clusters wait for the Elasticsearch blog. Typos, autocomplete, geo and vectors wait for Types of search.

01Why LIKE '%shoe%' falls over

Picture a shop with 10 million products in a table. A user types "shoe" into the search box, and the first version of the code looks like this:

SELECT id, title
FROM products
WHERE title LIKE '%shoe%';

Fine on your laptop, painful in production. A normal database index is a sorted list (a B-tree), which helps only when you know how the value starts. LIKE 'shoe%' can jump to the "shoe" part of the sorted list, the way you jump to "S" in a phone book. LIKE '%shoe%' can't, because "shoe" might sit anywhere inside the title. So the database does a full table scan: it reads every single row and checks it. An index exists to avoid that scan, and a leading wildcard throws it away.

And even when the scan is fast enough, the answer is wrong:

  • It misses "Shoes" and "SHOE" unless you lowercase everything, and it treats "running shoe" and "shoes for running" as unrelated.
  • It matches "shoehorn", which is not a shoe.
  • It can't rank. "Trail running shoe" and a phone case that mentions "shoe" once come back in random order.

Postgres does have a fix for the speed part: its pg_trgm extension builds trigram indexes where, unlike B-tree searches, the search string does not need to be anchored at the start. That fixes speed, but not plurals or ranking.

So we have two separate problems, and they give this blog its shape. First, find the matching documents without reading everything. That is the index. Second, order them so the best ones come first. That is scoring.

Try this first

Before reading on: how would you find every page that mentions "photosynthesis" in a 600-page textbook, without reading the book? You already know the answer. You'd flip to the index at the back.

Seen this before?

"A sorted index helps a prefix, not a middle" comes straight from how a B-tree works. The B-tree section of Data structures for system design builds it from scratch.

02The book index: what an inverted index is

The back of a textbook maps word → page numbers. A search engine does the same thing, except it maps term → list of document IDs. That's the core data structure behind almost every search box you've used.

Inverted index Definition

A map from each term to the sorted list of documents that contain it. It is called "inverted" because a normal file goes document → words, and this goes word → documents.

A few words we'll use for the rest of the series, one line each:

  • Document: one thing you can return as a result. A product, a web page, a chat message.
  • Field: a named part of a document, like title or body.
  • Term: a cleaned-up word from a field, after analysis (next section). "Running" might become the term run.
  • Dictionary: the sorted list of every distinct term in the index.
  • Postings list: for one term, the sorted list of document IDs that contain it. One entry is a "posting".
  • Forward index: the opposite direction, document → its terms.

Let's build one by hand. Take three tiny product titles:

  • D1: "Red running shoes"
  • D2: "Blue shoe rack"
  • D3: "Running in the rain"

Lowercase each word, cut "running" to "run" and "shoes" to "shoe", skip "in" and "the" for now, note which documents each term came from, and sort the terms. The textbook Introduction to Information Retrieval describes exactly these four steps: collect the documents, tokenise, normalise, then build the dictionary and postings. Here is the result next to an ordinary book index.

Fig. 1

A book index and an inverted index have the same shape

A book index and an inverted index have the same shapeLeft: the index at the back of a textbook maps each word to the pages it appears on, sorted by word. Right: three tiny documents (Red running shoes, Blue shoe rack, Running in the rain) are analyzed and inverted into a sorted dictionary of terms, each pointing at the document IDs that contain it.Back of a textbookSearch engineanalyze+ invertosmosisp. 33, 97photosynthesisp. 12, 48, 210xylemp. 51D1Red running shoesD2Blue shoe rackD3Running in the rainterm: doc IDsblue: D2rack: D2rain: D3red: D1run: D1, D3shoe: D1, D2Same shape: a sorted list of words, each pointing at where it appears
Same shape as the back of a textbook. A sorted list of words, each pointing at where it appears. The engine just says "doc IDs" where the book says "pages".

Now the query "running shoe" is easy. Look up run: D1, D3. Look up shoe: D1, D2. Only D1 has both. We never touched D2's or D3's text. With 10 million products, that difference is the whole game: the work depends on how many documents match, not on how many exist. Elastic's Elasticsearch from the Bottom Up builds another one from three tiny documents.

Seen this before?

The inverted index showed up in one paragraph in Data structures for system design, next to B-trees, LSM trees and Bloom filters. This blog is the long version.

03Text analysis: turning messy text into terms

Notice what I did quietly above: "Running" became run. That step is called analysis, and it decides what counts as a match. If "Running", "runs" and "RUN!" become three different terms, a user who types "run" misses two of them.

Analysis runs twice: once at index time on the document, and again at query time on what the user typed. Both sides must produce the same terms, or they never meet in the dictionary. It's like a filing cabinet: if one person files under "Shoes" and another looks under "shoe", the paper is there but nobody finds it.

Here are the steps, in the order they run:

  • Character filter: cleans the raw text before it is split. It might strip HTML tags or turn "&" into "and".
  • Tokenisation: splits text into tokens, usually on spaces and punctuation. It also records each token's position and character offsets, used later for phrase search and highlighting.
  • Normalisation: lowercasing, and folding accents so "café" becomes "cafe".
  • Stop words: very common words like "the" and "a". A stop-word filter drops them.
  • Stemming: crude suffix chopping by rules. Elastic's docs show an algorithmic stemmer turning both "jumping" and "jumpiness" into jumpi. Not a real word, and it doesn't need to be.
  • Lemmatisation: the dictionary-based cousin of stemming. It knows "better" comes from "good".

Analyzer Definition

The whole chain for one field: zero or more character filters, then exactly one tokenizer, then zero or more token filters (lowercase, stop words, stemmer and so on). That's how Elastic's anatomy of an analyzer page describes it.

Let's walk one messy product title through the chain, step by step.

Fig. 2

Text analysis turns raw text into index terms

Text analysis turns raw text into index termsThe sentence "The Runners' NEW shoes!" passes through character filters, a tokenizer that records positions, a lowercase filter, a stop-word filter and a stemmer, ending as the terms runner, new and shoe. The same analyzer runs at index time and at query time.drops "the"Raw textThe Runners'NEW shoes!1Character filtersstrip HTML, map"&" to "and"2TokenizerThe | Runners |NEW | shoes3Lowercasethe | runners |new | shoes4Stop-word filterrunners | new |shoes5Stemmerrunner | new |shoe6Terms that reach the indexrunner, new, shoeTokenizer also records positions (0, 1, 2, 3) and character offsetsSame analyzer at index time and at query time, or matches vanish quietly
Same pipeline at index time and at query time. If the two sides differ, matches vanish quietly, with no error anywhere.

The tokenizer splits "The Runners' NEW shoes!" into four tokens, lowercasing gives the runners new shoes, the stop-word filter drops the, and the stemmer leaves runner, new, shoe.

The trade-offs inside analysis

Every step makes more things match. That's good for recall (did we find all the relevant documents?) and can be bad for precision (are the documents we found actually relevant?). Aggressive stemming can fold "university" and "universe" together. IIR's stemming and lemmatization page is a short tour of this tension.

Stop words have their own trade-off. The classic approach, described in IIR's stop words section, is to sort terms by how often they appear and drop the most common ones. But a query like "to be or not to be" is all stop words. Many teams now keep them. In fact, Elasticsearch's standard analyzer does not remove stop words by default; its stop list defaults to none. Scoring already gives common words a tiny weight anyway.

Some fields should barely be analysed at all: a SKU like AB-1234-XL should match exactly. Elastic's stemming guide repeats the core rule: same stemmer at index and search time.

Many candidates stop at "we lowercase it"

That's half an answer. Interviewers want to hear that the query goes through the same analyzer as the documents, or nothing matches. Bonus: changing the analyzer later means re-indexing, because old terms are baked in.

04Postings lists and boolean queries: finding the matches

Here's the trick that makes everything fast: every postings list is sorted by document ID. Two sorted lists can be combined by walking both from the front with two pointers, never going back.

Boolean queries map onto simple list operations:

  • AND = intersect the lists (docs in both).
  • OR = union (docs in either).
  • NOT = subtract (docs in the first, not the second).

Take running AND shoe. After analysis it becomes run AND shoe. Say run has postings [1, 3, 7, 12] and shoe has [3, 5, 12, 40]. Compare the two heads, move the smaller one forward, and record a match when they're equal:

Fig. 3

Intersecting two sorted postings lists with two pointers

Intersecting two sorted postings lists with two pointersThe query running AND shoe becomes run AND shoe. The postings list for run is 1, 3, 7, 12 and for shoe is 3, 5, 12, 40. Two pointers walk both lists forward once and the matches are 3 and 12.both pointers only move forward13712351240Result: 3, 12runshoeWalk it: compare the two heads1 vs 3 -> 1 is smaller, move run3 vs 3 -> match! keep 3, move both7 vs 5 -> 5 is smaller, move shoe7 vs 12 -> 7 is smaller, move run12 vs 12 -> match! keep 12, move bothrun is used up -> stopQuery: running AND shoe (analyzed to run AND shoe)Sorted lists make AND one forward pass. Start from the shorter list.
Because both lists are sorted, AND is one forward pass. The work is the length of the two lists, never their product. Start from the shorter list.

One plain-words optimisation is worth saying out loud: start with the rarest term. The result of an AND can't be longer than the shortest list, so if trail appears in 500 docs and shoe in 2 million, you walk the 500 and jump ahead in the long list.

What else lives in a posting

A posting usually stores more than the doc ID:

  • Term frequency: how many times the term appears in that document. Scoring needs it.
  • Positions: where in the field the term appears. Phrase search ("running shoes" as an exact phrase) needs them. We use these in Types of search.

Making postings small

Postings for common terms hold millions of IDs, so engines compress them. The first trick is to store the gaps between IDs instead of the IDs themselves: [3, 12, 40, 41] becomes [3, 9, 28, 1]. Small numbers need fewer bits. Lucene packs gaps in blocks (Frame of Reference). For cached filters it uses Roaring bitmaps: doc IDs are split into blocks of 65,536, and a block with fewer than 4,096 values is stored as a sorted array, otherwise as a bitmap. Adrien Grand's post Frame of Reference and Roaring Bitmaps explains both.

Finding the term itself

And how do you find shoe among millions of terms? Lucene keeps a compact index into its term dictionary, built as an FST (finite state transducer: an automaton that shares common prefixes and suffixes between terms, so a huge sorted term list fits in little memory). For the rabbit hole, Andrew Gallant's Index 1,600,000,000 Keys with Automata and Rust is the friendliest FST explainer I know.

Seen this before?

Postings lists and the FST first appeared in Data structures for system design. Same structures, now doing a real job.

05Scoring, part 1: TF-IDF in plain words

Matching gives a set. Users want an ordered list. Scoring answers one question: which match is the best match?

Start with two everyday hunches.

Term frequency (TF). A product page that says "trail" five times is probably more about trail gear than one that says it once. So more mentions of a query term should push the score up.

Inverse document frequency (IDF). Not all terms are equally useful. If the query is "new trail shoe", the word new appears in half the catalogue, so matching it tells you almost nothing. trail appears in a few hundred products, so matching it tells you a lot. Document frequency (DF) is the number of documents containing a term; IDF flips it so rare terms weigh more.

Multiply the two and add them up over the query terms, and you have TF-IDF. In the simplest form from the IIR chapter on inverse document frequency, with N documents in total:

idf(t)        = log( N / df(t) )
tf-idf(t, d)  = tf(t, d) × idf(t)
score(q, d)   = sum of tf-idf(t, d) for every term t in the query

In words: "how much this document talks about the term" times "how rare the term is", summed over the query. It works surprisingly well, but it has two flaws, and BM25 exists to fix them.

  1. TF grows without limit. A page that repeats "trail" 50 times scores 10 times higher than one that says it 5 times. Keyword stuffing wins.
  2. Long documents win by being long. A 5,000-word blog post mentions almost everything a few times. A 6-word product title can't compete, even when the title is exactly what the user wanted.

06Scoring, part 2: BM25, the default everywhere

BM25 keeps IDF and fixes both flaws with two knobs. It comes from the probabilistic relevance framework of information retrieval research, and the canonical write-up is Robertson and Zaragoza's The Probabilistic Relevance Framework: BM25 and Beyond (Foundations and Trends in Information Retrieval 3(4), 2009, pp. 333-389). Today it is the default similarity in Elasticsearch, and the scorer you should assume unless told otherwise.

Knob 1: TF saturation (k1)

Think about restaurant tips. The first friend who says "the biryani is great" changes your mind. By the tenth, you've stopped counting. BM25 treats term frequency the same way: the 10th mention adds much less than the 2nd.

k1 is "how quickly extra mentions stop helping". A low k1 saturates fast; a high k1 lets repetition count for longer. Here's the shape.

Fig. 4

TF-IDF keeps rewarding repetition; BM25 saturates

TF-IDF keeps rewarding repetition; BM25 saturatesIllustrative chart, not real scores. The x-axis is how many times a term appears in a document, from 1 to 10. The TF-IDF line keeps climbing in a straight line. The BM25 curve rises quickly for the first few mentions and then flattens toward a ceiling controlled by k1.TF-IDFkeeps climbingwith every mentionBM25flattens outafter a few mentionsk1how fast extramentions stop helping12345678910times the term appears in the documentscore from this termIllustrative shape only: no real scores are plottedceiling set by k1stuffing the word 10x keeps payingextra mentions stop paying
BM25's key idea: after a few mentions, more repetition barely moves the score. TF-IDF keeps climbing in a straight line. The shape is illustrative; no real scores are plotted.

To put rough numbers on it (my own arithmetic, for a document of average length with k1 = 1.2), the term-frequency part of BM25 is 1.0 for one mention, about 1.38 for two, about 1.77 for five and about 1.96 for ten. It can never pass k1 + 1 = 2.2. Ten mentions earn roughly twice what one earns, not ten times.

Knob 2: length normalisation (b)

A match in a 10-word title says more than one in a 5,000-word page. BM25 compares each field's length with the average for that field, and scales the TF part down for long fields and up for short ones.

b is "how much we punish long fields". At b = 0, length doesn't matter at all. At b = 1, it fully counts.

The defaults are worth memorising: Elasticsearch's similarity settings give k1 = 1.2 and b = 0.75. Elastic's Practical BM25, Part 2 walks through each part with a four-document example.

A worked example, in words

The user searches for "trail". Two documents match:

  • Doc A: a product titled "Trail running shoe", 3 words long, with "trail" once.
  • Doc B: a 1,000-word blog post about a holiday, which mentions "trail" three times in passing.

Under raw TF-IDF, Doc B wins: three mentions beat one, with the same IDF. Under BM25, saturation shrinks B's extra mentions, and length normalisation punishes B for being far longer than average while rewarding A for being short. With a 200-word average and the default knobs, my rough arithmetic gives a TF part of about 1.66 for A and 0.85 for B. The short title wins roughly two to one, which is what a shopper wants.

The BM25 formula (optional)

For a query Q with terms q1...qn and a document D:

score(D, Q) = sum over each query term q of
    IDF(q) × ( f(q, D) × (k1 + 1) )
             / ( f(q, D) + k1 × (1 - b + b × |D| / avgdl) )

IDF(q) = ln( 1 + (N - n(q) + 0.5) / (n(q) + 0.5) )

Here f(q, D) is how often q appears in D, |D| is the length of the field, avgdl is the average field length, N is the number of documents and n(q) is how many of them contain q. This is the form Elastic's Practical BM25 post walks through. The bracket with b is the length penalty: it equals 1 when |D| equals avgdl.

Default to BM25 and leave k1 and b alone

Start with the defaults. Change k1 or b only when you have a set of judged queries to measure against. Most bad results come from analysis or field choices, not from these two numbers.

07Real systems: Lucene, and everything built on it

Apache Lucene is a Java library, not a server. It does everything we've covered so far: analysis, the inverted index, postings, BM25, segments. Most of the famous search servers wrap it:

  • Elasticsearch adds a cluster, a REST API and sharding around Lucene.
  • OpenSearch is a community fork, announced in April 2021 and derived from Elasticsearch 7.10.2, so it shares the same Lucene core.
  • Solr is a search server built on Apache Lucene.
  • MongoDB Search (formerly Atlas Search) is, per MongoDB's product page, built on Apache Lucene too.

Learn Lucene's ideas and you've learned the core of all of them.

Databases you already run have the same idea built in. Postgres stores analysed text as a tsvector and indexes it with GIN, which literally stands for Generalized Inverted Index. Redis Search describes itself as using compressed, inverted indexes, and is built into Redis starting with Redis 8. We compare them properly in Choosing a search engine.

For a real story, LinkedIn's post Did you mean "Galene"? explains how LinkedIn's search started on Lucene, grew layers on top, and was eventually rebuilt as a system called Galene. It explains inverted and forward indexes in plain terms; we summarise it in Search case studies.

One teaser: when the index outgrows one machine, you split it into shards, send the query to every shard, and merge their top results. That's scatter-gather.

Seen this before?

The inverted index inside Lucene, Postgres GIN and Redis Search is the same idea in three costumes; Choosing a search engine puts them side by side. Scatter-gather gets its full treatment in the read path of Elasticsearch for system design.

08Segments: why Lucene never edits a file

So far the index has been one neat structure. In reality, it's a pile of smaller ones.

Segment Definition

A small, complete, read-only inverted index (its own dictionary, postings and stored fields). Lucene's file format docs call each segment a "fully independent index". A search runs on every segment and combines the results.

Here's the life of a document:

  • Add: the new document waits in an in-memory buffer. When the buffer is flushed, it becomes a brand-new small segment.
  • Delete: the old segment is not touched. The document is only marked as deleted in a separate small file, and searches filter it out.
  • Update: a delete plus an add. The old version is marked deleted; the new version goes into a new segment.

Why not edit files in place? Elasticsearch from the Bottom Up says Lucene's files are immutable, never updated, unlike a B-tree's pages. That buys a lot:

  • No locks for readers. A file that never changes can be read by any number of searches at once.
  • Great caching. The OS page cache can hold hot files without them ever going stale.
  • Better compression. Frozen data is packed tightly once.

The cost is clutter: many small segments slow searches, and deleted documents waste space. So Lucene runs merges: a background job that combines several small segments into one bigger segment and leaves the deleted documents behind. Elasticsearch's merge settings describe exactly this, including that merges are IO-throttled.

The picture looks like a staircase:

Fig. 5

Small immutable segments get merged into bigger ones

Small immutable segments get merged into bigger onesNew documents wait in an in-memory buffer, then get written out as small read-only segments. A background merge combines small segments into a medium one, and later into a large one, dropping documents that were marked as deleted. Searches read every live segment.mergeIn-memorybuffernew docs wait hereSeg 1smallSeg 22 docs deletedSeg 3smallSeg Amediummade of 1 + 2 + 3Seg Blargedeleted docsfinally droppedflushmergeDeletes are only marked, never edited in placeMerges run in the background, IO-throttledSearch reads every live segment and combines the results. Same shape as an LSM tree.
New data lands in small immutable files; background merges roll them into bigger ones. Deleted documents are only dropped when their segment is merged.

Merging isn't free. Every merge rewrites data that was already on disk. Lucene committer Mike McCandless made a lovely video of this in Visualizing Lucene's segment merges. He indexed English Wikipedia (29 GB of plain text) into a 10.87 GB index, and merging copied 67.30 GB in total along the way. That's a write amplification of 6.19: each byte of the final index was written about six times.

Seen this before?

Small immutable files plus background merging is the LSM-tree shape from Reads and writes and Data structures. The comparison is ours, not Lucene's: the original LSM-tree paper doesn't mention Lucene. But the trade is the same: cheap writes now, merge cost later.

09Near-real-time: why a new doc isn't searchable instantly

A classic production puzzle: "I indexed the product, the API said OK, why can't I find it?"

Because the document is still in the in-memory buffer, and searches only read segments. To make it searchable, Lucene has to write a new segment and open a reader that can see it. There are two ways, at very different costs.

  • Commit: write the segment files and fsync them (force them from the operating system's memory onto the physical disk), then record the new set of segments. Durable, but fsync is slow, so you can't do it every second.
  • Near-real-time (NRT) reopen: write the new segment into the filesystem cache (the operating system's memory copy of the file) and open it for search, without fsync. Cheap, so you can do it often. McCandless explains how it works in Lucene's near-real-time search is fast!; NRT has existed since Lucene 2.9.

Elasticsearch calls the NRT reopen a refresh. According to its near real-time search docs, the default refresh runs every 1 second, but only on indices that have received at least one search in the last 30 seconds. That's why documents become searchable "within 1 second", and why it's called near real-time.

Fig. 6

Searchable and durable are two different moments

Searchable and durable are two different momentsA document is indexed into an in-memory buffer where it is neither searchable nor durable. A refresh writes a new segment to the filesystem cache and opens it, so it becomes searchable but is not fsynced. A commit fsyncs the segment files so they survive a power cut.Index a docAPI call returns1In-memorybufferinside the process2Refreshnew segment in thefilesystem cache3Commitfsync segmentfiles to disk4Searchable?Survives a crash?about every 1 s in Elasticsearchless often: fsync is costlynonoyesnoyesyesThe gap between refresh and commit is covered by a write-ahead log (the translog in Elasticsearch)
Searchable and durable are two different moments. NRT gives you the first one cheaply. The commit gives you the second one, less often.

Notice the gap: after a refresh, a document is searchable but not fsynced. If the power dies, what saves it? A write-ahead log: every change is appended to a log on disk before it is acknowledged, and replayed after a crash. Elasticsearch calls its log the translog, and we cover it in the write path of the Elasticsearch blog.

Seen this before?

The page cache and fsync are the whole story of Disk I/O and durability. The write-ahead log is the same trick Postgres uses with its WAL, covered in Reads and writes.

Many candidates promise "real-time search"

Then the interviewer asks the cost. More frequent refresh means more tiny segments and more merging; during bulk loads teams often slow refresh down. Say "searchable within about a second" and explain why.

10Relevance tuning basics

Start with the defaults (BM25, a standard analyzer), measure, then change one thing at a time, cheapest first:

  1. Pick the right analyzer per field. Stem descriptions; don't stem SKUs, IDs or brand names.
  2. Boost fields. A match in the title should usually beat a match in the body.
  3. Boost phrases and proximity. "running shoes" next to each other beats the two words far apart. We cover it in Types of search.
  4. Add business signals (popularity, rating, recency) on top of the text score.
  5. Tune k1 and b only if you have a test set to prove it helped.

To measure, build a small judged set: real queries, each with documents marked good or bad. Track two numbers. Precision: of the results we showed, how many were good? Recall: of all the good documents, how many did we show?

Bigger teams add a learned ranking stage. Slack's Search at Slack describes a two-stage design: Solr retrieves candidates with a few cheap features, then the application re-ranks them with a learned model.

Many candidates jump straight to "add ML ranking"

Most bad results come from analysis and field choices, not scoring. A stemmed SKU field or a body match outranking a title match won't be fixed by a model. Start with analyzers and field boosts.

11How to say it in the interview

When I ask "how would you add search to this product?", I'm listening for three things: an inverted index, an analyzer that runs on both sides, and BM25 as the default ranker.

The 30-second answer

"I wouldn't use LIKE; it scans every row and can't rank. I'd put the searchable fields into an inverted index, Lucene-based like Elasticsearch, or Postgres full-text with GIN if the scale is small. At index time each field goes through an analyzer: tokenise, lowercase, maybe stem. The query goes through the same analyzer, then we look up each term's postings list, intersect or union them for the boolean match, score the matches with BM25, and return the top k. Under the hood the index is immutable segments merged in the background, and new documents show up after a refresh, about one second in Elasticsearch."

Then expect these follow-ups.

Why not just use LIKE?

A leading wildcard can't use a sorted index, so it scans every row, and it can't handle plurals or rank. An inverted index fixes finding; BM25 fixes ordering.

How do you handle plurals and different word forms?

In the analyzer: a stemmer (or lemmatiser) folds "shoes" and "shoe" into one term. It must run at both index and query time. Skip it for exact fields like SKUs.

Why isn't a new product searchable right away?

It sits in an in-memory buffer until the next refresh writes a new segment and opens it for search. In Elasticsearch that's about every second by default. Durability is separate: a commit fsyncs the segment files, and a write-ahead log covers the gap.

How would you rank by popularity too?

Keep BM25 for text relevance and combine it with sales, rating or recency, either as a query-time boost or in a second re-ranking stage over the top results.

What happens when the index doesn't fit on one machine?

Split it into shards, send each query to every shard, and merge their top results (scatter-gather). That's the subject of the Elasticsearch blog.

Pitfalls that cost points: forgetting query-time analysis, calling TF-IDF the modern default, and promising real-time search without the refresh cost.

Whiteboard summary

  • Analyze → Index (dictionary + postings) → Match (AND / OR) → Score (BM25) → Top k
  • Same analyzer at index time and query time
  • BM25 defaults: k1 = 1.2 (saturation), b = 0.75 (length)
  • Immutable segments + background merges (LSM shape)
  • Refresh about every 1 s = searchable; commit + fsync = durable; WAL covers the gap

Key takeaways

  1. An inverted index is the back of a book: term → sorted list of doc IDs. Sorted lists make AND and OR a single forward pass.
  2. Analysis (tokenise, normalise, stop words, stem) decides what counts as a match, and it must be the same at index and query time.
  3. TF-IDF rewards terms that are frequent in the document and rare in the collection; BM25 adds saturation (k1 = 1.2) and length normalisation (b = 0.75), and it is the default.
  4. Lucene writes immutable segments and merges them in the background, the same idea as an LSM tree, at a real write-amplification cost.
  5. "Near real-time" means a refresh (about every 1 second in Elasticsearch) makes docs searchable before they are durable on disk.

Next up

Types of search: autocomplete, fuzzy, phrase, geo, faceted, vector and hybrid search, all built on the index you just learned.

Related

Real-world story

LinkedIn: how search outgrew plain Lucene and became Galene.

Further reading

Interview practice