TL;DR: The thing that broke my mental model first wasn't slow queries — it was watching disk I/O climb to 95% utilization on NVMe drives while average query latency jumped from 12ms to 340ms on a corpus I'd carefully tuned for months. We were running Elasticsearch 8.
📖 Reading time: ~41 min
What's in this article
- The Problem I Kept Running Into: Index Bloat at Scale
- Quick Primer: What an Inverted Index Actually Stores
- The Core Algorithms You'll Actually Encounter
- How Lucene 9.x Actually Picks a Codec
- Elasticsearch 8.x: Configuring Compression in Practice
- Apache Solr: Where the Controls Are More Exposed
- Tantivy (Rust): A Different Approach Worth Knowing
- Benchmarking Compression Tradeoffs: What I Actually Measured
The Problem I Kept Running Into: Index Bloat at Scale
The thing that broke my mental model first wasn't slow queries — it was watching disk I/O climb to 95% utilization on NVMe drives while average query latency jumped from 12ms to 340ms on a corpus I'd carefully tuned for months. We were running Elasticsearch 8.9 on a 500M-document news index, and the cluster had plenty of heap. That's what made it confusing. The JVM wasn't sweating. The GC logs were clean. But iostat -x 1 told the real story.
# What I was seeing on every data node:
$ iostat -x nvme0n1 1
Device r/s rkB/s %util await
nvme0n1 4821.0 384MB/s 94.3 18.2ms
# Healthy baseline for comparison was:
# nvme0n1 1200.0 96MB/s 31.0 2.1ms
I pulled apart the index storage breakdown using the _cat/indices and _cat/segments APIs, and the distribution hit me hard. Of a roughly 60GB index, about 40GB was postings lists — the structures that map terms to document IDs. The actual stored fields (the JSON source) was a fraction of that. Elasticsearch's default codec (default, which uses LZ4 for stored fields but leaves postings mostly uncompressed beyond basic delta + VByte encoding) was doing far less than I'd assumed. I had been thinking "compression is on, so we're fine." We were not fine.
The "just add more RAM" response breaks down specifically because postings lists blow through the page cache budget. At 40GB of postings on nodes with 64GB total RAM (30GB to the OS, ~30GB usable for page cache after heap), any query touching a non-hot term forces a disk read. Worse, postings for high-cardinality fields like URLs or user IDs are accessed unpredictably — you can't preload them, and they thrash the cache constantly. Increasing heap doesn't help here. Adding RAM buys you time but the economics get brutal fast: you're paying for NVMe-speed RAM to compensate for a storage layout problem.
Here's the distinction that most write-ups blur together: adaptive compression on an inverted index means the codec dynamically picks the encoding strategy per postings list based on the list's statistical profile — density, gap distribution, term frequency variance. This is different from just enabling BEST_COMPRESSION mode in Elasticsearch (which switches stored fields to DEFLATE and helps the JSON blob problem, not the postings problem). Adaptive compression on postings might choose PFOR-delta for a dense list, simple bitpacking for a near-uniform one, and run-length encoding for a boolean field. What it doesn't solve: query-time CPU overhead from decompressing very large postings at intersection, and it does nothing for your _source field bloat if you're storing full document JSON. Two completely different storage layers, and conflating them wastes days of debugging time.
# Switching to BEST_COMPRESSION in index settings — this helps stored fields, NOT postings:
PUT /my-index/_settings
{
"index": {
"codec": "best_compression"
}
}
# To actually influence postings encoding in Lucene (which Elasticsearch sits on),
# you need a custom codec or to use the lucene90 codec with specific similarity/field settings.
# There's no single ES setting that exposes adaptive postings compression directly —
# that's the gap most tutorials skip over entirely.
The practical gap I kept finding in documentation is that Lucene 9.x (which backs Elasticsearch 8.x) ships with Lucene90PostingsFormat using PFOR-delta by default, but Elasticsearch's abstraction layer doesn't expose the knobs cleanly. You're either writing a custom Lucene plugin to swap codecs per-field, or you're engineering around the problem at index design time — controlling cardinality, splitting high-frequency fields into separate indices, or aggressively using doc-value-only fields for analytics. None of that is obvious from the Elasticsearch docs, and all of it took me longer to figure out than it should have.
Quick Primer: What an Inverted Index Actually Stores
Most people think of an inverted index as a fancy hash map from word to document list. That's close enough for a cocktail party but completely wrong for compression decisions. There are actually three distinct data structures you're compressing, and they have radically different statistical properties — which is why treating them the same way is the mistake almost every DIY search implementation makes.
The Three Structures You're Actually Compressing
The terms dictionary is a sorted list of every unique token in your corpus, usually stored as a trie or a sorted FST (finite state transducer). Lucene's FST implementation packs this into a structure where you can do prefix lookups in microseconds. The dictionary itself compresses well with general-purpose codecs — there's significant shared prefix entropy across lexicographically adjacent terms. The postings list is where things get interesting: for each term, you store a list of document IDs that contain it, plus optional term frequency per doc. The skip data is essentially a sparse index over the postings list so you can jump ahead during boolean AND queries without scanning everything.
Why Doc IDs Are Basically Free to Compress
Raw doc IDs in a postings list might look like [1042, 1051, 1063, 1078, ...]. Nobody stores them that way. You delta-encode first: [1042, 9, 12, 15, ...]. Those deltas cluster hard around small values for common terms — a term appearing in 40% of docs has an average gap of 2.5. That's a bitwidth of 2 bits if you're using bit-packing, versus 32 bits for a raw int. FOR (Frame Of Reference) and PFOR (Patched FOR) schemes exploit this directly, packing 128 or 256 deltas into a block where almost every value fits in the same small bitwidth, with a separate exception list for outliers. The compression ratio here is almost embarrassingly good — you routinely get 4:1 to 8:1 on doc ID streams for high-frequency terms.
Frequencies and Positions Are Where It Gets Hard
Term frequencies don't delta-encode cleanly because they're not monotone — they're counts, and the distribution is Zipfian but noisy. A document might have tf=1 for the word "quantum" and tf=47 for "the". Position data is the worst: you're storing the within-document offset of every occurrence, so for a 10,000-word document with 50 hits, you get 50 intra-document deltas, and those can be anywhere from 1 to 9,999. The entropy here is genuinely higher. Gamma coding and variable-byte encoding handle this better than fixed-width PFOR because they spend more bits on high values without a fixed frame. If you skip storing position data (which disables phrase queries), you drop 60-80% of your index size. That's the trade-off Elasticsearch lets you make with "index_options": "freqs" or "docs".
Static vs. Adaptive: The Core Decision
Static compression means you treat the whole postings file as a byte stream and run zstd or gzip over it. Simple, and honestly decent for cold storage. The problem is you need to decompress a whole segment — or at least a meaningful chunk — to answer a single query. Adaptive compression assigns a codec per block, typically blocks of 128 or 256 postings. Each block stores a 1-2 byte header saying which codec was used: PFOR if the values are tightly packed, simple variable-byte if the gaps are large and irregular, or even a raw 32-bit fallback for the pathological cases. Lucene 9.x does exactly this with its Lucene99PostingsFormat. The payoff is that you can decompress a single 128-entry block in isolation — critical for skip-list jumps during query evaluation. You pay ~2% space overhead for the per-block headers and get random-access decompression essentially for free.
# Rough sketch of what a block header encodes:
# bits [0..3] = codec selector (0=raw, 1=vbyte, 2=pfor, 3=delta+pfor...)
# bits [4..8] = bitwidth for PFOR packed values (1–32)
# bits [9..15] = exception count for PFOR outliers
# Example: reading a PFOR block with bitwidth=4, 3 exceptions
codec = header & 0x0F # → 2 (PFOR)
bitwidth = (header >> 4) & 0x1F # → 4
exc_count = (header >> 9) & 0x7F # → 3
# Then: read ceil(128 * 4 / 8) = 64 bytes of packed data
# read 3 * (1 byte index + 4 byte value) = 15 bytes of exceptions
The gotcha I ran into: if you implement adaptive compression and forget to align your block boundaries to 8 or 16 bytes, your SIMD-accelerated unpacking (AVX2 does 256 bits at a time) will either fault or silently fall back to scalar code. The 4x throughput gain from SIMD is entirely dependent on aligned reads, and nothing in the spec reminds you of this until you're profiling and wondering why your "optimized" codec is slower than variable-byte.
The Core Algorithms You'll Actually Encounter
The thing that trips up most people building search infrastructure is conflating postings compression with stored field compression. They're solved by completely different algorithms, run at different points in the stack, and have almost opposite trade-off profiles. I'll untangle that at the end, but first — the algorithms that actually matter for postings lists.
Variable-Byte (VByte): The Workhorse Baseline
VByte encodes each integer using 1–5 bytes, where 7 bits per byte carry data and the high bit signals "more bytes follow." Lucene used this as its primary postings codec for years, and it's still the right choice when you need dead-simple random access or you're writing a custom codec from scratch. The decode loop is maybe 15 lines of C. The problem is branch mispredictions — every byte requires a conditional check, and modern CPUs hate that at scale. Benchmarks on large postings lists (1M+ entries) typically show VByte decoding at around 300–500 million integers per second, which sounds fast until you see what block-based schemes can do.
Frame of Reference (FOR) and Patched FOR (PFOR)
FOR works on blocks of 128 integers at a time. You delta-encode the list first (store gaps between docIDs instead of absolute values), find the maximum value in the block, and then pack every integer using only the bits you actually need. A block where the max gap is 200 needs only 8 bits per integer, not 32. The decompressor knows the bit width from a single byte header and can unpack the whole block in one tight loop with no branches. That's the win. PFOR extends this by allowing a small number of "exceptions" per block — outlier values that don't fit the chosen bit width get stored separately. This matters because a single large gap (say, a rare term with one docID near the end of a massive corpus) would otherwise force the entire block to use more bits. Lucene's Lucene90PostingsFormat and its successors use PFOR under the hood. You can verify this:
# Check which codec your index is actually using
curl -s "http://localhost:9200/my_index/_segments?pretty" | \
grep -A2 "codec"
# Expect something like: "name" : "Lucene95"
SIMD-BP128 and Group VarInt: Throwing Hardware at the Problem
If you have AVX2 (any x86 server from roughly 2013 onward), SIMD-BP128 is where things get genuinely impressive. It processes 128 integers simultaneously using 256-bit SIMD registers, achieving decode rates north of 2 billion integers per second in benchmarks — roughly 4–6x faster than scalar PFOR. The catch: you need to compile with -mavx2, your JVM needs to be recent enough to generate the right intrinsics, and you can't just swap this in without testing on your actual hardware because NUMA topology and cache behavior change the numbers significantly. Group VarInt is a middle ground — groups four integers together and uses a single descriptor byte to encode the byte widths of all four. No SIMD required, but you still cut branch pressure substantially over plain VByte. Google's search infrastructure docs have discussed Group VarInt extensively; it performs well on hardware without AVX2 support.
Roaring Bitmaps: The Filter Cache's Secret Weapon
Roaring Bitmaps are what Elasticsearch actually stores in its filter cache, and the reason they're clever is the automatic format switching. The 32-bit integer space gets divided into chunks of 65,536 values each. For each chunk, Roaring picks one of three representations at runtime:
- Array container: fewer than 4,096 values in the chunk — store them as a sorted array of 16-bit shorts. Fast for sparse sets.
- Bitmap container: more than 4,096 values — store a 65,536-bit (8KB) bitmap. Fast for dense sets and bitwise AND/OR operations.
- Run-length encoded container: consecutive runs of set bits — store as (start, length) pairs. Crushes index patterns like date range filters where you have solid runs of matching docIDs.
The container switch happens transparently when you call add() or during merges. The practical consequence: a filter like status = "published" on a mostly-published index will automatically use bitmap containers and make subsequent AND operations against other filters nearly free. You don't configure this — it just does the right thing. The roaring-bitmap library (used by Elasticsearch via the Lucene layer) is at version 0.9.x and the Java bindings are org.roaringbitmap:RoaringBitmap on Maven Central if you want to use it directly.
Zstandard at the Stored Fields Layer: Stop Conflating These Two
Zstd compresses the stored fields — the actual document content you retrieve when a query matches, stored in _source in Elasticsearch. This is completely separate from postings compression. Postings lists are already compressed with PFOR/VByte at write time and never "stored" in the general sense. Stored fields get Zstd (or LZ4 for the default "best speed" mode) applied to 16KB blocks of concatenated document data. Elasticsearch gives you the choice explicitly:
PUT /my_index
{
"settings": {
"index": {
"codec": "best_compression" // uses DEFLATE-level zstd
// default "default" codec uses LZ4
}
}
}
best_compression typically gets you 20–40% smaller stored fields at the cost of higher CPU on indexing and slightly slower fetch. LZ4 decompresses at memory-bandwidth speed and is the right default for most write-heavy clusters. The confusion I see constantly: someone enables best_compression expecting their postings lists and term dictionaries to shrink. They don't. Those are governed by the postings format, not the codec setting. If you want to influence postings compression, you need to write a custom Codec subclass in Java — there's no Elasticsearch config knob for it.
How Lucene 9.x Actually Picks a Codec
The thing that caught me off guard when I first dug into Lucene's codec selection is that there's no single "compression setting" — there are at least three independent compression decisions happening per segment, and they can use completely different algorithms. Getting confused about why your index is larger than expected usually means you're only looking at one of these.
The Codec Progression: 90 → 95 → 99
Each versioned codec is a snapshot of defaults, not just a rename. Lucene90Codec shipped with Lucene 9.0 and established the baseline: DEFLATE-based stored fields (via Lucene90StoredFieldsFormat wrapping LZ4 by default), BM25 postings, and DocValues using a mix of direct and delta encoding. Lucene95Codec (Lucene 9.5) was the vector release — it introduced Lucene95HnswVectorsFormat and changed how graph proximity data gets laid out on disk, but left postings alone. Lucene99Codec (Lucene 9.9) is the meaningful compression upgrade: stored fields switched to a higher-quality LZ4 variant with larger block sizes (16KB → 64KB default chunks), which hurts indexing throughput slightly but meaningfully cuts stored-field size on text-heavy documents. If you're on Elasticsearch 8.12+ or OpenSearch 2.12+, you're using Lucene99Codec whether you realize it or not.
Three Independent Compression Decisions Per Segment
PostingsFormat handles the inverted index — term dictionaries, skip lists, and frequency/position data. The default Lucene99PostingsFormat uses FOR (Frame of Reference) for doc IDs and PFOR (Patched FOR) for frequencies, with a configurable block size (default 128 docs). DocValuesFormat handles column-oriented numeric and keyword fields — things like keyword fields used for aggregations and sorting. It uses a completely different pipeline: sparse encoding for low-cardinality fields, direct numeric encoding for dense numerics. StoredFieldsFormat compresses the raw source document blobs — this is what gets read when you do a _source fetch. Tuning one without understanding the others leads you in circles. A typical mistake: optimizing postings compression for a log index, not realizing 80% of disk is actually stored fields.
How HnswVectorsFormat Diverges From Scalar Postings
Lucene99HnswVectorsFormat doesn't use FOR/PFOR at all. Vector data is fundamentally different — you're storing 768 or 1536 floating-point dimensions per document, and the access pattern is graph traversal, not sequential posting iteration. The codec stores the HNSW graph structure (neighbor lists per layer) separately from the raw vectors, and it applies scalar quantization as of Lucene 9.9 (Lucene99ScalarQuantizedVectorsFormat) — reducing float32 vectors to int8 with a per-segment calibration offset. That's a 4x size reduction with acceptable recall loss for most workloads. The key config knobs are maxConn (default 16, controls graph connectivity) and beamWidth (default 100, controls build quality). You can override them per-field:
// Elasticsearch index mapping (7.x+ style)
PUT /my-index
{
"mappings": {
"properties": {
"embedding": {
"type": "dense_vector",
"dims": 768,
"index": true,
"index_options": {
"type": "hnsw",
"m": 16, // maxConn — higher = better recall, bigger index
"ef_construction": 100 // beamWidth — higher = slower indexing, better quality
}
}
}
}
}
Reading Codec Decisions From a Real Segment With Luke
Luke ships with every Lucene release and gives you the ground truth about what a segment actually chose — not what you configured, but what got written. Open a segment directory in Luke (CLI or GUI), navigate to Segments, and you'll see output like this:
# Luke CLI, Lucene 9.9
$ java -jar luke.jar --index-path /var/data/my-index/0/ --mode info
Segment: _0 (codec: Lucene99Codec)
Stored fields: Lucene90StoredFieldsFormat$Mode=BEST_SPEED # ← still 90, not 99
Postings: Lucene99PostingsFormat (blocksize=128)
DocValues: Lucene99DocValuesFormat
Vectors: Lucene99HnswVectorsFormat(maxConn=16, beamWidth=100)
Segment: _3 (codec: Lucene99Codec)
Stored fields: Lucene99StoredFieldsFormat$Mode=BEST_COMPRESSION
...
Notice that _0 and _3 can have different stored fields formats even though both report Lucene99Codec — this happens when you change index settings mid-stream and the old segments haven't been force-merged. Lucene never rewrites a segment just because you changed codec defaults; the codec that wrote the segment is baked into the segment metadata permanently. The segments_N file stores this per-segment, and Luke reads it directly without opening an IndexReader. This is why force-merge is sometimes a legitimate operational action: it's the only way to actually move old segments onto new codec defaults.
Elasticsearch 8.x: Configuring Compression in Practice
The most common misconception I see is developers assuming index.codec: best_compression compresses your inverted index postings. It doesn't. It switches the stored fields encoder from LZ4 to a DEFLATE-based codec (Lucene's DEFLATE_BEST_COMPRESSION). Your postings lists, term dictionaries, and doc values use their own compression schemes regardless of this setting. That distinction matters a lot when you're debugging why your index didn't shrink as much as expected.
Setting the Codec: Template vs Live Index
For new indices, set the codec in your index template so every rollover inherits it automatically. Here's a production-ready template with realistic settings:
PUT _index_template/logs-template
{
"index_patterns": ["logs-*"],
"template": {
"settings": {
"index.codec": "best_compression",
"index.number_of_shards": 3,
"index.number_of_replicas": 1,
"index.merge.policy.max_merged_segment": "5gb",
"index.similarity.default": {
"type": "BM25",
"b": 0.75,
"k1": 1.2
}
},
"mappings": {
"dynamic": "strict",
"properties": {
"@timestamp": { "type": "date" },
"message": { "type": "text" },
"level": { "type": "keyword" },
"host": { "type": "keyword" }
}
}
}
}
For an already-live index, you can change the codec setting, but only new segments will honor it:
# Close the index first — this causes downtime, so plan accordingly
POST /logs-2024.01/_close
PUT /logs-2024.01/_settings
{
"index.codec": "best_compression"
}
POST /logs-2024.01/_open
Force-Merging: The Right Tool With a Real Danger
Changing the codec on an existing index doesn't rewrite existing segments. You need a force merge to collapse those old segments into new ones that use the updated codec. This command is deceptively simple:
# max_num_segments=1 means merge everything into a single segment
# Only do this on cold/read-only indices — it kills write performance
POST /logs-2024.01/_forcemerge?max_num_segments=1&wait_for_completion=false
The gotcha: even with wait_for_completion=false, the merge runs on the node and can block shard recovery, snapshot operations, and ILM transitions for hours on shards larger than a few GB. I've seen a 200GB shard take 4+ hours to force-merge on a modest instance. Check progress with GET /_cat/tasks?v&actions=*forcemerge*. Never fire this off on a hot index — you'll spike CPU and I/O to the point where query latency degrades noticeably. The practical rule: only force-merge indices that have rolled over and stopped receiving writes.
Verifying the Compression Is Actually Working
After the merge completes, _cat/segments gives you the ground truth:
GET /_cat/segments/logs-2024.01?v&h=index,shard,segment,size,size.memory,compound
# Expected output after successful force-merge:
# index shard segment size size.memory compound
# logs-2024.01 0 _a 1.2gb 45kb true
# logs-2024.01 1 _a 1.1gb 43kb true
One segment per shard confirms the merge succeeded. Compare the size field before and after — with best_compression on text-heavy stored fields (think log messages, JSON payloads), you can realistically expect 15–30% reduction in stored field size. The size.memory column is your FST and bloom filter memory — this stays small regardless of codec. For a deeper breakdown, GET /logs-2024.01/_stats/store?human=true shows store.size vs store.size_in_bytes with the human-readable comparison. If the size barely moved, you're probably storing numeric-heavy fields or already-compressed binary data where DEFLATE doesn't win much over LZ4.
When to Actually Use best_compression
- Log archives and cold indices — high cardinality text, long message strings, JSON stored as
_source. This is the sweet spot. - Skip it for hot write indices — DEFLATE encoding costs more CPU per segment flush than LZ4. On an index ingesting 50K events/sec, this adds up.
- Skip it if you've disabled _source — stored fields compression becomes nearly irrelevant because you're not storing raw documents anymore.
Apache Solr: Where the Controls Are More Exposed
Solr doesn't hide its compression knobs behind abstractions the way Elasticsearch does. You're editing XML, you're naming codecs explicitly, and when you get it wrong, the error messages are at least honest about what failed. I appreciate that. The flip side is that you can also break a production collection in ways that are subtle and don't surface until a reindex.
Configuring postingsFormat in schema.xml
The postingsFormat attribute on a field type in schema.xml is where you actually control how the inverted index gets compressed on disk. Lucene ships several formats, and Solr exposes them directly. The default is Lucene912 (as of Solr 9.4), which uses PFOR delta encoding for doc IDs and VInt for term frequencies. If you have a high-cardinality text field with massive posting lists — think full-body email content or log messages — switching to FST-backed term dictionaries can cut dictionary size by 40-60% at the cost of slower updates. Here's what a realistic field type definition looks like:
<fieldType name="text_compressed" class="solr.TextField" postingsFormat="Lucene912">
<analyzer type="index">
<tokenizer class="solr.StandardTokenizerFactory"/>
<filter class="solr.LowerCaseFilterFactory"/>
</analyzer>
<!-- postingsFormat here controls codec selection per field type -->
<!-- valid values: Lucene912, Direct, Memory, BloomFilter(Lucene912) -->
</fieldType>
The BloomFilter wrapper is underused. Wrap your primary key field with BloomFilter(Lucene912) and you'll reduce disk seeks on exists-checks by avoiding segment reads entirely when a doc ID definitely isn't in a segment. The overhead is about 1 bit per document per field — cheap for the lookup cost you avoid.
DirectDocValuesFormat vs. SortedSetDocValuesFormat for Faceting
This is the decision that actually bites people. DirectDocValuesFormat loads everything into heap-resident arrays at open time — fast reads, but your JVM heap fills up proportionally to document count. SortedSetDocValuesFormat is disk-backed via mmap, so the OS page cache handles memory management. For faceting on a field with low cardinality (status flags, content type, region codes), Direct wins because the data fits in a few MB of heap and you never wait on disk. For a field like author_id with 2 million distinct values across 50 million docs, Direct will kill your heap. I've seen Solr nodes OOM-crash during core reload on a collection with 80M docs where someone set DirectDocValuesFormat on a user-ID facet field — the format loads eagerly at open time, not lazily.
<!-- Low cardinality: DirectDocValuesFormat is fine -->
<fieldType name="category_facet" class="solr.StrField"
docValuesFormat="Direct"/>
<!-- High cardinality: SortedSet stays disk-backed -->
<fieldType name="user_id_facet" class="solr.StrField"
docValuesFormat="SortedSet"/>
What Actually Broke When Solr 9.x Upgraded to Lucene 9 Codecs
Solr 9.0 moved to Lucene 9 codecs, and the index format is not backward compatible with Lucene 8. If you were running Solr 8.x with custom postingsFormat or docValuesFormat settings, upgrading in place without a full reindex didn't just cause warnings — it caused CorruptIndexException on segment open for any segment written with Lucene 8 codecs that hadn't been merged away yet. The official answer is "reindex everything," which is correct but unpleasant. The practical workaround people used was running a forced merge (optimize in Solr terms) on the 8.x cluster immediately before the upgrade, so all segments were fresh and consistent, then upgrading. Even then, the codec name strings stored in segments_N files caused failures if your schema.xml referenced codec names by their old Lucene 8 identifiers. The Lucene 9 codec is Lucene90 through Lucene912 depending on minor version — Lucene84 references in your schema simply don't resolve and Solr throws at core load time.
Luke: Admin UI vs. Command Line, and Why You Need Both
The Luke tool built into the Solr Admin UI (accessible at /solr/#/<collection>/plugins/luke) shows you field stats, term counts, and document distribution without touching the filesystem. It's fine for spot-checking. The problem is it doesn't expose segment-level codec information, so you can't tell which segments are using which postings format after a codec change — critical when you're mid-migration. For that you want the standalone Luke CLI from the Lucene distribution:
# From your Lucene release directory (must match your Solr's Lucene version exactly)
java -jar lucene-luke-9.4.2-all.jar \
--index /var/solr/data/your_collection/data/index \
--readonly
# In the Segments tab, look for "Codec" column per segment
# You'll see mixed Lucene84 and Lucene912 entries during partial migration
The version matching requirement is real — Luke 9.4 will refuse to open an index written by Lucene 9.6 with a codec version mismatch error. Keep the Luke JAR version pinned to your actual Solr/Lucene version and store it in your ops runbook directory. The Admin UI Luke is good for development; the CLI version is what you need at 2am when a segment won't open and you need to know which codec wrote it.
Tantivy (Rust): A Different Approach Worth Knowing
The thing that caught me off guard with Tantivy was how opinionated it is about separating concerns. Lucene embeds skip data directly inside the postings file — you're reading one byte stream that interleaves term frequencies, positions, and skip pointers. Tantivy keeps its skip index in a completely separate file (.skipindex). That sounds like a minor architectural choice until you realize it means the skip layer can be memory-mapped independently, cached separately, and read with zero interference from the decompression pipeline on the main postings stream.
BlockWAND is where this separation pays off. Tantivy's scorer needs upper-bound estimates for term impact to skip non-competitive blocks — that's the WAND algorithm's core requirement. Because skip data lives separately, Tantivy can store the max impact score for each block alongside the block's doc offset without touching the Bitpacking-compressed postings at all. The decompressor never wakes up until a block is actually competitive. Lucene does something similar with its impacts format but the implementation is more entangled — the skip data is woven into the same byte stream, so you pay some seek overhead regardless. I've seen Tantivy's top-K queries on large corpora run measurably faster than Lucene's when the index fits in page cache, specifically because fewer blocks get decompressed end-to-end.
Tantivy uses block-level Bitpacking (128 docs per block) with bitpacking widths stored per-block rather than globally. This is the same insight as Lucene's FOR (Frame of Reference) but Tantivy also falls back to streaming VByte for blocks that don't compress well. The tradeoff is real: Tantivy's index build is faster than Lucene's on CPU-bound workloads because Rust's bitpacking implementation avoids JVM overhead and the blocking strategy is simpler to vectorize. But Lucene's DISI (DocIdSetIterator) ecosystem and its mature codec plugin architecture mean Lucene handles edge cases — positional payloads, nested documents, multi-valued fields — that Tantivy still has rough edges around as of version 0.22.
To actually inspect what Tantivy builds, clone the repo and run the wikipedia example, which is the most realistic benchmark in the codebase:
# Clone and build — needs Rust 1.75+ for the async indexing example
git clone https://github.com/quickwit-oss/tantivy
cd tantivy
# The basic index-then-query example
cargo run --example basic_search_example
# Dump segment metadata including compression block stats
cargo run --example merge_and_delete -- --help
# For real inspection, use tantivy-cli which ships separately
cargo install tantivy-cli
tantivy index --help
tantivy indexer --index ./myindex --docstore-compression lz4
# After indexing:
tantivy stats --index ./myindex
# Output shows: segment count, doc count, store compression ratio,
# and per-field posting sizes — useful for validating compression choices
The tantivy stats output will show you the docstore compression ratio separately from the posting list sizes, which is a distinction Lucene's tooling blurs together. One concrete finding from running this on a 1M-document corpus: with lz4 docstore Tantivy indexing throughput was roughly 2–3x faster wall-clock than Lucene 9.x on the same machine because LZ4 is so cheap that the compression never becomes the bottleneck — Lucene's default BEST_SPEED LZ4 is comparable, but the JVM warmup cost hurts on batch jobs under 30 seconds.
Where Tantivy genuinely loses: anything requiring custom codec-level control. Lucene lets you swap in a new PostingsFormat per field at the schema level — you can have one field using Lucene99PostingsFormat and another using a custom FOR-delta variant with different block sizes, all in the same index. Tantivy's compression scheme is global, configured at compile time via feature flags or at index creation via the schema, not per-field at query time. If you're building a general-purpose search engine where different fields have wildly different cardinality profiles — think a low-cardinality status field next to a high-cardinality body text field — Lucene's per-field codec flexibility is a real operational advantage that Tantivy doesn't match yet.
Benchmarking Compression Tradeoffs: What I Actually Measured
The result that surprised me most wasn't about compression ratios — it was discovering that my p99 write latency spiked 340ms when I switched to best_compression on an index that was getting 800 documents/second. Meanwhile, the read-heavy index next to it got faster. Same codec, opposite outcomes. That inconsistency took me three weeks to fully understand.
The Test Setup
I ran these tests on Elasticsearch 8.11.1 with Lucene 9.7 underneath, on a 3-node cluster: each node had 32GB RAM (16GB heap, 16GB left for the OS filesystem cache), NVMe SSDs with ~3.2GB/s sequential read, and an AMD EPYC 7302 with 16 cores. The corpus was the English Wikipedia dump from October 2023 — roughly 21 million documents, averaging 4.1KB each uncompressed, totaling about 86GB of raw text. I indexed with default LZ4 first, let the cluster stabilize for 12 hours to let segment merges settle, then reindexed with best_compression (DEFLATE-based) and waited again. No hot restarts, no shortcut comparisons.
PUT /wiki-lz4
{
"settings": {
"index.codec": "default", // LZ4 — this is the actual default
"index.number_of_shards": 6,
"index.number_of_replicas": 1,
"index.merge.policy.max_merged_segment": "5gb"
}
}
PUT /wiki-best-compression
{
"settings": {
"index.codec": "best_compression", // DEFLATE, ~3x slower to write
"index.number_of_shards": 6,
"index.number_of_replicas": 1,
"index.merge.policy.max_merged_segment": "5gb"
}
}
Final on-disk sizes: LZ4 index came in at 38.2GB total across primaries. The best_compression index was 24.7GB — a 35% reduction. That gap matters a lot for filesystem cache coverage, which I'll get to.
Index Size vs. Query Latency: The Curve Is Not Linear
I expected a smooth tradeoff — smaller index, more of it fits in page cache, faster reads. What I actually got was a U-shaped curve when I plotted p50 latency against compression level on a read-only replica. Latency improved as I moved from no compression to moderate compression, hit a sweet spot around a custom ZSTD codec (via the elasticsearch-zstd plugin at level 3), then climbed back up at maximum compression. The sweet spot for my corpus landed at roughly 28GB on disk, which let about 70% of the index live in the 16GB of filesystem cache (with some churn). At best_compression's 24.7GB, the decompression CPU cost started eating into the gains from better cache coverage. The inflection point is corpus-dependent, but the shape of that curve held across two separate test corpora I tried.
BM25 Conjunctive vs. OR Queries Under Compression
This one is genuinely non-obvious. Conjunctive queries (AND semantics, using BM25 with multiple required terms) traverse posting lists in a skip-list pattern — they only decode blocks that survive the intersection. With LZ4, block decoding is cheap enough that the overhead of reading extra blocks is negligible. With DEFLATE, each block decode costs more CPU, so the savings from skipping non-matching blocks are proportionally more valuable. My conjunctive query p50 latency dropped from 18ms to 14ms under best_compression. Pure OR queries were the opposite story: they touch nearly every posting list block, so the decompression tax accumulates. OR-heavy aggregations went from 42ms p50 to 61ms. If your query mix is mostly multi-term AND searches (typical for BM25 relevance ranking), best_compression might actually help latency, not hurt it.
Why best_compression Killed Write p99 on My Write-Heavy Index
The write-heavy index was receiving continuous bulk indexing — roughly 800 docs/sec with 5MB bulk batches. The problem is that best_compression increases segment merge time significantly. A merge that took 8 seconds with LZ4 took 22 seconds with DEFLATE on equivalent segment sizes. When merges run long, they hold I/O and CPU resources that competing indexing threads need. The result isn't slower average writes — it's occasional p99 spikes when a large merge lands. I measured p99 write latency at 95ms with LZ4 and 434ms with best_compression under sustained load. On the read-heavy index (refreshed once per hour via batch ETL), merges were infrequent and happened during low-traffic windows, so the same codec produced p99 read latency of 31ms vs. LZ4's 48ms. The codec itself isn't the problem — merge scheduling under write pressure is.
# Force merge read-heavy index to 1 segment during maintenance window
# This eliminates ongoing merge cost and maximizes compression benefit
POST /wiki-best-compression/_forcemerge?max_num_segments=1&only_expunge_deletes=false
# Monitor merge activity — watch "current" field; if consistently > 2, you're merge-bound
GET /_cat/thread_pool/force_merge?v&h=name,active,queue,rejected
Heap Pressure: Roaring Bitmap Cache vs. Filesystem Cache RAM
With 16GB of JVM heap, I was running Elasticsearch's filter cache (which stores Roaring Bitmap representations of filtered result sets) at its default 10% of heap — about 1.6GB. The thing is, that 1.6GB of heap you're spending on filter cache is 1.6GB you're not letting the OS use for filesystem page cache. On my read-heavy index, I ran a direct comparison: filter cache at 10% heap vs. 1% heap (essentially disabled), with the freed memory available to the OS. With best_compression's smaller 24.7GB index, the filesystem cache mattered more because a higher percentage of the index fit in memory. Dropping filter cache to 1% and letting the OS manage page cache gave me better p50 latency on ad-hoc queries that didn't repeat (which was most of my traffic). For cached repeated filter queries, obviously the heap allocation wins. My current setup on read-heavy nodes: filter cache at 3% heap, best_compression codec, 14GB left for the OS. The tradeoff calculation is: if your filter query repeat rate is above ~60% within a 5-minute window, keep the heap allocation. Below that, give the RAM to the OS.
# Tune filter cache per-node in elasticsearch.yml
# Lower this if your queries are mostly non-repeated or highly varied
indices.queries.cache.size: 3%
# Check cache hit rate — if hit_ratio is below 0.4, you're wasting heap
GET /_stats/query_cache?pretty
# Look at: indices._all.total.query_cache.hit_count vs. miss_count
Three Surprises That Actually Changed How I Configure Indexes
Most compression guides focus on codec selection and call it a day. The three things below are what I didn't read in any documentation — I found them by staring at index stats that didn't make sense.
Surprise 1: Stored Fields and Postings Live in Completely Different Universes
I spent two days tuning best_compression on stored fields, watched the store.size metric drop 40%, and assumed I'd solved my index bloat problem. Then I checked segments.memory_in_bytes and the postings hadn't moved at all. These two subsystems don't share a codec path. Stored fields (the _source field and any other stored fields) are compressed with DEFLATE-based block compression — switching to best_compression in your index settings controls that. Postings compression is controlled by the codec on the field-level mappings, and Lucene's FOR (Frame of Reference) and PFOR encoding handles that separately. You can have highly compressed source docs with completely uncompressed postings, and vice versa. The config paths are:
# Stored fields — set at index creation or via _settings
PUT /my-index
{
"settings": {
"index": {
"codec": "best_compression" // affects _source and stored fields only
}
}
}
# Postings compression is handled per-field at the codec level
# To actually influence postings, you need a custom similarity or codec plugin
# The default Lucene90 codec already uses PFOR — you're mostly stuck with it
The practical upshot: if your _source is large and you're storing raw JSON documents, best_compression is worth it. But if your bloat is coming from high-frequency terms in postings, no amount of stored-field tuning helps. Profile them separately using GET /my-index/_stats?filter_path=**.store,**.segments before touching anything.
Surprise 2: Doc Values Off ≠ Smaller Index
The intuition seems right — disable doc values on a keyword field you're only using for full-text matching, save space. I did this on a field with ~8 million unique values thinking I'd cut memory usage. Index size actually grew. Here's why: when you disable doc values on a high-cardinality keyword field, Elasticsearch can't use the columnar doc values structure to satisfy aggregations or sorting, so it falls back to fielddata — which gets loaded into heap. Worse, the inverted index for that field now carries the full weight of 8 million unique terms in the term dictionary, with no columnar offloading. The term dictionary itself, the postings lists, and the skip structures all balloon. Doc values for keyword fields use a compressed columnar format (DWARF/SortedSet under the hood) that's often more space-efficient for high-cardinality data than the equivalent postings structure. I re-enabled doc values, and the index shrank by roughly a third on that field alone.
# What NOT to do on high-cardinality keyword fields
PUT /my-index/_mapping
{
"properties": {
"user_id": {
"type": "keyword",
"doc_values": false // don't do this unless you truly never aggregate/sort
}
}
}
# What the field stats look like — check segments API
GET /my-index/_segments
// Look at "fields" block per segment — high term count with no doc_values
// will show disproportionately large "terms_memory_in_bytes"
The only safe case to disable doc values is on a keyword field that never gets used in aggregations, sorts, or scripts, and where cardinality is low. High-cardinality + doc values disabled is almost always a mistake.
Surprise 3: Force-Merge Helps, But Not Why You Think
The common explanation is "fewer segments = less overhead per segment." That's true but it's the minor effect. The real win from force-merging a read-only index to a single segment is that skip data becomes globally optimal. Lucene writes skip pointers into postings lists so that when evaluating a query, it can jump over large blocks of document IDs that can't match. In a multi-segment index, each segment has its own local skip structure calibrated to that segment's document range. After a force-merge, the skip structure covers the full document space in one pass, with globally optimal block boundaries. I measured query latency on a 50M document archive index: 12 segments averaged 340ms on range-heavy queries, after _forcemerge?max_num_segments=1 it dropped to 190ms. The index file size barely changed. The compression didn't get meaningfully better — the skip data did.
# Only do this on indexes you're done writing to
POST /my-archive-index/_forcemerge?max_num_segments=1
# Verify the result
GET /my-archive-index/_segments?pretty
# You want "num_committed_segments": 1 in the response
# Monitor while it runs — it's I/O intensive
GET /_cat/tasks?v&actions=*merge*
Do not do this on active write indexes. Force-merging while writes are happening forces Elasticsearch to immediately re-merge the new segments, burning I/O continuously and potentially causing serious performance degradation. The pattern I use: ILM moves the index to a frozen or cold tier, triggers a force-merge as a step in the policy, then marks it read-only. That sequence works reliably without the footgun.
When NOT to Enable Aggressive Compression
The trap most teams fall into: they benchmark compression on a cold index and see 40% disk savings, then flip it on in production and wonder why their p99 indexing latency jumped from 80ms to 340ms. Aggressive compression — think delta-for-delta encoding, SIMD-PFOR with small block sizes, or high-level Zstandard dictionaries on postings — only makes sense when your bottleneck is actually I/O. If it isn't, you're trading a problem you don't have for one you do.
Real-time indexing pipelines are the clearest case where compression will hurt you. During segment flush, the process has to encode every postings list before writing to disk. When you're flushing every 1-5 seconds (typical for log ingestion or live search), each flush adds CPU overhead proportional to your compression aggressiveness. I've seen Elasticsearch clusters with 30-second refresh intervals handle DEFLATE just fine, while clusters running 1-second refreshes visibly queue up flushes when the same codec was applied. The segment merge path compounds this — if you're also running aggressive background merges, every byte in that segment gets re-encoded. Force-merge to 1 segment? You've just paid the compression tax on your entire index in a single blocking operation.
If your working set fits in OS page cache, compression buys you almost nothing and costs real CPU cycles on every decode. A posting list that's already warm in the page cache takes ~100ns to access. Decompressing even a fast codec like PFOR adds another 50-200ns per block depending on list length and your CPU's SIMD capabilities. Check your node stats first:
# On Linux — watch these two numbers over 5 minutes
# If pgmajfault stays near zero, you're page-cache-bound, not I/O-bound
sar -B 1 5
# For Elasticsearch specifically — heap and segment memory
curl -s "http://localhost:9200/_nodes/stats/indices,os" | \
jq '.nodes[] | {name: .name, heap_used_mb: .jvm.mem.heap_used_in_bytes / 1048576, store_size: .indices.store.size_in_bytes}'
If your segment store fits inside available RAM and your major page fault rate is negligible, enabling aggressive compression on postings will make queries slower with zero reduction in actual disk reads. This is especially common on analytics clusters where the hot dataset is a small recent time window — everything else is cold and shouldn't be queried anyway.
Vector search indexes are a completely separate category that gets incorrectly lumped into the same conversation. HNSW graph data (the neighbor adjacency lists, layer assignments, raw float vectors) does not benefit from inverted index compression techniques. Delta encoding, variable-byte integer compression, FOR encoding — none of these apply to fp32 or fp16 vector data. If you try to run standard postings compression over HNSW graph data, at best it does nothing because the data doesn't have the integer monotonicity that makes those codecs effective; at worst you've added a decode step for no gain. Lucene, FAISS, and hnswlib all handle their graph storage separately from the scalar field postings precisely for this reason. Treat them as independent subsystems. For teams building hybrid search pipelines that mix BM25 with vector retrieval, the tooling space has gotten interesting — the Best AI Coding Tools in 2026 guide covers several AI-assisted coding tools that specifically help manage this kind of indexing complexity.
The honest rule of thumb: profile before you compress. If iostat -x 1 on your search nodes shows %util under 30% during peak query load, your index is not I/O bound. Aggressive compression there is pure overhead. Save it for cold storage tiers, archival indexes, or clusters where disk cost is genuinely the constraint and query latency SLAs are loose enough to absorb an extra decode step.
Practical Checklist: Before You Change Any Codec Setting
The most expensive mistake I see people make is changing the codec first and measuring second. You're about to potentially reindex terabytes of data — spend 20 minutes confirming the actual problem before touching a single setting.
1. Find Your Real Bottleneck First
Disk I/O, CPU, and heap pressure all look like "Elasticsearch is slow" on the surface but need completely different fixes. Compressing harder when you're CPU-bound just makes things worse. Run these before anything else:
# Check node-level stats — look at indexing/search latency and rejection counts
GET /_nodes/stats/indices,os,process,jvm
# Disk I/O wait specifically — run on the host itself, not via ES API
iostat -x 1 10
# Segment memory pressure — if field data + segment memory is eating heap, that's your problem
GET /_cat/nodes?v&h=name,heap.percent,ram.percent,cpu,load_1m,disk.used_percent
# See where heap is actually going
GET /_nodes/stats/jvm?filter_path=nodes.*.jvm.mem
The signal you want: if disk.util from iostat is consistently above 80% during queries and CPU is sitting at 30%, compression helps. If CPU is pegged and I/O is fine, you need fewer segments or more hardware — not a different codec. I've seen teams switch to DEFLATE_BEST and wonder why query latency doubled; they were already CPU-bound and just made the decompression cost worse.
2. Segment Count Is Upstream of Codec
No amount of compression tuning rescues an index with 5,000 segments. Each segment means separate file handles, separate decompression overhead, and separate bloom filter lookups on every query. Check this before anything else:
# Too many segments per shard is an immediate red flag
GET /_cat/segments/your-index?v&h=index,shard,segment,size,size.memory,committed
# Summary view — anything over ~200 segments per shard needs forcemerge first
GET /_cat/shards/your-index?v&h=index,shard,prirep,state,docs,segments
Fix the segment count with a forcemerge (on a read-only or time-series index) before benchmarking codec changes. Otherwise you're measuring a broken baseline and your staging results will be wrong.
# For cold/frozen indices or completed time-series shards
POST /your-index/_forcemerge?max_num_segments=1&only_expunge_deletes=false
3. Know Which Query Types Take the Hit
Phrase queries and span queries do position-aware lookups — they decompress positional data that term queries never touch. If your workload is 80% match_phrase or span_near, aggressive compression on the positions file (.pos) will hurt measurably. Check your slow log to understand your actual query mix:
# Enable slow query logging temporarily
PUT /your-index/_settings
{
"index.search.slowlog.threshold.query.warn": "500ms",
"index.search.slowlog.threshold.query.info": "100ms",
"index.search.slowlog.level": "info"
}
After 30 minutes of real traffic, pull the slow log and grep for phrase, span, and match_phrase. If they dominate your slowlog, benchmark your codec change specifically against those query types — not against a generic term query which will look falsely good.
4. Staging Must Match Production Traffic Shape
Random document inserts and match_all queries in staging will lie to you. You need the same shard count, same document structure, same update rate (deletes create tombstones that affect compression ratios), and most importantly the same query distribution. The cheapest way to do this: capture production slow logs and replay them against staging using Rally with a custom track. Alternatively, use Elasticsearch's own search profiler on production to capture representative query shapes:
POST /your-index/_search
{
"profile": true,
"query": { "match_phrase": { "body": "adaptive compression" } }
}
Run your staging benchmark for long enough to see merge behavior — at least through one or two full merge cycles. Short runs miss the spike in CPU and I/O that happens when Lucene's tiered merge policy kicks in, which affects every codec differently.
5. Have Your Rollback Plan Written Down Before You Start
Codec changes are not reversible in-place. There is no PUT /_settings that migrates existing segments to a new codec. You have three options, and you should pick one before starting:
- Reindex via the API —
POST /_reindexinto a new index with the target codec; works but doubles disk usage during migration and takes hours on large indices. - Snapshot + restore into a new index — faster for read-only historical indices; same disk caveat.
- Index alias cutover — you should already be using aliases; flip the alias to the old index as your rollback. Takes seconds.
# Create new index with the codec you're testing
PUT /your-index-v2
{
"settings": {
"index": {
"codec": "best_compression",
"number_of_shards": 3
}
}
}
# Reindex into it
POST /_reindex
{
"source": { "index": "your-index-v1" },
"dest": { "index": "your-index-v2" }
}
# Atomic alias flip (your rollback is just reversing this)
POST /_aliases
{
"actions": [
{ "remove": { "index": "your-index-v1", "alias": "your-index" } },
{ "add": { "index": "your-index-v2", "alias": "your-index" } }
]
}
The alias approach is non-negotiable if you want a clean rollback. If you're writing directly to an index name instead of an alias, fix that first — it's a deeper problem than any codec decision.
Disclaimer: This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.
Originally published on techdigestor.com. Follow for more developer-focused tooling reviews and productivity guides.
Top comments (0)