Module 15 · Lesson 2 of 15Free
Inverted indexes: mapping words to documents
- Level
- Intermediate
- Topic
- Search: indexing, ranking and retrieval
- Reading time
- 19 min
When you finish this page you can
- Build an inverted index from product titles and trace a term lookup.
- Choose between document IDs, frequencies and positions for each searchable field.
- Explain how analysis rules affect matching and require index rebuilds.
- Estimate index storage and explain safe publication of index updates.
Builds on 1 lesson
Start with lesson 118 minVideo lesson · coming soon
Maya searches for “red shoes” on . Its catalog has 20 million products, but the first implementation reads every title before returning anything.
An index on product ID cannot answer “which titles contain shoes?” How can search find matching products without reading every title?
Store the question’s missing direction
A product record maps an ID to its title. Search asks the reverse question: given a word, which IDs contain it?
Start with this small slice of Kartly’s catalog. Throughout this lesson, a document means one product’s searchable record.
| Document ID | Title |
|---|---|
| 11 | Red running shoes |
| 12 | Blue running shoes |
| 13 | Red leather bag |
| 14 | Shoes red shoes |
The word shoes appears four times across these titles. Should a lookup for shoes return four document IDs?Show answer
An maps each searchable term to matching documents. A term is the representation actually stored after processing the text.
For now, lowercase each word:
| Term | Matching document IDs |
|---|---|
| bag | 13 |
| blue | 12 |
| leather | 13 |
| red | 11, 13, 14 |
| running | 11, 12 |
| shoes | 11, 12, 14 |
Diagram not drawn yet: kartly-title-inversion.svg
Three product titles map into term entries. Documents 11, 12 and 14 each contain shoes. The shoes entry points to the sorted list 11, 12, 14. Document 14 contains shoes twice but appears once in the list.
The term-to-list lookup structure is the dictionary. Each document entry is a posting; the entries for one term form its . PostgreSQL’s GIN text-search index uses this word-to-matching-locations structure. 3
We have not copied the catalog into a different order. We have built an additional access path, like the access paths introduced in Indexes.
That extra structure saves reads, but every catalog change now creates maintenance work.
Pay once, then reuse the work
The simplest implementation scans every title on every query. It needs no derived data and immediately sees changes in the source records.
Its limitation is repeated work. A customer searching for “shoes” makes the server rediscover the same matches that another customer just requested.
When should Kartly store a term-to-document mapping?
Scan documents at query time.
Each query analyzes all titles again. This avoids a separate structure, but query work grows with the catalog even when very few documents match.
Build and maintain an inverted index.
Analyze a document when indexing it, then add its ID under each term. Queries look up relevant lists instead of analyzing the entire catalog. Writes and storage become more expensive.
Why this choice
Kartly has many products and repeated searches. Reusing analysis work is worth the extra storage and update path.
A scan remains reasonable for a tiny collection or an occasional administrative query. The choice changes when maintaining the index costs more than the searches it saves.
An inverted index also need not mean a separate search cluster. PostgreSQL supports GIN indexes for full-text search. The data structure and the deployment choice are different decisions. 3
Keep document identity separate
Use numeric document IDs inside this lesson’s index. Keep a separate mapping to Kartly’s public product IDs.
The index can then return compact identifiers without repeating long public identifiers in every term list. The mapping must belong to the same published index version; otherwise ID 11 could resolve to the wrong product.
Keep postings sorted by numeric document ID in this implementation. The next lesson, Boolean retrieval and tiered indexes, uses that order to combine lists.
Sorted IDs do not mean relevant results come first. We have built a way to find matches, not a ranking.
Matching starts before the dictionary
Suppose the title contains “Shoes”, but the query contains “shoes”. Literal dictionary lookup finds different strings unless both sides apply compatible rules.
An converts text into searchable terms. It splits text into tokens and can normalize them, such as converting uppercase letters to lowercase. Analysis happens during both indexing and searching. 1
For our small example, choose this explicit contract:
| Input | Stored terms |
|---|---|
Red running shoes |
red, running, shoes |
SHOES |
shoes |
running-shoes |
running, shoes |
shoes2 |
shoes2 |
The implementation below recognizes only ASCII letter-and-digit runs. This is a teaching boundary, not a suitable analyzer for Kartly’s multilingual catalog.
A real field needs deliberate rules for its languages and punctuation. Elastic’s documentation describes tokenization, normalization and configurable analyzers as separate parts of that choice. 1
Normalize only what you can merge
Lowercasing makes differently capitalized words share an entry. More aggressive normalization can also merge words you wanted to distinguish.
For example, splitting AB-12 into ab and 12 may help broad text search. It does not preserve an exact model-number lookup.
For this scenario, keep separate fields:
title: analyzed words for text search.model_code: one preserved value for exact lookup.
Do not concatenate title and description without preserving their boundaries. Otherwise an apparent phrase could begin in the title and end in the description.
If analysis changes, old postings do not change themselves. Rebuild affected fields, or query old and new representations deliberately. The analyzer is part of the index’s data contract.
One posting can answer several questions
Our ID-only list answers “does this document contain shoes?” It cannot answer “how often?” or “where?”
For document 14, “Shoes red shoes”, a richer posting could store:
{
"term": "shoes",
"document_id": 14,
"frequency": 2,
"positions": [0, 2]
}counts occurrences within one document. Positions record token order, starting at zero in this lesson.
Lucene 10.3.1 exposes separate indexing options for document IDs, frequencies, positions and character offsets. These options preserve different information rather than merely changing storage size. 2
What should Kartly retain in title postings?
Store document IDs only.
Each matching document appears once. Membership checks are enough, but neither repeated occurrences nor word order can be recovered from this list.
Store IDs and frequencies.
Each posting adds an occurrence count. Ranking can use that count, but red shoes and shoes red still have identical counts.
Store IDs, frequencies and positions.
Each posting also retains token locations. Phrase checks become possible, but repeated words add more position values to store and read.
Why this choice
For this scenario, Kartly wants phrase matching in product titles. Positions retain the order needed to check it.
Choose IDs only for a membership-only field. Choose frequencies without positions when a field needs occurrence-based scoring but no positional queries.
Adding positions later requires recovering them from the original text. They cannot be reconstructed from counts alone.
Trace a phrase without guessing
Consider the exact phrase “red shoes”:
| Document | red positions |
shoes positions |
Phrase matches? |
|---|---|---|---|
| 11 | 0 | 2 | No: “running” is between them |
| 12 | — | 2 | No: “red” is absent |
| 13 | 0 | — | No: “shoes” is absent |
| 14 | 1 | 0, 2 | Yes: positions 1 and 2 |
For this analyzer, accept a document when a shoes position equals a red position plus one. Counts alone cannot prove that relationship.
Diagram not drawn yet: kartly-title-positions.svg
Document 11 has red at position 0, running at 1 and shoes at 2, so red shoes is not adjacent. Document 14 has shoes at 0, red at 1 and shoes at 2, so red shoes matches positions 1 and 2.
Lucene’s ID-only and frequency-only options do not support positional queries on the field. 2 Our example explains the missing information: neither representation records adjacency.
Frequency will matter again in TF-IDF. Here, it remains evidence stored for later use, not a relevance score.
Build the index from titles
The index builder owns the following structure:
term → document ID → ordered positions
The inner map has one entry per matching document. Its position-list length gives the frequency, avoiding a separate count in this teaching implementation.
Process documents in ID order. Process each title from left to right. Those choices produce ordered document entries and ordered positions without sorting every postings list afterward.
import re
Index = dict[str, dict[int, list[int]]]
def analyze(text: str) -> list[str]:
return [
token.lower()
for token in re.findall(r"[A-Za-z0-9]+", text)
]
def build_index(documents: dict[int, str]) -> Index:
index: Index = {}
for doc_id, title in sorted(documents.items()):
for position, term in enumerate(analyze(title)):
postings = index.setdefault(term, {})
postings.setdefault(doc_id, []).append(position)
return index
documents = {
11: "Red running shoes",
12: "Blue running shoes",
13: "Red leather bag",
14: "Shoes red shoes",
}
index = build_index(documents)
print(list(index.get(analyze("SHOES")[0], {})))
# [11, 12, 14]type Index = Map<string, Map<number, number[]>>;
function analyze(text: string): string[] {
return (text.match(/[A-Za-z0-9]+/g) ?? [])
.map(token => token.toLowerCase());
}
function buildIndex(documents: Map<number, string>): Index {
const index: Index = new Map();
const ordered = [...documents].sort(([a], [b]) => a - b);
for (const [docId, title] of ordered) {
analyze(title).forEach((term, position) => {
let postings = index.get(term);
if (postings === undefined) {
postings = new Map();
index.set(term, postings);
}
const positions = postings.get(docId) ?? [];
positions.push(position);
postings.set(docId, positions);
});
}
return index;
}
const documents = new Map<number, string>([
[11, "Red running shoes"],
[12, "Blue running shoes"],
[13, "Red leather bag"],
[14, "Shoes red shoes"],
]);
const index = buildIndex(documents);
const term = analyze("SHOES")[0]!;
console.log([...(index.get(term)?.keys() ?? [])]);
// [11, 12, 14]There are 12 token occurrences in the sample. The builder performs one position append per occurrence.
For N documents and T analyzed tokens, document sorting costs O(N log N). Building the maps then takes expected O(T) map operations, plus text-analysis work.
A single-term lookup finds its dictionary entry, then reads the matching postings. Returning M document IDs still requires O(M) output work. A dictionary lookup does not make a large result set free.
The code handles repeated terms and empty titles. It does not implement updates, persistence or ranking. An empty query also needs an explicit API policy; do not index into an empty analyzed query as the fixed demonstration does.
Trace reads and writes separately
Place this structure inside the search service from Search system architecture. The following view represents one logical index , not all of Kartly’s deployment.
Diagram not drawn yet: kartly-inverted-index-paths.svg
Catalog records flow to an index builder, which publishes a search index. The search handler reads postings from that index and requests product cards from the catalog. The index stores searchable terms, not authoritative stock or prices.
| Component | Responsibility | Talks to | State | Failure behavior |
|---|---|---|---|---|
| Index builder | Analyze records | Catalog, index | Build progress | Publication pauses |
| Search index | Store postings | Builder, handler | Terms and postings | Searches fail or use a serving copy |
| Search handler | Resolve matches | Index, catalog | Request state | Request fails |
| Catalog | Supply records and cards | Builder, handler | Product truth | Cards cannot be completed |
Write path
- Read a product record with its ID and version.
- Analyze the searchable fields.
- Build postings under field-specific terms.
- Publish a complete index generation for readers.
Read path
- Analyze the query using compatible field rules.
- Look up its terms in the dictionary.
- Read postings and select matching document IDs.
- Resolve those IDs into product cards.
A missing dictionary entry means no match under this index’s representation. It does not prove the customer’s intended product does not exist.
If the catalog call fails, do not turn that failure into “no results”. Distinguish a dependency error from a successful search with no matches.
The paths also expose a correctness boundary: an indexed product may no longer be available when the customer opens it.
Measure the work you moved
Use these illustrative assumptions for Kartly’s 20-million-product catalog:
- Average encoded title: 160 bytes.
- Average distinct indexed title terms: 12 per product.
- “Shoes” occurs in 40,000 titles.
- Packed document ID: 4 bytes.
These are scenario inputs, not measured production values.
Title data examined by a scan
20,000,000 titles × 160 bytes/title = 3.2 GB
This is logical title data considered per full scan, not necessarily physical disk traffic. Caching does not remove the repeated matching work.
IDs in the shoes list
40,000 IDs × 4 bytes/ID = 160 KB
This counts only packed IDs. It excludes positions, decoding and product-card retrieval.
ID payload across all terms
20,000,000 documents × 12 terms/document × 4 bytes = 960 MB
This excludes the dictionary, frequencies, positions and every implementation overhead.
The term list names 500 times fewer documents than the catalog contains. That is not a claim of 500-times lower : the operations and downstream costs differ.
The 960 MB figure also does not describe the code above. Its maps and lists store objects rather than packed four-byte entries.
The decision is therefore narrower than “put everything in memory”: prepare term access paths, then measure their full representation and query costs.
Updates must not expose half a document
Suppose a seller changes document 11 from “Red running shoes” to “Blue running shoes”.
Appending its ID under blue is insufficient. Leaving it under red creates a stale match. Removing the entire shoes list would also remove unrelated products.
For a simple correct update design, rebuild a replacement snapshot for a bounded partition. Give readers an immutable snapshot handle, then switch new requests to the replacement only after it is complete.
An in-flight request keeps its old handle. It sees either the old title representation or the new one, never half of each.
This design spends extra storage and rebuild work to simplify reader . It is an explanatory baseline, not a proposal to rebuild Kartly’s whole catalog for every edit.
For incremental updates, retain enough document-to-term information to remove that document’s old memberships. Publish the removals and additions as one reader-visible change. Compare source versions so a late retry cannot replace newer text with older text.
Recover without trusting incomplete output
If the builder crashes, keep serving the previous complete generation. Track the last successful publication and source progress; a widening gap signals that customers are searching older data.
Build into a new destination. Validate it before publication, including known-term lookups and document counts. A failed build must not replace a working index.
If the serving index is unavailable, return a search error or use an available serving copy. Do not start unlimited catalog scans during a traffic peak.
The source-to-index delivery protocol belongs in Index synchronization. This lesson’s requirement is that readers never mistake partial construction for a complete index.
Where this shows up
- Design: search autocomplete: retrieving matching vocabulary is a separate step from retrieving its document postings.
- Design: in-app search for a large product: field analysis and posting detail determine supported matching behavior.
We can now find each word’s documents; next, Kartly must combine those lists without scanning them carelessly.
Common mistakes
- Treating repeated words as duplicate results. Keep one document entry per term, with occurrences inside that entry.
- Assuming every query becomes cheap. A common term can still have a large postings list.
- Discarding positions, then promising phrase search. Membership and frequency do not retain word order.
- Changing only the query analyzer. Existing postings still encode the old rules.
- Serving index data as catalog truth. Recheck authoritative price and where the product flow requires them.
Check your understanding
Document 14 contains shoes twice. A lookup returns it twice. Which part of the index representation should change?Show answer
Your index stores IDs and frequencies. Can it distinguish red running shoes from the exact phrase red shoes?Show answer
The query analyzer starts converting shoes to shoe, but stored postings still use shoes. What happens?Show answer
A product stops containing red after an edit. The builder adds its new terms but removes nothing. What failure should you expect?Show answer
A term occurs in almost every product title. Does a fast dictionary lookup guarantee a fast complete response?Show answer
Summary
- An inverted index avoids repeated title scans by mapping searchable terms to document postings.
- Index and query analysis must produce compatible terms; changing stored representations requires rebuilding or migration.
- IDs preserve membership, frequencies preserve counts, and positions preserve word order.
- A lookup costs more than finding a dictionary entry: large postings and product retrieval still need work.
- Publish complete reader-visible updates, remove obsolete memberships, and keep the catalog authoritative.