Full-Text Search and the Inverted Index

How full-text search works: inverted indexes, tokenization, relevance ranking with BM25, and keeping a search index in sync with a database.

Intermediate 7 min read · Updated

Full-text search finds documents containing words, ranks them by relevance, and does it in milliseconds over millions of documents. A database LIKE '%shoe%' query can't do this: it scans every row and has no notion of relevance. Search engines like Elasticsearch, OpenSearch and Solr (built on Lucene) are built around one data structure, the inverted index.

The LIKE problem

Product descriptions, blog posts and reviews all need text search. SQL offers LIKE:

SELECT * FROM products WHERE description LIKE '%wireless%headphones%';

The database checks every row's description against the pattern: a full table scan. On a 10-million-row products table that takes seconds. The leading wildcard is what kills it: a B-tree index helps a prefix match like LIKE 'apple%' (sorted keys can be range-scanned), but LIKE '%apple%' could match anywhere, so every row must be read. E-commerce sites need sub-second search, and LIKE can't deliver it. Full-text search engines solve this.

direct lookupLIKE '%cat%'Full table scancheck all 10M rowssearch "cat"Inverted indexcat → [1, 4]Docs 1 and 4
LIKE '%term%' reads every row; an inverted index jumps straight from the term to the matching document IDs.

The inverted index

A book's table of contents maps pages to topics; its index at the back maps topics to pages. An inverted index does the second: it maps each term to the documents that contain it. Take four documents:

Document IDDocument
1The cat is black
2The dog is brown
3The mouse is grey
4The cat and the dog are friends

Tokenize each document into terms and record which documents each term appears in:

TermDocument IDs
and4
are4
black1
brown2
cat1, 4
dog2, 4
friends4
grey3
is1, 2, 3
mouse3
the1, 2, 3, 4

Documents containing "cat"? Look up cat and return 1 and 4, with no scan and a lookup cost that doesn't grow with the number of documents. "cat AND dog" intersects two lists: document 4. The index can also store metadata: term frequencies for ranking, term positions for phrase queries, timestamps for recency sorting.

from collections import defaultdict

docs = {1: "The cat is black", 2: "The dog is brown",
        3: "The mouse is grey", 4: "The cat and the dog are friends"}

index = defaultdict(lambda: defaultdict(list))        # term -> doc_id -> positions
for doc_id, text in docs.items():
    for pos, term in enumerate(text.lower().split()):
        index[term][doc_id].append(pos)

def search(*terms):                                   # AND query: intersect postings
    sets = [set(index[t.lower()]) for t in terms]
    return sorted(set.intersection(*sets)) if sets else []

print(dict(index["cat"]))       # {1: [1], 4: [1]}
print(search("cat", "dog"))     # [4]

A normal (forward) index maps a document to its words; real engines store, for each term, a postings list of documents with positions and frequencies:

TermPostings (doc id: positions)
runningdoc1: [2], doc3: [1, 7]
shoedoc1: [3], doc2: [5], doc3: [2]
waterproofdoc2: [1]

A query for running shoe looks up two short lists and intersects them (doc1 and doc3), instead of reading every document. Positions make phrase queries ("running shoe") possible.

From text to terms: analysis

Before indexing, text goes through an analyzer, and the same analysis is applied to queries so they match:

  1. Tokenize: split "Trail-Running Shoes!" into Trail, Running, Shoes.
  2. Normalize: lowercase, strip accents.
  3. Remove stop words (optional): drop very common words like "the".
  4. Stem or lemmatize: running → run, shoes → shoe, so different forms match.
  5. Synonyms and n-grams (optional): sneaker ↔ shoe; edge n-grams (sh, sho, shoe) for search-as-you-type.

Ranking: why some results come first

Lucene-based engines score documents with BM25, which rewards:

  • Term frequency: the term appears often in this document, with diminishing returns.
  • Inverse document frequency: the term is rare across all documents ("waterproof" matters more than "shoe").
  • Field length normalisation: a match in a short title counts more than in a long description.

Real products then add business signals (popularity, recency, price, personalisation), field boosts (title over body), and often a machine-learned re-ranking step over the top results. Semantic search adds vector embeddings so "warm jacket" can match "insulated parka" without shared words, frequently combined with BM25 in hybrid search.

Architecture: keeping search in sync

The search index is almost never the source of truth. Data lives in a database and is copied into the search engine:

writessearch queriesAppDatabasesource of truthCDC / eventsIndexertransform, enrichSearch clustershards + replicas
Writes go to the database; changes flow through CDC or events into an indexer that updates the search cluster. Search results can lag writes by seconds.
  • Use change data capture or an outbox rather than dual writes from the app, so the index can't silently miss changes.
  • Support a full reindex from the source of truth (into a new index, then switch an alias) for mapping changes and recovery.
  • Search clusters scale by sharding the index (each query fans out to all shards and merges top results) and replicating shards for read throughput and availability.
  • Accept that search is eventually consistent: a just-created item may take a second to appear.

Database full-text search vs a search engine

NeedGood enoughUse a search engine
Simple keyword search on one table, modest volumePostgreSQL full-text (tsvector + GIN index)
Typo tolerance, synonyms, rich relevance tuningElasticsearch / OpenSearch / Solr
Facets and filters on large cataloguesSearch engine aggregations
Search-as-you-type across millions of itemsSearch engine with n-grams or a dedicated autocomplete service
Logs and observability searchElasticsearch / OpenSearch, or a log store
EngineWhere it runsDistinct feature
ElasticsearchAWS, GCP, Azure, open sourceProbably the most popular: real-time analytics, extensive query capabilities, horizontal scaling for large data volumes
Amazon CloudSearchAWSIntegrates with other AWS services, automatic scaling, language-specific text processing
Azure Cognitive Search (now Azure AI Search)AzureAI features such as semantic search, computer vision and natural-language processing
Vertex AI SearchGCPMachine-learned ranking with semantic and vector search, plus grounding for generative apps
AlgoliaSaaSThorough typo tolerance and customizable ranking rules
Apache SolrOpen sourceAdvanced faceted search, distributed search, extensible plugins
MeilisearchOpen sourceSmall memory footprint, instant search-as-you-type, simple API

Demo: product search with Elasticsearch

A realistic e-commerce search: an index with a mapping (text fields for matching, a keyword field for exact filters, a number for sorting), then a query with pagination, sorting and filtering. Run Elasticsearch locally (docker run -p 9200:9200 -e discovery.type=single-node -e xpack.security.enabled=false docker.elastic.co/elasticsearch/elasticsearch:8.15.0) and pip install elasticsearch.

from elasticsearch import Elasticsearch

es = Elasticsearch("http://localhost:9200")

# Mapping: "text" fields are analyzed for full-text search, "keyword" is matched exactly
es.indices.create(index="products", mappings={"properties": {
    "name": {"type": "text"},
    "description": {"type": "text"},
    "price": {"type": "double"},
    "category": {"type": "keyword"},
}})

products = [
    {"id": 1, "name": "Apple iPhone 13", "description": "Latest iPhone model with a 120Hz display.", "price": 999.0, "category": "smartphones"},
    {"id": 2, "name": "Samsung Galaxy S22", "description": "Flagship Samsung smartphone with a versatile camera system.", "price": 899.0, "category": "smartphones"},
    {"id": 3, "name": "Google Pixel 6", "description": "Google's newest smartphone with stock Android experience.", "price": 799.0, "category": "smartphones"},
    {"id": 4, "name": "Apple MacBook Pro", "description": "High-performance laptop with Apple's M1 Pro chip.", "price": 2499.0, "category": "laptops"},
]
for p in products:
    es.index(index="products", id=p["id"], document=p)
es.indices.refresh(index="products")          # make new documents searchable now

user_input = "smartphone"
response = es.search(
    index="products",
    from_=0, size=10,                                         # pagination
    query={"bool": {
        "must":   {"multi_match": {"query": user_input, "fields": ["name^2", "description"]}},
        "filter": {"term": {"category": "smartphones"}},     # exact filter, not scored
    }},
    sort=[{"price": {"order": "asc"}}],                       # cheapest first
)
for hit in response["hits"]["hits"]:
    print(hit["_source"]["name"], hit["_source"]["price"])
# Google Pixel 6 799.0
# Samsung Galaxy S22 899.0

The iPhone doesn't match because none of its text contains "smartphone"; an analyzer with stemming and synonyms (phone ↔ smartphone) would fix that, which is exactly the tuning search teams do. Elastic's official demo gallery has a fuller example.

Where full-text search is used

Almost every search box is backed by full-text search:

  • E-commerce (Amazon, eBay): keyword product search ranked by relevance, price and reviews; typo tolerance finds "wireles headphones" despite the misspelling.
  • Social media (Twitter/X, Facebook): search posts, profiles and hashtags ranked by recency, relevance and engagement, with real-time indexing so new content appears immediately.
  • Document management (Dropbox, Google Drive): search file names and the text inside PDFs and documents, with metadata filters for file type and modified date.
  • Forums and Q&A (Stack Overflow, Reddit): search keywords, phrases or tags, ranked by recency, relevance and votes, so answers to common questions surface first.

Elasticsearch and OpenSearch, end to end

Picture Amazon with 100 million products such as { "id": 101, "name": "Apple iPhone 17 Pro", "brand": "Apple", "description": "Latest Apple smartphone with titanium body", "price": 90000 }. A customer types iphone. A plain database would check every product for the word; at 100 million products that's far too slow. Elasticsearch builds an inverted index instead, like a textbook index ("Docker → pages 18, 42, 91"), so it jumps straight to matches.

TermProducts
apple1 (Apple iPhone 17), 3 (Apple AirPods)
iphone1
samsung2 (Samsung Galaxy)
galaxy2
airpods3
  • Searching: apple returns products 1 and 3 straight from the index; apple iphone intersects {1, 3} and {1} → product 1. Normal storage is product → words; an inverted index is word → products.
  • New data: when "Boat Bluetooth Speaker" (product 4) arrives, only its terms are added: boat → 4, bluetooth → 4, speaker → 4.
  • Understanding text: analyzers lowercase and stem (Running → running → run), so run, runs, running and runner can match each other.
  • Typo tolerance: iphne is close enough to iphone (fuzzy matching by edit distance) to still find the right products.
  • Relevance ranking: for Apple Charger, Apple iPhone and Apple Cover, scores weigh term frequency, where the terms appear (title vs description), field boosts and other signals, giving iPhone, then Charger, then Cover.
  • Autocomplete: typing iph suggests iPhone, iPhone Charger, iPhone Cover (completion suggesters or edge n-grams; see typeahead).
  • Filters: combine text with structured conditions (price < 100000, brand = Apple, color = Black) efficiently in one query.

Is it a database? It stores the documents as well as the indexes, so it's both a document store and a search engine, but not a good primary database. Buying a product must reduce inventory, create an order, charge payment and update loyalty points together, and transactional databases (MySQL, PostgreSQL, DynamoDB) are built for that; Elasticsearch is built for search. The usual architecture: the app writes to the source-of-truth database, and changes flow asynchronously via CDC, streams or events into Elasticsearch/OpenSearch, which customers search.

writesasyncqueriesApplicationMySQL / DynamoDBsource of truthCDC / streams / eventsOpenSearch / Elasticsearchsearchable copyCustomer search
Typical search architecture: the primary database is the source of truth; CDC or events keep the search index in sync for customer queries.

Elasticsearch vs OpenSearch: after Elastic changed Elasticsearch's license in 2021, AWS created OpenSearch, an open-source fork now maintained by AWS and the community. For the concepts that matter (documents, indexes, inverted indexes, full-text search, filters, aggregations, distributed shards and replicas) they're nearly identical. Think of either as Google for your own data: the primary database keeps the official records, and the search engine keeps a copy optimized for full-text search, typo correction, autocomplete, ranking, complex filters and very fast queries over millions or billions of documents.

Where it’s used

E-commerce search

Amazon, Flipkart and Shopify stores combine text relevance, filters, facets and popularity signals over product catalogues.

GitHub code search

Indexes billions of files with a custom engine built on inverted indexes over code tokens and n-grams.

Log analytics

The ELK stack (Elasticsearch, Logstash, Kibana) and OpenSearch index logs for fast free-text search during incidents.

Wikipedia and documentation sites

Use Elasticsearch or Lucene-based search with language-specific analyzers.

In an interview

  • Don't do search with LIKE on the primary database; add a search index fed from it.
  • Explain the inverted index and analysis (tokenize, normalize, stem) in a sentence or two.
  • Describe how the index stays in sync (CDC or events) and how you'd rebuild it.
  • Mention ranking signals beyond text match: popularity, recency, personalisation.

Further reading