Search and inverted indexes
How full text search works. Analyzers, inverted indexes, BM25 ranking, segments and sharding, and when to pick Postgres full text or Elasticsearch.
- Read first
- Indexes
The problem it solves
Section titled “The problem it solves”An outdoor store sells 50 million products. Each listing has a title and a description, about 1 KB of text: 50 GB in all. The search box takes 2,000 queries a second at peak, and the product team wants results in under 200 ms. The first version is one line of SQL:
SELECT * FROM products WHERE description ILIKE '%trail running shoes%';It is slow. A B-tree is sorted by the start of the value. So a pattern that starts with %
cannot seek. The database reads every row, and runs a substring match on each.
| Where the 50 GB lives | One full scan | Queries a second it can answer |
|---|---|---|
| Already in RAM, 16 cores in parallel | 2 to 5 s | Under 1 |
| Local NVMe SSD at 3 GB/s | About 17 s | Well under 1 |
| Cloud block volume at 250 MB/s | About 3.5 min | None worth counting |
The workload asks for 2,000 a second. No index on description fixes a leading wildcard.
It finds the wrong things. It misses “Running shoes for the trail” (word order) and “trail-running shoe” (punctuation, plural). And a search for “shoes” matches “horseshoes”.
It cannot rank. If 40,000 listings match, SQL has no notion of which ten are best.
A search engine does the work before the query arrives. At index time, it breaks every document into normalized words, called terms. It maps each term to the sorted list of documents that contain it. This is an inverted index. A three-word query reads three lists, instead of 50 million rows.
The price is a second copy of the data that lags the database. Analysis choices are baked in until you reindex. And it’s a distributed system to run.
How it works
Section titled “How it works”Step 1: Turn text into terms
Section titled “Step 1: Turn text into terms”An analyzer turns text into a list of terms, for documents and for queries. The index only ever answers “which documents contain this exact term?”, so everything that makes “Running”, “runs” and “run” the same word happens here.
- Color key
- Service
- The index
Each stage is a choice with a cost:
| Stage | Example | What it buys | What it costs |
|---|---|---|---|
| Tokenize | Trail-Running → Trail, Running |
Words regardless of punctuation | Breaks C++, wi-fi, part numbers and emails unless configured |
| Lowercase | Running → running |
Case-insensitive matching | US and us become one term |
| Fold | café → cafe |
Accents and diacritics stop mattering | Loses meaning in languages where the accent changes the word |
| Stop words | drop the, of, for |
Smaller index, fewer huge postings lists | “The Who” and “to be or not to be” become empty queries |
| Stem | running, runs → run |
Matches across word forms | Over-stemming joins unrelated words: university and universe |
| Synonyms | sneakers → shoe |
Matches the customer’s word to the catalog’s | A list to maintain; a bad entry silently pollutes every query with it |
Stemmers such as Porter and Snowball chop suffixes by rule. A lemmatizer looks words up in a
dictionary (better → good). Chinese and Japanese need a dictionary tokenizer, or character
grams.
Index-time and query-time analysis must produce the same form of each term. Say documents are
stemmed and queries are not. The index holds run, the query asks for running, and the search
silently returns less. Two asymmetries are safe, because the forms still meet:
- Synonyms at query time only. Expanding
sneakerstoshoein the query means the synonym list can change without rebuilding the index. - Edge n-grams at index time only. For search-as-you-type,
shoesis indexed ass,sh,sho,shoe,shoes. The queryshomatches one of them. If you split the query into grams too,shomatches everything starting withs.
Changing an analyzer means reindexing every document, since terms are computed at write time.
Step 2: The inverted index
Section titled “Step 2: The inverted index”A database row is a forward index: document → its words. Search needs the reverse. Give each document an integer id, analyze it, and append the id to the list of each of its terms. Ids are handed out in increasing order, so every list stays sorted for free.
- Color key
- Service
- Kept in memory
The term dictionary holds every distinct term, sorted. Each term has a pointer to its postings, and its document frequency (df): how many documents contain it. A large index has hundreds of millions of terms.
Lucene is the library inside Elasticsearch, OpenSearch and Solr. It keeps the dictionary on disk in blocks. In memory, it holds only a finite state transducer (FST). That’s an automaton that shares common prefixes and suffixes, and maps a term’s prefix to its disk block. A lookup walks the FST, then reads one block. Prefix, wildcard, regex and fuzzy queries walk the same automaton. They visit only terms that could match.
The postings list for a term holds the sorted ids of the documents that contain it. It can optionally hold three more things:
- The term frequency (tf) in each document, for ranking.
- The positions, for phrase queries. (
"trail running"needsrunright aftertrail.) - Character offsets, for highlighting.
Positions are often the largest part of an index. So a keyword field such as a SKU stores ids alone.
Compression is what lets postings for a common term fit. Lucene stores the gaps between consecutive ids (delta encoding). Then it bit-packs each block of 128 gaps at the width of its largest gap (frame of reference encoding):
| Representation | Ids 1003, 1007, 1012, 1013, ... |
128 ids |
|---|---|---|
| Plain 32-bit integers | 4 bytes each | 512 bytes |
Gaps: 1003, 4, 5, 1, ... |
Small numbers | still 512 if unpacked |
| Gaps bit-packed at the block’s widest gap | 4 bits each if every gap is under 16 | about 64 bytes |
The more common the term, the smaller its gaps: a term in most documents costs a few bits per entry. This works because ids are small integers local to one segment (Step 5), never your database’s primary keys.
Skip data sits beside each list. For every block of 128 ids, it stores the block’s last id and
file offset, in several levels. So the reader jumps over whole blocks, without decompressing them.
“Advance to the first id at or past X” is the basic operation every query is built from. In the
code, advance stands in for skip data, with a galloping search.
import java.util.ArrayList;import java.util.Comparator;import java.util.HashMap;import java.util.LinkedHashSet;import java.util.List;import java.util.Map;import java.util.Set;import java.util.regex.Matcher;import java.util.regex.Pattern;
public class InvertedIndexDemo { // Words too common to narrow a search. Real lists are per language, and per field. static final Set<String> STOP_WORDS = Set.of("a", "an", "and", "the", "of", "for", "with", "to", "on", "in"); static final Pattern TOKEN = Pattern.compile("[a-z0-9]+");
// A toy stemmer: strip one English suffix so "running", "runs" and "run" become one term. // Production analyzers use Porter or Snowball rules; the idea is the same. static String stem(String word) { for (String suffix : List.of("ing", "ed", "s")) { if (word.endsWith(suffix) && word.length() - suffix.length() >= 3) { word = word.substring(0, word.length() - suffix.length()); break; } } // "runn" -> "run": a doubled final consonant is left over from the suffix. int n = word.length(); if (n >= 2 && word.charAt(n - 1) == word.charAt(n - 2) && "aeiouls".indexOf(word.charAt(n - 1)) < 0) { word = word.substring(0, n - 1); } return word; }
// The same function runs at index time and at query time. If they ever differ, a document // indexed under "run" is never found by a query for "running". static List<String> analyze(String text) { List<String> terms = new ArrayList<>(); Matcher m = TOKEN.matcher(text.toLowerCase()); while (m.find()) { if (!STOP_WORDS.contains(m.group())) terms.add(stem(m.group())); } return terms; }
// First index in list[lo, hi) whose value is >= target. static int lowerBound(List<Integer> list, int lo, int hi, int target) { while (lo < hi) { int mid = (lo + hi) >>> 1; if (list.get(mid) < target) lo = mid + 1; else hi = mid; } return lo; }
// Jump forward 1, 2, 4, 8 ... entries until we pass target, then binary search the last // jump. This is what skip lists give a postings list on disk: skip blocks without reading them. static int advance(List<Integer> list, int lo, int target) { int step = 1, hi = lo; while (hi < list.size() && list.get(hi) < target) { lo = hi + 1; hi = lo + step; step *= 2; } return lowerBound(list, lo, Math.min(hi + 1, list.size()), target); }
// Walk the short list and probe the long one. The cost follows the short list (times a log), // not the total length, which is why engines start from the rarest term. static List<Integer> intersect(List<Integer> shortList, List<Integer> longList) { List<Integer> out = new ArrayList<>(); int pos = 0; for (int doc : shortList) { pos = advance(longList, pos, doc); if (pos == longList.size()) break; if (longList.get(pos) == doc) out.add(doc); } return out; }
static class InvertedIndex { final Map<String, List<Integer>> postings = new HashMap<>(); // term -> sorted doc ids
// Ids arrive in increasing order, so appending keeps every list sorted. Sorted lists are // what make intersection and delta compression cheap. void add(int docId, String text) { for (String term : new LinkedHashSet<>(analyze(text))) { postings.computeIfAbsent(term, k -> new ArrayList<>()).add(docId); } }
List<Integer> searchAll(String query) { List<List<Integer>> lists = new ArrayList<>(); for (String term : new LinkedHashSet<>(analyze(query))) lists.add(postings.getOrDefault(term, List.of())); if (lists.isEmpty()) return List.of(); lists.sort(Comparator.comparingInt(List::size)); // rarest first: the answer can't be longer than the shortest list List<Integer> result = lists.get(0); for (List<Integer> other : lists.subList(1, lists.size())) { result = intersect(result, other); if (result.isEmpty()) break; } return result; } }
public static void main(String[] args) { String[] docs = { "Trail running shoes with a grippy sole", "Road running shoes, light and fast", "Waterproof hiking boots for rocky trails", "Running socks, pack of three", "Cleaning kit for running shoes and boots", }; InvertedIndex idx = new InvertedIndex(); for (int i = 0; i < docs.length; i++) idx.add(i + 1, docs[i]);
for (String term : List.of("run", "shoe", "trail")) { System.out.println("postings[" + term + "] = " + idx.postings.get(term)); } System.out.println("\"Running Shoes\" -> " + idx.searchAll("Running Shoes")); System.out.println("\"trail shoes\" -> " + idx.searchAll("trail shoes")); // Skip analysis on the query and nothing matches: the index holds "run", not "Running". System.out.println("raw \"Running\" -> " + idx.postings.getOrDefault("Running", List.of())); }}import re
# Words too common to narrow a search. Real lists are per language, and per field.STOP_WORDS = {"a", "an", "and", "the", "of", "for", "with", "to", "on", "in"}
def stem(word: str) -> str: # A toy stemmer: strip one English suffix so "running", "runs" and "run" become one term. # Production analyzers use Porter or Snowball rules; the idea is the same. for suffix in ("ing", "ed", "s"): if word.endswith(suffix) and len(word) - len(suffix) >= 3: word = word[: -len(suffix)] break # "runn" -> "run": a doubled final consonant is left over from the suffix. if len(word) >= 2 and word[-1] == word[-2] and word[-1] not in "aeiouls": word = word[:-1] return word
def analyze(text: str) -> list[str]: # The same function runs at index time and at query time. If they ever differ, a document # indexed under "run" is never found by a query for "running". tokens = re.findall(r"[a-z0-9]+", text.lower()) return [stem(t) for t in tokens if t not in STOP_WORDS]
def lower_bound(lst: list[int], lo: int, hi: int, target: int) -> int: # First index in lst[lo:hi] whose value is >= target. while lo < hi: mid = (lo + hi) // 2 if lst[mid] < target: lo = mid + 1 else: hi = mid return lo
def advance(lst: list[int], lo: int, target: int) -> int: # Jump forward 1, 2, 4, 8 ... entries until we pass target, then binary search the last # jump. This is what skip lists give a postings list on disk: skip blocks without reading them. step, hi = 1, lo while hi < len(lst) and lst[hi] < target: lo = hi + 1 hi = lo + step step *= 2 return lower_bound(lst, lo, min(hi + 1, len(lst)), target)
def intersect(short: list[int], long: list[int]) -> list[int]: # Walk the short list and probe the long one. The cost follows the short list (times a log), # not the total length, which is why engines start from the rarest term. out, pos = [], 0 for doc in short: pos = advance(long, pos, doc) if pos == len(long): break if long[pos] == doc: out.append(doc) return out
class InvertedIndex: def __init__(self) -> None: self.postings: dict[str, list[int]] = {} # term -> sorted doc ids
def add(self, doc_id: int, text: str) -> None: # Ids arrive in increasing order, so appending keeps every list sorted. Sorted lists are # what make intersection and delta compression cheap. for term in dict.fromkeys(analyze(text)): self.postings.setdefault(term, []).append(doc_id)
def search_all(self, query: str) -> list[int]: lists = [self.postings.get(t, []) for t in dict.fromkeys(analyze(query))] if not lists: return [] lists.sort(key=len) # rarest first: the answer can't be longer than the shortest list result = lists[0] for other in lists[1:]: result = intersect(result, other) if not result: break return result
def main() -> None: docs = [ "Trail running shoes with a grippy sole", "Road running shoes, light and fast", "Waterproof hiking boots for rocky trails", "Running socks, pack of three", "Cleaning kit for running shoes and boots", ] idx = InvertedIndex() for i, text in enumerate(docs, start=1): idx.add(i, text)
for term in ("run", "shoe", "trail"): print(f"postings[{term}] = {idx.postings[term]}") print(f'"Running Shoes" -> {idx.search_all("Running Shoes")}') print(f'"trail shoes" -> {idx.search_all("trail shoes")}') # Skip analysis on the query and nothing matches: the index holds "run", not "Running". print(f'raw "Running" -> {idx.postings.get("Running", [])}')
if __name__ == "__main__": main()using System;using System.Collections.Generic;using System.Linq;using System.Text.RegularExpressions;
public static class Analyzer{ // Words too common to narrow a search. Real lists are per language, and per field. private static readonly HashSet<string> StopWords = new() { "a", "an", "and", "the", "of", "for", "with", "to", "on", "in" };
// A toy stemmer: strip one English suffix so "running", "runs" and "run" become one term. // Production analyzers use Porter or Snowball rules; the idea is the same. public static string Stem(string word) { foreach (var suffix in new[] { "ing", "ed", "s" }) { if (word.EndsWith(suffix, StringComparison.Ordinal) && word.Length - suffix.Length >= 3) { word = word[..^suffix.Length]; break; } } // "runn" -> "run": a doubled final consonant is left over from the suffix. var n = word.Length; if (n >= 2 && word[n - 1] == word[n - 2] && !"aeiouls".Contains(word[n - 1])) word = word[..^1]; return word; }
// The same function runs at index time and at query time. If they ever differ, a document // indexed under "run" is never found by a query for "running". public static List<string> Analyze(string text) => Regex.Matches(text.ToLowerInvariant(), "[a-z0-9]+") .Select(m => m.Value) .Where(t => !StopWords.Contains(t)) .Select(Stem) .ToList();}
public static class Postings{ // First index in list[lo, hi) whose value is >= target. private static int LowerBound(List<int> list, int lo, int hi, int target) { while (lo < hi) { var mid = (lo + hi) / 2; if (list[mid] < target) lo = mid + 1; else hi = mid; } return lo; }
// Jump forward 1, 2, 4, 8 ... entries until we pass target, then binary search the last // jump. This is what skip lists give a postings list on disk: skip blocks without reading them. public static int Advance(List<int> list, int lo, int target) { int step = 1, hi = lo; while (hi < list.Count && list[hi] < target) { lo = hi + 1; hi = lo + step; step *= 2; } return LowerBound(list, lo, Math.Min(hi + 1, list.Count), target); }
// Walk the short list and probe the long one. The cost follows the short list (times a log), // not the total length, which is why engines start from the rarest term. public static List<int> Intersect(List<int> shortList, List<int> longList) { var output = new List<int>(); var pos = 0; foreach (var doc in shortList) { pos = Advance(longList, pos, doc); if (pos == longList.Count) break; if (longList[pos] == doc) output.Add(doc); } return output; }}
public sealed class InvertedIndex{ public Dictionary<string, List<int>> Terms { get; } = new(); // term -> sorted doc ids
// Ids arrive in increasing order, so appending keeps every list sorted. Sorted lists are // what make intersection and delta compression cheap. public void Add(int docId, string text) { foreach (var term in Analyzer.Analyze(text).Distinct()) { if (!Terms.TryGetValue(term, out var list)) Terms[term] = list = new List<int>(); list.Add(docId); } }
public List<int> SearchAll(string query) { var lists = Analyzer.Analyze(query).Distinct() .Select(t => Terms.TryGetValue(t, out var l) ? l : new List<int>()) .OrderBy(l => l.Count) // rarest first: the answer can't be longer than the shortest list .ToList(); if (lists.Count == 0) return new List<int>(); var result = lists[0]; foreach (var other in lists.Skip(1)) { result = Postings.Intersect(result, other); if (result.Count == 0) break; } return result; }}
public static class InvertedIndexDemo{ private static string Show(List<int> ids) => "[" + string.Join(", ", ids) + "]";
public static void Main() { string[] docs = { "Trail running shoes with a grippy sole", "Road running shoes, light and fast", "Waterproof hiking boots for rocky trails", "Running socks, pack of three", "Cleaning kit for running shoes and boots", }; var idx = new InvertedIndex(); for (var i = 0; i < docs.Length; i++) idx.Add(i + 1, docs[i]);
foreach (var term in new[] { "run", "shoe", "trail" }) Console.WriteLine($"postings[{term}] = {Show(idx.Terms[term])}"); Console.WriteLine($"\"Running Shoes\" -> {Show(idx.SearchAll("Running Shoes"))}"); Console.WriteLine($"\"trail shoes\" -> {Show(idx.SearchAll("trail shoes"))}"); // Skip analysis on the query and nothing matches: the index holds "run", not "Running". Console.WriteLine($"raw \"Running\" -> {Show(idx.Terms.GetValueOrDefault("Running", new List<int>()))}"); }}package main
import ( "fmt" "regexp" "sort" "strings")
// Words too common to narrow a search. Real lists are per language, and per field.var stopWords = map[string]bool{"a": true, "an": true, "and": true, "the": true, "of": true, "for": true, "with": true, "to": true, "on": true, "in": true}
var tokenRe = regexp.MustCompile(`[a-z0-9]+`)
// stem is a toy stemmer: strip one English suffix so "running", "runs" and "run" become one// term. Production analyzers use Porter or Snowball rules; the idea is the same.func stem(word string) string { for _, suffix := range []string{"ing", "ed", "s"} { if strings.HasSuffix(word, suffix) && len(word)-len(suffix) >= 3 { word = word[:len(word)-len(suffix)] break } } // "runn" -> "run": a doubled final consonant is left over from the suffix. n := len(word) if n >= 2 && word[n-1] == word[n-2] && !strings.ContainsRune("aeiouls", rune(word[n-1])) { word = word[:n-1] } return word}
// analyze runs at index time and at query time. If they ever differ, a document indexed// under "run" is never found by a query for "running".func analyze(text string) []string { var terms []string for _, tok := range tokenRe.FindAllString(strings.ToLower(text), -1) { if !stopWords[tok] { terms = append(terms, stem(tok)) } } return terms}
func unique(terms []string) []string { seen := map[string]bool{} var out []string for _, t := range terms { if !seen[t] { seen[t] = true out = append(out, t) } } return out}
// lowerBound returns the first index in list[lo:hi] whose value is >= target.func lowerBound(list []int, lo, hi, target int) int { for lo < hi { mid := (lo + hi) / 2 if list[mid] < target { lo = mid + 1 } else { hi = mid } } return lo}
// advance jumps forward 1, 2, 4, 8 ... entries until it passes target, then binary searches// the last jump. This is what skip lists give a postings list on disk: skip blocks without// reading them.func advance(list []int, lo, target int) int { step, hi := 1, lo for hi < len(list) && list[hi] < target { lo = hi + 1 hi = lo + step step *= 2 } return lowerBound(list, lo, min(hi+1, len(list)), target)}
// intersect walks the short list and probes the long one. The cost follows the short list// (times a log), not the total length, which is why engines start from the rarest term.func intersect(short, long []int) []int { var out []int pos := 0 for _, doc := range short { pos = advance(long, pos, doc) if pos == len(long) { break } if long[pos] == doc { out = append(out, doc) } } return out}
type InvertedIndex struct { postings map[string][]int // term -> sorted doc ids}
// Add appends the doc id to each term's list. Ids arrive in increasing order, so every list// stays sorted, which is what makes intersection and delta compression cheap.func (idx *InvertedIndex) Add(docID int, text string) { for _, term := range unique(analyze(text)) { idx.postings[term] = append(idx.postings[term], docID) }}
func (idx *InvertedIndex) SearchAll(query string) []int { var lists [][]int for _, term := range unique(analyze(query)) { lists = append(lists, idx.postings[term]) } if len(lists) == 0 { return nil } // Rarest first: the answer can't be longer than the shortest list. sort.SliceStable(lists, func(i, j int) bool { return len(lists[i]) < len(lists[j]) }) result := lists[0] for _, other := range lists[1:] { result = intersect(result, other) if len(result) == 0 { break } } return result}
func main() { docs := []string{ "Trail running shoes with a grippy sole", "Road running shoes, light and fast", "Waterproof hiking boots for rocky trails", "Running socks, pack of three", "Cleaning kit for running shoes and boots", } idx := &InvertedIndex{postings: map[string][]int{}} for i, text := range docs { idx.Add(i+1, text) }
for _, term := range []string{"run", "shoe", "trail"} { fmt.Printf("postings[%s] = %v\n", term, idx.postings[term]) } fmt.Printf("\"Running Shoes\" -> %v\n", idx.SearchAll("Running Shoes")) fmt.Printf("\"trail shoes\" -> %v\n", idx.SearchAll("trail shoes")) // Skip analysis on the query and nothing matches: the index holds "run", not "Running". fmt.Printf("raw \"Running\" -> %v\n", idx.postings["Running"])}// Words too common to narrow a search. Real lists are per language, and per field.const STOP_WORDS = new Set(['a', 'an', 'and', 'the', 'of', 'for', 'with', 'to', 'on', 'in']);
// A toy stemmer: strip one English suffix so "running", "runs" and "run" become one term.// Production analyzers use Porter or Snowball rules; the idea is the same.function stem(word: string): string { for (const suffix of ['ing', 'ed', 's']) { if (word.endsWith(suffix) && word.length - suffix.length >= 3) { word = word.slice(0, -suffix.length); break; } } // "runn" -> "run": a doubled final consonant is left over from the suffix. const n = word.length; if (n >= 2 && word[n - 1] === word[n - 2] && !'aeiouls'.includes(word[n - 1])) word = word.slice(0, -1); return word;}
// The same function runs at index time and at query time. If they ever differ, a document// indexed under "run" is never found by a query for "running".function analyze(text: string): string[] { const tokens = text.toLowerCase().match(/[a-z0-9]+/g) ?? []; return tokens.filter((t) => !STOP_WORDS.has(t)).map(stem);}
// First index in list[lo, hi) whose value is >= target.function lowerBound(list: number[], lo: number, hi: number, target: number): number { while (lo < hi) { const mid = (lo + hi) >>> 1; if (list[mid] < target) lo = mid + 1; else hi = mid; } return lo;}
// Jump forward 1, 2, 4, 8 ... entries until we pass target, then binary search the last// jump. This is what skip lists give a postings list on disk: skip blocks without reading them.function advance(list: number[], lo: number, target: number): number { let step = 1; let hi = lo; while (hi < list.length && list[hi] < target) { lo = hi + 1; hi = lo + step; step *= 2; } return lowerBound(list, lo, Math.min(hi + 1, list.length), target);}
// Walk the short list and probe the long one. The cost follows the short list (times a log),// not the total length, which is why engines start from the rarest term.function intersect(short: number[], long: number[]): number[] { const out: number[] = []; let pos = 0; for (const doc of short) { pos = advance(long, pos, doc); if (pos === long.length) break; if (long[pos] === doc) out.push(doc); } return out;}
class InvertedIndex { readonly postings = new Map<string, number[]>(); // term -> sorted doc ids
// Ids arrive in increasing order, so appending keeps every list sorted. Sorted lists are // what make intersection and delta compression cheap. add(docId: number, text: string): void { for (const term of new Set(analyze(text))) { if (!this.postings.has(term)) this.postings.set(term, []); this.postings.get(term)!.push(docId); } }
searchAll(query: string): number[] { const lists = [...new Set(analyze(query))].map((t) => this.postings.get(t) ?? []); if (lists.length === 0) return []; lists.sort((a, b) => a.length - b.length); // rarest first: the answer can't be longer than the shortest list let result = lists[0]; for (const other of lists.slice(1)) { result = intersect(result, other); if (result.length === 0) break; } return result; }}
const docs = [ 'Trail running shoes with a grippy sole', 'Road running shoes, light and fast', 'Waterproof hiking boots for rocky trails', 'Running socks, pack of three', 'Cleaning kit for running shoes and boots',];const idx = new InvertedIndex();docs.forEach((text, i) => idx.add(i + 1, text));
for (const term of ['run', 'shoe', 'trail']) console.log(`postings[${term}] = [${idx.postings.get(term)!.join(', ')}]`);console.log(`"Running Shoes" -> [${idx.searchAll('Running Shoes').join(', ')}]`);console.log(`"trail shoes" -> [${idx.searchAll('trail shoes').join(', ')}]`);// Skip analysis on the query and nothing matches: the index holds "run", not "Running".console.log(`raw "Running" -> [${(idx.postings.get('Running') ?? []).join(', ')}]`);Step 3: Boolean queries intersect postings
Section titled “Step 3: Boolean queries intersect postings”Every query is set operations on sorted lists:
- AND is an intersection, OR a union, NOT a difference.
- Start from the rarest term. An AND is no longer than its shortest list. Say
trailis in 200,000 documents,runin 2 million, andshoein 1.5 million. Walktrail, and calladvanceon the other two. That costs about 400,000 skip-assisted probes, instead of reading 3.7 million entries. - Phrases intersect first, then check positions in the survivors.
Filters (in_stock, category) narrow the set without changing the score. Frequent ones are
cached as bitsets. Sorting and facets read doc values: a column-oriented copy of each field,
keyed by doc id. Postings answer “which docs have this value”. Sorting needs “what value does this
doc have”.
Go deeperRanges, and top-k without scoring every match1 min
Numeric ranges (price < 100) use a BKD tree of points instead of unioning thousands of postings
lists, one per distinct price.
An OR of common terms matches millions of documents, and a user wants ten. So engines keep a heap of the best k so far. They know the highest score each term (and each block of each term) could possibly add. So they skip any document that cannot beat the current tenth place. This is WAND, and its block-max variant, in Lucene since version 8. It’s why Elasticsearch stops counting at 10,000 hits by default, and reports “10,000+”. An exact count would mean visiting every match.
Step 4: Ranking, from TF-IDF to BM25
Section titled “Step 4: Ranking, from TF-IDF to BM25”Matching decides which documents qualify. Ranking decides the order, from three intuitions:
- Term frequency. Mentioning
trailfive times is probably more about trails than once. - Inverse document frequency. A rare term is stronger evidence:
waterproofin 2% of listings says more thanshoein 30%. - Length. A 2,000-word guide contains most words by chance. One mention in a 6-word title means more.
TF-IDF multiplies the first two: tf × log(N / df), summed over query terms. The tf part is
linear, so repeating trail ten times scores ten times higher (keyword stuffing works), and it
ignores length.
BM25 (“best matching”, from the Okapi system of the 1990s) fixes both and has been Lucene’s default since 2016 (Lucene 6, Elasticsearch 5). For each query term in a document:
score(term, doc) = idf × tf × (k1 + 1) / (tf + k1 × (1 − b + b × docLength / avgDocLength))
idf = ln(1 + (N − df + 0.5) / (df + 0.5))Read it in three parts:
- idf is large for rare terms, and near zero for a term in nearly every document. Lucene’s
+ 1inside the log keeps it positive. The original went negative for very common terms. tf / (tf + k1 × ...)saturates. The first occurrence adds the most, the second less, the tenth almost nothing, and no amount of repetition pushes the factor pastk1 + 1. k1 sets how quickly it flattens: at 0 only presence counts, at 3 repetition keeps paying for a while. The default is 1.2.1 − b + b × docLength / avgDocLengthscales that by the document’s length, relative to the average. A document of average length gets 1. A document twice as long counts its occurrences as if they were rarer. b sets the strength: 0 ignores length, and 1 applies it fully. The default is 0.75.
A document’s score is the sum over its matching query terms. Scores only order one query’s results: 8 is not “twice as relevant” as 4, and scores from different queries do not compare.
The demo is a complete engine over eight product listings. Type a query and follow it through analysis, postings and BM25.
1 Analyze the query
2 Look up postings and intersect
3 Score with BM25
The eight documents and their indexed terms
Things to try:
- Search
trailand drag k1. The sticker pack that says “trail” four times leads by about 1.5 times, not the 4 times TF-IDF would give it. At k1 = 0, only presence counts. At 3, repetition pays again. - Search
trail running shoesand drag b from 0 to 1. At 0, the 16-term guide ranks first, because it repeatstrailandrun. With length normalization, it falls below the 5-term listing. - Search
Running Shoe, then turn query stemming off. The query asks forrunning, the index holdsrun, and the AND returns nothing, with no error. - Search
the. Every word is a stop word, and the query is empty.
The code compares TF-IDF and BM25 on five documents, one of them keyword stuffed:
import java.util.ArrayList;import java.util.Collections;import java.util.HashMap;import java.util.List;import java.util.Map;
public class Bm25Demo { // Documents already analyzed into terms. Doc 3 repeats "trail" to game the ranking. static final String[] TITLES = { "Trail running shoe", "Road running shoe", "Waterproof hiking boot", "Trail sticker pack (keyword stuffed)", "Complete guide to trail running", }; static final List<List<String>> DOCS = List.of( List.of("trail run shoe grippy sole".split(" ")), List.of("road run shoe light fast".split(" ")), List.of("waterproof hike boot rocky trail".split(" ")), List.of("trail sticker pack trail trail trail trail trail".split(" ")), List.of("complete guide trail run choose shoe sock pack hydrate pace long run rocky mountain trail".split(" ")));
// In a real engine df is just the length of the term's postings list. static Map<String, Integer> docFreq(List<List<String>> docs, List<String> terms) { Map<String, Integer> df = new HashMap<>(); for (String t : terms) df.put(t, (int) docs.stream().filter(d -> d.contains(t)).count()); return df; }
static int count(List<String> doc, String term) { return Collections.frequency(doc, term); }
// Classic TF-IDF for comparison: raw count times log(N / df). Linear in tf and blind to // length, so repeating a word ten times is worth ten times as much. static double[] tfIdf(List<List<String>> docs, List<String> terms) { int n = docs.size(); Map<String, Integer> df = docFreq(docs, terms); double[] scores = new double[n]; for (int i = 0; i < n; i++) { for (String t : terms) { if (df.get(t) > 0) scores[i] += count(docs.get(i), t) * Math.log((double) n / df.get(t)); } } return scores; }
static double[] bm25(List<List<String>> docs, List<String> terms, double k1, double b) { int n = docs.size(); Map<String, Integer> df = docFreq(docs, terms); double avgdl = docs.stream().mapToInt(List::size).average().orElse(0); double[] scores = new double[n]; for (int i = 0; i < n; i++) { List<String> d = docs.get(i); for (String t : terms) { int tf = count(d, t); if (tf == 0) continue; // Rare terms are stronger evidence. Lucene adds 1 inside the log so idf stays // positive even for a term in more than half the documents. double idf = Math.log(1 + (n - df.get(t) + 0.5) / (df.get(t) + 0.5)); // Long documents contain any given word by chance more often, so tf is judged // against the document's length relative to the average. b = 0 ignores length. double norm = k1 * (1 - b + b * d.size() / avgdl); // tf / (tf + norm) saturates: the first occurrence counts most and later ones less // and less, never more than (k1 + 1). k1 sets how fast it flattens. scores[i] += idf * tf * (k1 + 1) / (tf + norm); } } return scores; }
static void show(String name, double[] scores) { System.out.println(name); // Top-k: an engine keeps a small heap of the best k; sorting is fine for five documents. List<Integer> order = new ArrayList<>(); for (int i = 0; i < scores.length; i++) order.add(i); order.sort((a, c) -> Double.compare(scores[c], scores[a])); for (int i : order) { if (scores[i] > 0) System.out.printf(" %5.2f %s%n", scores[i], TITLES[i]); } }
public static void main(String[] args) { List<String> query = List.of("trail", "shoe"); show("TF-IDF", tfIdf(DOCS, query)); show("BM25 (k1=1.2, b=0.75)", bm25(DOCS, query, 1.2, 0.75)); }}import math
# Documents already analyzed into terms. Doc 3 repeats "trail" to game the ranking.TITLES = [ "Trail running shoe", "Road running shoe", "Waterproof hiking boot", "Trail sticker pack (keyword stuffed)", "Complete guide to trail running",]DOCS = [ "trail run shoe grippy sole".split(), "road run shoe light fast".split(), "waterproof hike boot rocky trail".split(), "trail sticker pack trail trail trail trail trail".split(), "complete guide trail run choose shoe sock pack hydrate pace long run rocky mountain trail".split(),]
def doc_freq(docs: list[list[str]], terms: list[str]) -> dict[str, int]: # In a real engine df is just the length of the term's postings list. return {t: sum(1 for d in docs if t in d) for t in terms}
def tf_idf(docs: list[list[str]], terms: list[str]) -> list[float]: # Classic TF-IDF for comparison: raw count times log(N / df). Linear in tf and blind to # length, so repeating a word ten times is worth ten times as much. n, df = len(docs), doc_freq(docs, terms) return [sum(d.count(t) * math.log(n / df[t]) for t in terms if df[t]) for d in docs]
def bm25(docs: list[list[str]], terms: list[str], k1: float = 1.2, b: float = 0.75) -> list[float]: n, df = len(docs), doc_freq(docs, terms) avgdl = sum(len(d) for d in docs) / n scores = [] for d in docs: score = 0.0 for t in terms: tf = d.count(t) if tf == 0: continue # Rare terms are stronger evidence. Lucene adds 1 inside the log so idf stays # positive even for a term in more than half the documents. idf = math.log(1 + (n - df[t] + 0.5) / (df[t] + 0.5)) # Long documents contain any given word by chance more often, so tf is judged # against the document's length relative to the average. b = 0 ignores length. norm = k1 * (1 - b + b * len(d) / avgdl) # tf / (tf + norm) saturates: the first occurrence counts most and later ones less # and less, never more than (k1 + 1). k1 sets how fast it flattens. score += idf * tf * (k1 + 1) / (tf + norm) scores.append(score) return scores
def show(name: str, scores: list[float]) -> None: print(name) # Top-k: an engine keeps a small heap of the best k; sorting is fine for five documents. for i in sorted(range(len(scores)), key=lambda i: -scores[i]): if scores[i] > 0: print(f" {scores[i]:5.2f} {TITLES[i]}")
def main() -> None: query = ["trail", "shoe"] show("TF-IDF", tf_idf(DOCS, query)) show("BM25 (k1=1.2, b=0.75)", bm25(DOCS, query))
if __name__ == "__main__": main()using System;using System.Collections.Generic;using System.Linq;
public static class Bm25Demo{ // Documents already analyzed into terms. Doc 3 repeats "trail" to game the ranking. private static readonly string[] Titles = { "Trail running shoe", "Road running shoe", "Waterproof hiking boot", "Trail sticker pack (keyword stuffed)", "Complete guide to trail running", };
private static readonly List<string[]> Docs = new[] { "trail run shoe grippy sole", "road run shoe light fast", "waterproof hike boot rocky trail", "trail sticker pack trail trail trail trail trail", "complete guide trail run choose shoe sock pack hydrate pace long run rocky mountain trail", }.Select(d => d.Split(' ')).ToList();
private static int Count(string[] doc, string term) => doc.Count(t => t == term);
// In a real engine df is just the length of the term's postings list. private static Dictionary<string, int> DocFreq(List<string[]> docs, string[] terms) => terms.ToDictionary(t => t, t => docs.Count(d => d.Contains(t)));
// Classic TF-IDF for comparison: raw count times log(N / df). Linear in tf and blind to // length, so repeating a word ten times is worth ten times as much. private static double[] TfIdf(List<string[]> docs, string[] terms) { var n = docs.Count; var df = DocFreq(docs, terms); return docs.Select(d => terms.Where(t => df[t] > 0).Sum(t => Count(d, t) * Math.Log((double)n / df[t]))).ToArray(); }
private static double[] Bm25(List<string[]> docs, string[] terms, double k1 = 1.2, double b = 0.75) { var n = docs.Count; var df = DocFreq(docs, terms); var avgdl = docs.Average(d => d.Length); var scores = new double[n]; for (var i = 0; i < n; i++) { var d = docs[i]; foreach (var t in terms) { var tf = Count(d, t); if (tf == 0) continue; // Rare terms are stronger evidence. Lucene adds 1 inside the log so idf stays // positive even for a term in more than half the documents. var idf = Math.Log(1 + (n - df[t] + 0.5) / (df[t] + 0.5)); // Long documents contain any given word by chance more often, so tf is judged // against the document's length relative to the average. b = 0 ignores length. var norm = k1 * (1 - b + b * d.Length / avgdl); // tf / (tf + norm) saturates: the first occurrence counts most and later ones less // and less, never more than (k1 + 1). k1 sets how fast it flattens. scores[i] += idf * tf * (k1 + 1) / (tf + norm); } } return scores; }
private static void Show(string name, double[] scores) { Console.WriteLine(name); // Top-k: an engine keeps a small heap of the best k; sorting is fine for five documents. foreach (var i in Enumerable.Range(0, scores.Length).OrderByDescending(j => scores[j])) { if (scores[i] > 0) Console.WriteLine($" {scores[i],5:F2} {Titles[i]}"); } }
public static void Main() { string[] query = { "trail", "shoe" }; Show("TF-IDF", TfIdf(Docs, query)); Show("BM25 (k1=1.2, b=0.75)", Bm25(Docs, query)); }}package main
import ( "fmt" "math" "sort" "strings")
// Documents already analyzed into terms. Doc 3 repeats "trail" to game the ranking.var titles = []string{ "Trail running shoe", "Road running shoe", "Waterproof hiking boot", "Trail sticker pack (keyword stuffed)", "Complete guide to trail running",}
var docs = [][]string{ strings.Fields("trail run shoe grippy sole"), strings.Fields("road run shoe light fast"), strings.Fields("waterproof hike boot rocky trail"), strings.Fields("trail sticker pack trail trail trail trail trail"), strings.Fields("complete guide trail run choose shoe sock pack hydrate pace long run rocky mountain trail"),}
func count(doc []string, term string) int { n := 0 for _, t := range doc { if t == term { n++ } } return n}
// docFreq counts documents containing each term. In a real engine df is just the length of// the term's postings list.func docFreq(docs [][]string, terms []string) map[string]int { df := map[string]int{} for _, t := range terms { for _, d := range docs { if count(d, t) > 0 { df[t]++ } } } return df}
// tfIdf is classic TF-IDF for comparison: raw count times log(N / df). Linear in tf and blind// to length, so repeating a word ten times is worth ten times as much.func tfIdf(docs [][]string, terms []string) []float64 { n, df := float64(len(docs)), docFreq(docs, terms) scores := make([]float64, len(docs)) for i, d := range docs { for _, t := range terms { if df[t] > 0 { scores[i] += float64(count(d, t)) * math.Log(n/float64(df[t])) } } } return scores}
func bm25(docs [][]string, terms []string, k1, b float64) []float64 { n, df := float64(len(docs)), docFreq(docs, terms) total := 0 for _, d := range docs { total += len(d) } avgdl := float64(total) / n scores := make([]float64, len(docs)) for i, d := range docs { for _, t := range terms { tf := float64(count(d, t)) if tf == 0 { continue } // Rare terms are stronger evidence. Lucene adds 1 inside the log so idf stays // positive even for a term in more than half the documents. idf := math.Log(1 + (n-float64(df[t])+0.5)/(float64(df[t])+0.5)) // Long documents contain any given word by chance more often, so tf is judged // against the document's length relative to the average. b = 0 ignores length. norm := k1 * (1 - b + b*float64(len(d))/avgdl) // tf / (tf + norm) saturates: the first occurrence counts most and later ones less // and less, never more than (k1 + 1). k1 sets how fast it flattens. scores[i] += idf * tf * (k1 + 1) / (tf + norm) } } return scores}
func show(name string, scores []float64) { fmt.Println(name) // Top-k: an engine keeps a small heap of the best k; sorting is fine for five documents. order := make([]int, len(scores)) for i := range order { order[i] = i } sort.SliceStable(order, func(a, c int) bool { return scores[order[a]] > scores[order[c]] }) for _, i := range order { if scores[i] > 0 { fmt.Printf(" %5.2f %s\n", scores[i], titles[i]) } }}
func main() { query := []string{"trail", "shoe"} show("TF-IDF", tfIdf(docs, query)) show("BM25 (k1=1.2, b=0.75)", bm25(docs, query, 1.2, 0.75))}// Documents already analyzed into terms. Doc 3 repeats "trail" to game the ranking.const TITLES = [ 'Trail running shoe', 'Road running shoe', 'Waterproof hiking boot', 'Trail sticker pack (keyword stuffed)', 'Complete guide to trail running',];const DOCS = [ 'trail run shoe grippy sole', 'road run shoe light fast', 'waterproof hike boot rocky trail', 'trail sticker pack trail trail trail trail trail', 'complete guide trail run choose shoe sock pack hydrate pace long run rocky mountain trail',].map((d) => d.split(' '));
const count = (doc: string[], term: string): number => doc.filter((t) => t === term).length;
// In a real engine df is just the length of the term's postings list.function docFreq(docs: string[][], terms: string[]): Map<string, number> { return new Map(terms.map((t) => [t, docs.filter((d) => d.includes(t)).length]));}
// Classic TF-IDF for comparison: raw count times log(N / df). Linear in tf and blind to// length, so repeating a word ten times is worth ten times as much.function tfIdf(docs: string[][], terms: string[]): number[] { const n = docs.length; const df = docFreq(docs, terms); return docs.map((d) => terms.reduce((s, t) => (df.get(t)! > 0 ? s + count(d, t) * Math.log(n / df.get(t)!) : s), 0), );}
function bm25(docs: string[][], terms: string[], k1 = 1.2, b = 0.75): number[] { const n = docs.length; const df = docFreq(docs, terms); const avgdl = docs.reduce((s, d) => s + d.length, 0) / n; return docs.map((d) => { let score = 0; for (const t of terms) { const tf = count(d, t); if (tf === 0) continue; // Rare terms are stronger evidence. Lucene adds 1 inside the log so idf stays // positive even for a term in more than half the documents. const idf = Math.log(1 + (n - df.get(t)! + 0.5) / (df.get(t)! + 0.5)); // Long documents contain any given word by chance more often, so tf is judged // against the document's length relative to the average. b = 0 ignores length. const norm = k1 * (1 - b + (b * d.length) / avgdl); // tf / (tf + norm) saturates: the first occurrence counts most and later ones less // and less, never more than (k1 + 1). k1 sets how fast it flattens. score += (idf * tf * (k1 + 1)) / (tf + norm); } return score; });}
function show(name: string, scores: number[]): void { console.log(name); // Top-k: an engine keeps a small heap of the best k; sorting is fine for five documents. const order = scores.map((_, i) => i).sort((a, c) => scores[c] - scores[a]); for (const i of order) { if (scores[i] > 0) console.log(` ${scores[i].toFixed(2).padStart(5)} ${TITLES[i]}`); }}
const query = ['trail', 'shoe'];show('TF-IDF', tfIdf(DOCS, query));show('BM25 (k1=1.2, b=0.75)', bm25(DOCS, query));TF-IDF ranks the stuffed listing first, with 1.34. BM25 drops it to fourth, with 0.52. It puts the short listing that matches both terms on top.
Go deeperLucene's BM25 differs from the textbook1 min
- Since Lucene 8, the
(k1 + 1)factor in the numerator is dropped. It multiplies every score by the same constant, so the ranking is unchanged. But dropping it keeps the per-term maximum at idf, which the top-k skipping above relies on. - Document length is stored in one byte per field, a lossy encoding. So two long documents of slightly different length can get the same stored length. Small, unexpected ties in a test corpus sometimes come from this.
- Scoring is per field. A query across
titleanddescriptionscores each field with its own statistics, then combines them. It can take the best field, the sum, or (withcross_fields) treat the fields as one. Matching in a shorttitlenaturally outweighs matching in a longdescription.
Step 5: Segments, refresh and merges
Section titled “Step 5: Segments, refresh and merges”A compressed, delta-encoded postings list cannot take an insert in the middle. Lucene does what an LSM tree does: it never updates a file. The index is a set of immutable segments. Each one is a complete small inverted index. A search queries every segment, and merges the results.
- Color key
- Database
- Service
- Durable now
- Searchable now
- Write. A document goes into an in-memory buffer and the translog, a write-ahead log fsynced before the acknowledgement, so the write survives a crash. It is not searchable yet.
- Refresh. Every second by default, the buffer becomes a new segment, opened for search. It sits in the OS page cache, not fsynced, which keeps a refresh cheap. This is near-real-time search: a write becomes visible about one refresh interval later. Bulk loads raise the interval to 30 s or turn it off for much higher throughput.
- Flush. Less often, segments are fsynced in a Lucene commit and the translog is trimmed.
- Merge. A refresh a second under steady writes makes 86,400 tiny segments a day per shard, and every query pays per segment. A background policy merges similar-sized segments, up to about 5 GB each.
Deletes are tombstones. Deleting a document sets a bit in its segment’s live-documents bitmap. The document stays in the postings, skipped at query time and still counted in df, until a merge rewrites the segment. An update is a delete plus an add: change a price and the whole document is reindexed. That is why fields that change every second, such as stock counts, belong in a cache beside the index.
Read-your-writes. A seller who saves a listing and searches at once may not find it. Here are the fixes, in order of preference:
- Show the seller’s own edit from the database.
- Index with
refresh=wait_for, which returns after the next refresh. - Force a refresh. This makes a segment each time, so it’s never for a busy index.
Step 6: Shards, scatter-gather and replicas
Section titled “Step 6: Shards, scatter-gather and replicas”One machine runs out of disk, memory or CPU, so the index is split into partitions called shards, in one of two ways.
By document (local index): each shard is a complete small search engine over a subset of documents, routed by hashing the id. Every query goes to every shard. Elasticsearch, OpenSearch and Solr work this way.
By term (global index): each shard holds the full postings lists for a subset of terms. A query touches only the shards that own its terms. But an AND ships lists across the network to intersect them. And one 300-term document writes to nearly every shard.
| Partitioning | A query touches | A write touches | Hot spots | Wins when |
|---|---|---|---|---|
| By document | Every shard, in parallel | One shard | Rare; hashing spreads documents evenly | Almost always: rich queries, high write rates, simple rebalancing |
| By term | Only the shards of its terms | Many shards (one per term) | Shards owning new, sale, trending tags |
Single-term lookups at huge scale: hashtag pages, a label in a metrics store |
Document partitioning turns every query into a scatter-gather in two phases:
- Color key
- Database
- Service
- Query phase. Each shard returns ids and scores for its own top
from + size, and the coordinator merges them into a global top k. - Fetch phase. Only the winners’ shards send documents: 10 cross the network, not 30.
- Latency is the slowest shard. With 50 shards, a 1-in-100 slow response on any one shard hits about 40% of queries. Fewer shards, adaptive replica selection (route to the fastest copy, the default since Elasticsearch 7) and timeouts that return partial results keep the tail down.
Replicas are full copies of a shard on other nodes. They give availability and read throughput (three copies serve about three times the queries), not write throughput, since every copy indexes every write.
The primary shard count is fixed at creation, because routing is hash(id) mod primaries.
Changing it means a split or shrink (to a multiple or factor) or a reindex. Replicas can change at
any time.
Go deeperWhy scores differ slightly across shards1 min
BM25’s idf needs N and df. Each shard computes them from its own documents only. That’s cheap, and close enough when documents are routed randomly and each shard holds millions of them.
But take a small index, or routing by a key that puts similar documents together. Then a term can be
rare on one shard and common on another. So equal documents get different scores, depending on
where they live. The dfs_query_then_fetch search type adds a round trip that collects global term
statistics first. It’s for tests and tiny indexes. At scale, the per-shard estimate is fine.
Step 7: Keeping the index in sync with the database
Section titled “Step 7: Keeping the index in sync with the database”The search index is a derived view of the database, which stays the source of truth. The index must follow it, seconds behind, without silently drifting.
- Color key
- Service
- Database
- Queue or stream
- Background worker
- Dual writes (the service writes the database, then the index) break in two ways. An index write that fails after the commit leaves the index wrong. And two racing updates to one product can land in the opposite order, keeping the older one forever.
- Change data capture reads committed changes from the database’s log into Kafka, keyed by product id. (Debezium does this on the Postgres WAL or MySQL binlog.) An indexer applies them. Nothing that committed is lost, and updates to one product stay in order.
- Transactional outbox: the service writes an event row in the same transaction, and a relay publishes it. Use it when the search document needs fields the row change does not carry.
Two habits make any pipeline safe:
- Version every index write, with a number that only grows, such as the row’s log sequence number. Use external versioning, so the engine rejects anything older than what it holds. Then replays and reordering become harmless.
- Reconcile on a schedule. Compare counts and per-range checksums with the database, and reindex what differs.
Reindexing with aliases. Analyzer, field and shard count changes need a full rebuild. So clients query an alias, never an index name. The steps:
- Record the current Kafka offset.
- Create
products_v8, with replicas at 0 and refresh off while loading. - Backfill it from a database snapshot.
- Replay changes from the recorded offset until it catches up.
- Compare counts and known queries.
- Swap the alias in one atomic call.
Rollback is swapping it back.
Step 8: Relevance, typos and facets
Section titled “Step 8: Relevance, typos and facets”Relevance tuning starts after BM25. The usual layers, cheapest first:
- Field boosts. A
titlematch counts three times adescriptionmatch. - Proximity. Boost documents where the query words appear close together.
- Business signals. Popularity, rating and recency, so a best-seller outranks an equally relevant listing nobody buys.
- Learning to rank. Rerank the top 100 to 1,000 BM25 results with a model trained on clicks and purchases. Then the expensive model sees few candidates.
Measure every change offline against a judgment list: a few hundred real queries with graded results (NDCG@10). Then A/B test click-through, conversion, and the zero-results rate. That last one is the cheapest signal of broken analysis or missing synonyms. Typo tolerance (fuzzy queries, at most 2 edits) and facet counts (“Brand (124)”, read from doc values) layer on top.
Go deeperTypo tolerance and facet counts1 min
Typo tolerance. A fuzzy query accepts terms within a Levenshtein edit distance (at most 2 in
Lucene). Lucene builds an automaton that accepts every string within that distance. It walks it
against the FST, visiting only nearby terms. fuzziness: AUTO allows 0 edits for 1 or 2
characters, 1 for 3 to 5, and 2 beyond. Requiring the first character or two to match cuts the cost
a lot. And fuzzy matches should score below exact ones. Otherwise shoe ranks show alongside it.
Facets. The “Brand (124) · Size (38) · Under $100 (512)” sidebar is an aggregation over the matched documents, read from doc values. Across shards, a top-terms facet is approximate. Each shard sends only its own top buckets. So a brand just outside one shard’s list is undercounted. Engines report an error bound. Raising the per-shard bucket count trades memory for accuracy.
Go deeperAutocomplete and vector search, briefly2 min
Autocomplete is a different query shape: a prefix, under 50 ms, on every keystroke. There are three ways to serve it from a search engine:
- Edge n-grams at index time. Flexible: it matches any word in the title. But it makes several times more terms.
- A completion suggester that holds an in-memory FST of whole suggestions, with weights. Fastest, but it matches only the prefix of the suggestion.
- A separate service with precomputed top suggestions per prefix. The search autocomplete problem designs this end to end.
Vector search matches by meaning, instead of by term. “Shoes for muddy hills” can find “trail runner with deep lugs”, with no word in common. Documents and queries become embeddings. An approximate nearest neighbor index (usually HNSW) finds close vectors. Elasticsearch, OpenSearch and Postgres with pgvector all support it.
It is weak where BM25 is strong: part numbers, exact names, rare words. So production systems run
both, and merge the two ranked lists. They often use reciprocal rank fusion: each document scores
the sum of 1 / (60 + rank) over the lists it appears in.
Options and tradeoffs
Section titled “Options and tradeoffs”Where search lives
Section titled “Where search lives”| Option | Strengths | Limits | Wins when |
|---|---|---|---|
PostgreSQL full text (tsvector, GIN index) |
No second system, transactional, joins and permissions in the same query | ts_rank uses no corpus statistics (no idf) and reads every matching row to rank; no fuzzy or facets built in |
Up to a few million documents, simple ranking, or search inside a tenant’s own data |
Postgres with pg_trgm |
Indexes LIKE '%x%' and similarity with trigrams; typo tolerant |
No linguistic analysis or ranking; trigram indexes are large | Searching names, codes and short strings |
| Elasticsearch or OpenSearch | BM25, analyzers per language, facets, fuzzy, geo, vectors, scales to billions | A second system to run and sync; eventually consistent; heap and shard tuning | Search is a product feature with real traffic and ranking needs |
| Hosted engines (Algolia, Typesense, Meilisearch) | Typo tolerance and instant search out of the box, little to operate | Pricing per record or request; less control over ranking and analysis | A small team needs a good search box this month |
In Postgres, a GIN index makes matching fast. Ranking is the cost. Sorting by ts_rank scores every
matching row. So a query matching 500,000 rows reads all of them, to return 10.
How the index learns about changes
Section titled “How the index learns about changes”| Option | Lost updates | Ordering | Lag | Wins when |
|---|---|---|---|---|
| Dual writes | Yes, on a failure between the two writes | Can invert under concurrency | Lowest | Prototypes, or a nightly reconcile is acceptable |
| CDC from the log | No | Per key, from the log | 1 to 5 s | The default for a database-backed index |
| Transactional outbox | No | Per key, by the relay | 1 to 5 s | The index document is built from an event the service designs |
| Periodic rebuild | Until the next run | Not an issue | Hours | Small, slow-changing catalogs; also the backstop for the others |
How to partition
Section titled “How to partition”By document unless the workload is overwhelmingly single-term lookups (Step 6). The second decision is how many shards.
| Shard count | Query cost | Rebalancing | Wins when |
|---|---|---|---|
| Few, 10 to 50 GB each | Few fan-out calls, better tail latency | Slower, each move copies tens of GB | Most indexes |
| Many small, under 5 GB | Overhead per shard per query; heap per shard | Fast and fine-grained | Rarely; oversharding is the common mistake |
| Time-based indexes (daily) | Queries hit only the days in range | Old days dropped by deleting an index | Logs and events, where data ages out |
How it fails
Section titled “How it fails”| Failure | What happens | Detect | Blast radius | Fix |
|---|---|---|---|---|
| Mapping explosion | Dynamic mapping turns every new JSON key into a field; cluster state grows and every node slows | Field count per index; the default 1,000-field limit rejects writes | The whole cluster’s metadata | Strict mappings; key-value data in a flattened field or nested key/value pairs |
| Deep pagination | from=9990 makes every shard return 10,000 hits to the coordinator; past that it is rejected by default |
Slow queries with large from; heap spikes on coordinators |
Coordinators, then the cluster | search_after with a point in time; cap the pages users can reach |
| Hot shard | One shard holds a huge tenant or a routing key that everyone queries | Per-shard query and indexing rate, one node’s CPU | Every query (it waits for the slowest) | Avoid custom routing on skewed keys; split big tenants into their own index |
| Split brain (before ES 7) | Two halves of a partitioned cluster each elect a master and accept writes | Two masters in the logs; diverging document counts | Data lost when the halves rejoin | Set minimum_master_nodes to a quorum, or run 7.0+, which manages quorum itself |
| Stale index | The CDC connector stalls or an indexer drops errors; search shows deleted or old products | Consumer lag; a canary write timed until it is searchable | Every query, silently | Alert on lag and canary freshness; reconcile counts; rebuild behind an alias |
| Analyzer mismatch | A changed analyzer on new documents, or a query analyzer that differs from the index | Zero-results rate jumps after a deploy | Recall for affected queries | Analyzer changes only through a reindex; test queries in CI |
| Merge or heap pressure | Heavy indexing plus big aggregations; long GC pauses, nodes drop out | GC time, segment count, rejected thread pool tasks | The node, then shard relocation storms | Raise refresh interval, limit aggregation size, keep heap under half of RAM |
Deep pagination gets more expensive with every page. Page 1,000 asks each of 20 shards for its
top 10,000. The coordinator sorts 200,000 entries, to return 10. search_after passes the last
hit’s sort values, so each shard seeks past them, and every page costs the same. A point in time
keeps the view stable while the index changes.
A stale index is the failure nobody pages for, because search keeps answering. Write a canary document every minute through the real pipeline and alert when it takes over a minute to become searchable.
Go deeperSplit brain in Elasticsearch before version 71 min
Before version 7, the setting that required a majority of master-eligible nodes to elect a master
(minimum_master_nodes) defaulted to 1. A network partition could produce two masters, both
accepting writes, and one side’s writes vanished when the cluster healed. The fix was to set it to
a quorum by hand, and many clusters forgot to update it when they added nodes. Version 7 replaced
the old discovery with a coordination layer that tracks the voting configuration itself. Run three
dedicated master-eligible nodes in three zones either way.
At scale
Section titled “At scale”| Quantity | Typical value |
|---|---|
| Refresh interval | 1 s default; 30 s or off for bulk loads |
| Max merged segment | About 5 GB by default |
| Shard size | 10 to 50 GB is the common guidance |
| JVM heap | At most half the RAM, and under about 31 GB to keep compressed pointers; the rest is page cache for segments |
| Result window | 10,000 hits by default (from + size) |
| Index size | Often 0.5 to 1.5 times the raw JSON, depending on positions, doc values and the stored source |
| Indexing throughput | Thousands to tens of thousands of small documents a second per node, measured, not assumed |
| Query latency | Single-digit to tens of ms for a selective query on a warm index; aggregations over millions of hits take longer |
Sizing the store from the opening (rough). 50 million listings at 1 KB is 50 GB of source. With positions, doc values and the stored source, plan for roughly 50 to 75 GB. That’s two or three primaries of about 25 GB. Measure one shard copy’s throughput on real queries. Say a copy handles about 400 a second. Then 2,000 a second needs five copies of each shard, and six or seven leave headroom for a lost node. Keep the segments in page cache. A search engine that reads disk on every query is a slow one.
At 10x (500 million documents, 20,000 queries a second), shards and replicas scale out, and the problems move. Reindexing takes hours, so the alias swap and replay must be routine. And fan-out across 20 or more shards makes the slowest shard the latency.
At 100x, one cluster is the wrong unit. Split by tenant, region or product line. Tier the data. A small, heavily replicated index of popular documents answers most queries. The full index is queried only when that returns too little.
What to monitor
Section titled “What to monitor”- Freshness: canary write-to-searchable time, CDC consumer lag, indexer errors.
- Latency and load: p99 per query type, query and indexing rate per shard, thread pool rejections.
- Quality: zero-results rate, click-through on the first page.
- Health: heap and GC time, segment count, disk watermarks. (Elasticsearch stops allocating shards to a node at 85% disk, and blocks writes to its indexes at 95%.)
In the interview
Section titled “In the interview”Interviewer asksWe store products in Postgres. Why not search them with LIKE?
A leading wildcard cannot use a B-tree, so LIKE '%shoes%' scans every row: seconds per query
on 50 GB, against 2,000 a second. It also matches substrings, misses word forms and cannot rank.
For a small catalog, Postgres full text with a GIN index is enough and stays transactional. At 50
million products with ranking, facets and typo tolerance, I would add Elasticsearch fed by CDC and
keep Postgres as the source of truth.
Interviewer asksHow do you keep the search index in sync with the database?
Not dual writes, which lose and reorder updates. Use CDC from the Postgres WAL into Kafka, keyed by product id. An indexer writes with external versioning on the row’s LSN, so an older change is rejected. A canary measures write-to-searchable time, and a nightly reconcile compares counts. Mapping changes go to a new index, built from a snapshot plus replay, then an atomic alias swap. A seller who must see their own edit reads it from the database.
Interviewer asksWalk me through a query across 20 shards. What limits it?
The coordinator sends it to one copy of each shard. Each shard intersects postings, scores with
BM25, and returns its top from + size ids and scores. The coordinator merges them, and fetches
only the global top 10 documents. Latency is the slowest of 20 responses, so the tail grows with
shard count. Replicas add throughput, and let routing avoid a slow copy. Deep pages cost every
shard from + size hits, so pagination uses search_after with a point in time.
Seen in
Section titled “Seen in”- Design a hotel reservation system: a CDC-fed hotel index holds the static facts. Nightly availability lives in Redis beside it, because reindexing on every booking would churn the index.
- Design Ticketmaster: event search on an Elasticsearch index fed from Postgres by CDC, so browsing (about 90% of requests) stays off the booking path.
- Design search autocomplete: prefix suggestions served from a precomputed trie, with Lucene’s FST-based completion suggester as the alternative.
- Design a proximity service: a text query such as
“vegan ramen near me” goes to a search engine with a
geo_pointfilter. It’s fed by the same change stream as the location index. - Design Instagram: an indexer feeds post events into a search engine sharded by term, seconds behind, to serve hashtag pages. That’s the single-term lookup where term partitioning fits.
- Design a metrics and monitoring system: the same inverted index maps each label value to a sorted list of series ids, intersected per query.
- Design a web crawler: the front of a web search pipeline. What it fetches is what gets analyzed and indexed.
Why. LIKE '%x%' scans every row and cannot rank. An inverted index moves the work to write
time: each term points to the sorted list of documents containing it.
Analysis. Index-time and query-time analysis must produce the same term forms. Synonyms at query time and edge n-grams at index time are the safe asymmetries. Changing an analyzer means reindexing.
Structure. A term dictionary (an in-memory FST over on-disk blocks), and postings lists of doc ids, frequencies and positions. They’re delta-encoded and bit-packed in blocks of 128, with skip data.
Queries. AND intersects from the rarest list. Filters are cached bitsets. Sorting and facets read doc values.
BM25. idf × saturating tf (k1 = 1.2) × length normalization (b = 0.75). Repetition stops paying, and long documents are discounted. Scores order one query’s results, and mean nothing else.
Segments. Immutable. A refresh every 1 s makes writes searchable (near real time). The translog makes them durable. Merges compact. Deletes are tombstones, and updates are delete plus add.
Distribution. Shard by document. Query, then fetch. Latency is the slowest shard. Replicas add read throughput. Primaries are fixed at creation.
Sync and operations. CDC with versioned writes, reconcile, rebuild behind an alias. Watch a freshness canary, the zero-results rate, deep pagination, hot shards and mapping growth.
Choice. Postgres full text for a small table. Elasticsearch or OpenSearch, fed by CDC, once search is a product.
Further reading
Section titled “Further reading”- On this site: indexes (B-trees and the LSM idea), partitioning (local and global secondary indexes), replication and message queues (the outbox relay and CDC).
- Christopher Manning, Prabhakar Raghavan and Hinrich Schütze, Introduction to Information Retrieval, chapters 1, 2, 5 and 6. They cover inverted indexes, postings intersection with skip pointers, compression and scoring.
- Michael McCandless, Using Finite State Transducers in Lucene: why Lucene’s term dictionary is an FST, from one of its authors.
- Elastic, Near real-time search: segments, refresh and the translog as Elasticsearch implements them.
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.