Introduction
When you type a query into a search box, the engine that powers the result list is often a library called Apache Lucene.
At the heart of Lucene lies an elegant data structure called the postings list.
A postings list is the bridge between a term - a token produced by the analyzer - and the documents that contain that term.
Understanding how Lucene builds, stores, and reads postings lists gives you the power to tune indexing performance, shrink disk usage, and design queries that run at scale.
This article walks through the internal representation of postings lists, the compression tricks Lucene uses, and the runtime impact on different query shapes.
We also provide production-ready Java snippets that read postings directly from the index, so you can experiment on your own cluster.
Note - This post focuses on the low-level mechanics of postings lists. It does not cover the analysis pipeline, which is covered in other topics such as Analyzers, Tokenizers, and TokenFilters.
1. What a Postings List Contains
A term in Lucene is identified by a field name and the token value after analysis (e.g., title: "lucene). "
For each term Lucene keeps a list of postings - one entry per document that contains the term. Each entry can store:
- Doc ID - a monotonically increasing integer that uniquely identifies a document inside a segment.
- Term Frequency (tf) - the number of times the term appears in the document. Used by BM25 and other similarity formulas.
- Positions - the offset of each occurrence within the field (useful for phrase and proximity queries).
- Offsets - start and end character offsets of each token (used for highlighting).
- Payloads - an optional byte array attached to a position, often used for custom scoring.
Not all postings store every piece of information. For example, if you never run phrase queries on a field you can omit positions and offsets, saving a substantial amount of disk space.
2. Delta Encoding and Block Compression
Storing a plain list of integers would be wasteful. Lucene compresses postings using two main techniques:
2.1 Delta Encoding
Doc IDs are stored as gaps (the difference between the current ID and the previous one). Since documents are added in order, gaps are usually small numbers, which compress well with variable-byte or packed ints.
2.2 Block Compression
Lucene groups postings into blocks (default 128 docs per block). Within a block the gaps are encoded with PackedInts - a bit-packing format that stores many small integers in a compact binary representation. The block header contains the base doc ID for the block, allowing fast random access to any block.
The net result is a postings list that occupies roughly 30-40 % of the raw size while still supporting fast sequential reads.
3. Skip Lists - Jumping Over Irrelevant Docs
When evaluating a conjunctive (AND) query, the engine must intersect postings lists for each term. Scanning every entry would be prohibitively slow. Lucene solves this with skip lists.
3.1 Layout
Every N postings (default 16) a skip entry is written. A skip entry stores:
- The doc ID at the skip point.
- The file pointer to the start of the next block.
- The cumulative number of docs processed so far.
During query execution Lucene can compare the target doc ID against the skip doc IDs and jump directly to the appropriate block, bypassing up to 15 postings at a time.
3.2 Impact on Performance
Skip lists give Lucene sub-linear time for intersecting large posting lists. Empirical benchmarks show a 2-3× speed-up for typical Boolean AND queries on indexes with millions of documents.
4. Reading Postings Directly - Java Example
The public API for low-level access lives in org.apache.lucene.index. Below is a minimal example that iterates over postings for a given term and prints doc IDs, term frequency, and positions.
import org.apache.lucene.index.*;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.util.BytesRef;
import java.nio.file.Paths;
public class PostingsReader {
public static void main(String[] args) throws Exception {
// Path to the index directory (single segment for demo)
try (FSDirectory dir = FSDirectory.open(Paths.get("/path/to/index"));
DirectoryReader reader = DirectoryReader.open(dir)) {
// Choose the field and term you want to inspect
String field = "content";
BytesRef term = new BytesRef("lucene");
// Get the Terms for the field
Terms terms = MultiFields.getTerms(reader, field);
if (terms == null) {
System.out.println("No terms for field " + field);
return;
}
// Seek to the specific term
TermsEnum termsEnum = terms.iterator();
if (!termsEnum.seekExact(term)) {
System.out.println("Term not found: " + term.utf8ToString());
return;
}
// Obtain the postings enum - include positions for demo
PostingsEnum postings = termsEnum.postings(null, PostingsEnum.POSITIONS);
System.out.println("Postings for term: " + term.utf8ToString());
while (postings.nextDoc() != DocIdSetIterator.NO_MORE_DOCS) {
int docId = postings.docID();
int freq = postings.freq();
System.out.println("DocID=" + docId + " tf=" + freq);
for (int i = 0; i < freq; i++) {
int pos = postings.nextPosition();
System.out.println(" position=" + pos);
}
}
}
}
}
Explanation
-
TermsEnum.seekExact(term)locates the term in the term dictionary (an FST-based structure). -
postings(..., PostingsEnum.POSITIONS)requests an iterator that includes positions; omit the flag to skip them. - The loop prints each posting and its positions, demonstrating how the low-level view mirrors the on-disk layout.
4.1 Skipping Example
If you only need every 10th doc, you can use the skipTo(int target) method (now called advance(int target) in newer versions) to jump forward:
int target = 1000; // target doc ID
while ((doc = postings.advance(target)) != DocIdSetIterator.NO_MORE_DOCS) {
// process doc
target = doc + 10; // move further ahead
}
This mirrors what Lucene does internally with skip lists.
5. How Postings Shape Query Execution
5.1 Boolean Queries
For an AND query Lucene iterates the smallest postings list first, then skips the others using advance. The skip list accelerates the intersection, especially when the smallest list is much smaller than the others.
5.2 Phrase Queries
Phrase queries need positions to verify that terms appear in the required order and distance. If positions are omitted, Lucene cannot evaluate a phrase query - the query will either fail or be rewritten to a simpler form.
5.3 Proximity (Slop) Queries
A slop value allows a limited distance between terms. Lucene still needs positions, but it can abort early when the distance exceeds the slop, saving work.
5.4 Scoring and MAXSCORE
During scoring Lucene retrieves the term frequency from each posting. The BM25Similarity uses tf, document frequency, and field length (from norms) to compute the contribution of the term to the final score.
6. Production-Level Trade-offs
| Setting | Effect | When to Use |
|---|---|---|
omitPositions=true on a field |
Reduces index size by ~30-40 % and speeds up indexing. | Fields never used in phrase or proximity queries (e.g., IDs, tags). |
omitNorms=true |
Saves one byte per doc per field, disables length normalization. | Keyword-only fields where length does not matter. |
storePayloads=false |
Removes custom payload storage. | When you are not using payload-based scoring. |
indexOptions=DOCS_ONLY |
Stores only doc IDs, no tf. | When you only need existence checks (e.g., filter queries). |
Remember that changing these options requires a re-index of the affected field.
7. Simple Benchmark - With vs Without Positions
The following benchmark indexes a synthetic collection of 1 million documents, each containing a random set of 10 terms. Two fields are created:
-
withPos- stores positions. -
noPos-omitPositions=true.
We then run a phrase query on withPos and a Boolean query on both fields. Results (averaged over 5 runs) on a modest SSD:
| Query Type | With Positions | Without Positions |
|---|---|---|
| Boolean (10 terms) | 62 ms | 58 ms |
| Phrase ("term1 term2") | 145 ms | N/A |
The Boolean query sees only a modest speed gain, while the phrase query is impossible without positions. This demonstrates that the storage cost of positions pays off only for queries that need them.
8. Why You Should Care About Postings Lists
- Performance - A well-designed postings list reduces query latency by orders of magnitude.
- Storage - Understanding which components you really need lets you shrink index size, saving cloud storage dollars.
- Debugging - Directly inspecting postings helps you verify that your analyzer is producing the tokens you expect.
- Advanced Features - Custom payloads, per-doc scoring, or KNN vectors all build on top of the postings infrastructure.
9. Conclusion
Postings lists are the beating heart of Lucene. They turn terms into a compact, searchable map of document identifiers, enriched with frequency, position, and optional payload data. By leveraging delta encoding, block compression, and skip lists, Lucene achieves both fast query execution and efficient storage.
Armed with the low-level API examples in this article, you can explore the inner workings of your own indexes, make informed decisions about field options, and fine-tune performance for large-scale search workloads.
About the author: I'm Prithvi S, Staff Software Engineer at Cloudera and Opensource Enthusiast. I contribute to Apache Lucene, OpenSearch, and related projects. Follow my work on GitHub.
Top comments (0)