studyDDIAChapter 4 · Storage and Retrieval

LIKE Reads Every Row; an Inverted Index Reads Five Buffers

Finding a rare word in 5 million documents with ILIKE '%word%' scans the whole table — ~1.25 s. A tsvector + GIN inverted index finds the same rows in ~19 ms, and the index probe itself touches just 5 buffers — ~65× faster. The catch: it matches words, not arbitrary substrings.


Concept

Finding a word in a text column with LIKE '%word%' forces a full table scan — the leading wildcard defeats an ordinary index, so the database reads every row. An inverted index (the structure behind full-text search) instead stores, for each distinct word, the list of documents that contain it, so a lookup jumps straight to the matches. On 5 million short documents you will find a rare word with ILIKE in ~1.25 seconds reading the whole table, then with a tsvector + GIN index in ~19 ms whose index probe touches just 5 buffers — ~65× faster. The catch: the inverted index matches words, not arbitrary substrings.

Provenance

Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 4 — Storage and Retrieval, §"Full-Text Search":

a full-text search index … allows you to search for a word and find all the documents … that contain that word.

The book frames full-text search as a specialized inverted index, distinct from the key-lookup structures earlier in the chapter. Here you measure the gap against the naive substring scan, and meet the semantic catch that comes with it.

Prerequisites

The surprise is about which queries an index can serve. These help; each line notes where it shows up.

  1. LIKE '%x%' and the leading wildcard — a B-tree index orders strings by prefix, so it can accelerate LIKE 'x%' but not LIKE '%x%'; a leading wildcard forces a full scan.
  2. Inverted index — a map from each term (word) to a postings list: the set of documents containing it. Looking up a word returns its matches directly, the way a book's index maps a word to page numbers.
  3. Tokenization / lexemes — full-text search first splits text into words and normalizes them (lowercase, stem), then indexes those lexemes — not the raw character string.
  4. Postgres tsvector / tsquery / GINto_tsvector turns text into its lexemes; a GIN (Generalized Inverted iNdex) indexes them; @@ matches a tsquery against the tsvector.
Where to learn the prerequisites Inverted indexes (#2): DDIA §"Full-Text Search"; any IR (information retrieval) intro on inverted indexes and postings lists. Postgres FTS (#3–#4): the PostgreSQL manual, "Full Text Search" and the GIN index docs. Substring search (#1): the pg_trgm extension docs — the other tool, for when you genuinely need LIKE '%…%' to be fast (a Go-deeper below). If only one is new, make it #2 — the inverted index is the whole idea.
Environment these numbers came from Reproducible plan shapes and row counts (buffer counts and timing vary a little with cache state). Captured on macOS 26.5.2 (Darwin 25.5.0), arm64, with Docker 29.6.2 running the official postgres:16 image (PostgreSQL 16.14). One docs table, 5,000,000 short documents; ~5,000 contain the rare word kestrel.

Mental model

Five million short documents built from a small vocabulary; about one in a thousand also contains the rare word kestrel. We find those two ways:

We read EXPLAIN (ANALYZE, BUFFERS) for each. The whole exercise is one script, code/full_text_search.sql.

Setup

docker run -d --name ddia-ch4 -e POSTGRES_PASSWORD=study -e POSTGRES_DB=study postgres:16
sleep 3
docker cp full_text_search.sql ddia-ch4:/tmp/
docker exec ddia-ch4 psql -U postgres -d study -f /tmp/full_text_search.sql

Do not read the output yet — make each prediction first.


Step 1 · the substring scan (calibration)

Predict. WHERE body ILIKE '%kestrel%' over 5,000,000 rows, no full-text index. How much of the table does Postgres read, and how long does it take?

How much does a leading-wildcard LIKE read?
########## substring scan: WHERE body ILIKE '%kestrel%' (no usable index) ########## Finalize Aggregate (actual time=1246.827..1249.060 rows=1 loops=1) Buffers: shared hit=16138 read=26380 dirtied=8325 written=7857 -> Gather (actual time=1246.555..1249.055 rows=3 loops=1) Workers Planned: 2 -> Parallel Seq Scan on docs (actual time=1.130..1238.730 rows=1683 loops=3) Filter: (body ~~* '%kestrel%'::text) Rows Removed by Filter: 1664984 Buffers: shared hit=16138 read=26380 dirtied=8325 written=7857

A Parallel Seq Scan over the whole table — ~42,500 buffers, ~1.25 s even with two workers — to find the ~5,000 matches. The leading % means every one of the 5,000,000 rows must be read and tested.

Step 2 · the inverted index (the surprise)

Predict. Add a tsvector column and a GIN index, then query the word with @@. How many buffers does the index probe itself read, and how much faster is the whole query?

How few buffers does the inverted-index probe read?
########## inverted index: tsvector + GIN, WHERE body_tsv @@ to_tsquery('kestrel') ########## Aggregate (actual time=19.443..19.444 rows=1 loops=1) Buffers: shared hit=127 read=4791 -> Bitmap Heap Scan on docs (actual time=1.556..19.210 rows=5049 loops=1) Recheck Cond: (body_tsv @@ '''kestrel'''::tsquery) Heap Blocks: exact=4913 Buffers: shared hit=127 read=4791 -> Bitmap Index Scan on docs_gin (actual time=1.117..1.118 rows=5049 loops=1) Index Cond: (body_tsv @@ '''kestrel'''::tsquery) Buffers: shared hit=5

~19 ms — about 65× faster — and look at the last line: the Bitmap Index Scan that finds all 5,049 matching documents reads just 5 buffers. The word kestrel maps directly to its postings list. The remaining ~4,900 buffers are the heap fetches to count the matched rows; the search itself was almost free.

Step 3 · the catch: words, not substrings

Predict. The ILIKE '%kes%' version would also match kestrel. Can the GIN index answer WHERE body ILIKE '%kes%' too?

Can the inverted index serve a substring query?

No — and this is the semantic price. The inverted index is built on lexemes (whole words), so it answers "which documents contain the word kestrel," not "which contain the substring kes." to_tsquery('kes') would match the token kes, which no document has; ILIKE '%kes%' (substring) can't use the GIN index at all and falls back to the seq scan of Step 1. The 65× speedup comes with a change of question: word/phrase search, not arbitrary substring.


What you should see

Why

LIKE '%kestrel%' asks whether a pattern appears anywhere in each string. A B-tree orders strings by their prefix, so it can narrow LIKE 'kestrel%' but has nothing to offer once the pattern can start at any position — the planner has no choice but to read every row and run the matcher, which is the Parallel Seq Scan over all 5,000,000 rows.

An inverted index turns the question around. When the tsvector is built, Postgres tokenizes each document into lexemes and, in the GIN index, records for every distinct word the sorted list of rows that contain it (its postings list). Querying @@ to_tsquery('kestrel') is then a lookup of one word in that dictionary followed by a walk of its postings — the 5-buffer Bitmap Index Scan — returning the ~5,000 matching row locations directly. The database never looks at documents that lack the word. That is the same idea as the index at the back of a book: you don't reread the book to find a term, you look the term up and get the page numbers.

The boundary — a different question, and a write-time cost An inverted index is superb at "documents containing this word/phrase" and useless for "strings containing these characters." A genuine substring query (LIKE '%kes%') needs a different structure — Postgres's pg_trgm trigram GIN index, which indexes 3-character grams — because the token-based index simply doesn't contain substrings. And the speed isn't free: the tsvector must be computed and the GIN index maintained on every insert and update, so full-text search trades write throughput and storage for read speed. This is exactly the book's point that full-text search is its own specialized index, not the key-lookup structures used elsewhere in the chapter.

Go deeper

Sources: DDIA 2e, Ch. 4, §"Full-Text Search" · PostgreSQL manual, "Full Text Search," GIN indexes, and pg_trgm.