DocValues: The Columnar Store
The inverted index answers "which docs contain term X." The exact opposite
question — "given doc D, what is the value of field F" — is what sorting,
aggregating, and scripting need, and the inverted index is terrible at it.
Lucene's answer is doc values: a columnar, per-document, on-disk store,
written into each segment alongside the postings. This is the structure behind
every OpenSearch sort, every terms/stats/cardinality aggregation, and every
doc['field'].value script access.
This chapter is the Lucene-level companion to OpenSearch's
DocValues and Fielddata deep dive. That
chapter covered the OpenSearch view — the IndexFieldData abstraction, the
fielddata circuit breaker, global ordinals, why text is special. Here we go a
layer down: the Lucene DocValuesType flavors, the DocValuesFormat that encodes
them, the per-doc iterator APIs, and how ordinals work at the format level. We do
not re-derive the OpenSearch side — read that chapter for it; this one is the
storage format underneath.
After this chapter you can: name the five DocValuesType flavors and the Lucene
API for each; explain the columnar encoding and why it's disk/page-cache friendly;
describe how SORTED/SORTED_SET store ordinals + a sorted term dictionary; explain
global ordinals at the format level; and trace how OpenSearch reads a keyword
field's values for an aggregation down to the Lucene accessor.
Row store vs column store
Stored fields (.fdt, the _source) are a row store: doc 5's whole document
sits together, great for "fetch doc 5," terrible for "read field F across a million
docs" (you'd touch a million scattered rows). Doc values are a column store:
all values of field F sit together, contiguous, so "read F across all docs" is a
sequential scan of one tight array — cache-friendly, off-heap, OS-page-cached.
| Stored fields (row) | DocValues (column) | |
|---|---|---|
| Layout | per-doc, all fields together | per-field, all docs together |
| Good for | fetching a hit's _source | sort / aggregate / script over a field |
| Lives | .fdt/.fdx/.fdm, compressed | .dvd/.dvm, off-heap / page cache |
| Access | "give me doc 5" | "give me field F for every doc" |
grep -rn "enum DocValuesType\|class DocValuesFormat" \
lucene/core/src/java/org/apache/lucene/index/DocValuesType.java \
lucene/core/src/java/org/apache/lucene/codecs/DocValuesFormat.java
The five DocValuesType flavors
Lucene stores doc values as one of five column types, chosen at index time per field. Each has a matching read API (an iterator that advances doc-by-doc).
DocValuesType | Holds per doc | Read API | OpenSearch field types |
|---|---|---|---|
NUMERIC | one long | NumericDocValues | long, integer, double (encoded), date, boolean |
SORTED_NUMERIC | many longs, sorted | SortedNumericDocValues | multi-valued numerics |
BINARY | one byte[] | BinaryDocValues | geo_point (encoded), custom binary |
SORTED | one term ordinal | SortedDocValues | single-valued keyword-like |
SORTED_SET | a set of term ordinals | SortedSetDocValues | multi-valued keyword, ip |
The read APIs are all DocValuesIterators — they extend DocIdSetIterator, so
you advance(docID) to a doc and then read its value. This iterator model
(introduced when doc values became sparse-aware) means a field that's absent on
many docs costs nothing for those docs.
// NUMERIC: read a long per doc
NumericDocValues nd = leaf.getNumericDocValues("price");
if (nd.advanceExact(docID)) {
long v = nd.longValue();
}
// SORTED_SET: read ordinals, resolve to bytes only when needed
SortedSetDocValues ss = leaf.getSortedSetDocValues("tags.keyword");
if (ss.advanceExact(docID)) {
long ord;
while ((ord = ss.nextOrd()) != SortedSetDocValues.NO_MORE_ORDS) {
// bucket by ord (cheap); resolve text only to emit:
BytesRef term = ss.lookupOrd(ord);
}
}
grep -rn "class NumericDocValues\|class SortedDocValues\|class SortedSetDocValues\|class SortedNumericDocValues\|class BinaryDocValues" \
lucene/core/src/java/org/apache/lucene/index/
grep -rn "advanceExact\|nextOrd\|lookupOrd\|getValueCount" \
lucene/core/src/java/org/apache/lucene/index/SortedSetDocValues.java
Ordinals: the key trick for strings
SORTED/SORTED_SET do not store the strings per doc. They store, per doc, a
small integer ordinal into a per-segment sorted dictionary of the field's
distinct terms. So a keyword field with a million docs but only 50 distinct
values stores a million tiny ordinals + a 50-entry dictionary, not a million
strings.
flowchart LR
Dict["Per-segment sorted dict:<br/>0=apple 1=mango 2=pear"]
Doc0["doc0 -> ord 2"] --> Dict
Doc1["doc1 -> ord 0"] --> Dict
Doc2["doc2 -> ord {0,2}"] --> Dict
Dict -->|lookupOrd| Bytes["BytesRef('pear')"]
This is why string aggregations are fast: a terms agg buckets by ordinal
(integer compares, integer hash keys), and only calls lookupOrd to turn the
winning ordinals back into strings at the very end. Ordinals are also already
sorted (the dictionary is sorted), so a sort on a keyword field is a sort on
ordinals — no string comparison until the final fetch.
| Property | Why it matters |
|---|---|
| Per-doc value = ordinal (small int) | tiny storage, cache-friendly |
| Dictionary is sorted | ordinal order == term order, so sort is free |
lookupOrd deferred | resolve to bytes only for emitted buckets/hits |
getValueCount() | number of distinct ords in the segment |
Note: Ordinals are per-segment. Ordinal 2 in segment A and ordinal 2 in segment B may be different terms. To aggregate across a whole shard you need a shard-wide numbering — global ordinals, below.
The DocValuesFormat and the .dvd/.dvm files
The on-disk encoding is defined by the codec's DocValuesFormat sub-format (see
Segments and Codecs). It writes two files:
| File | Holds |
|---|---|
.dvd | the doc-values data: the packed columns, the ordinal arrays, the term dictionaries |
.dvm | metadata: per-field offsets, value counts, min/max, encoding parameters |
The default format is aggressively compact. Numerics are stored with techniques chosen from the data: delta/GCD encoding (store differences or a common factor), table/ordinal encoding when few distinct values, and bit-packing to the minimum bits per value. Sorted dictionaries are prefix-compressed. None of this is loaded onto the JVM heap — it's mmap'd and served from the OS page cache, which is exactly why doc values do not count against the fielddata circuit breaker (the OpenSearch deep dive's point, now with the format reason underneath).
ls lucene/core/src/java/org/apache/lucene/codecs/lucene*/ # *DocValuesFormat
grep -rln "DocValuesFormat" lucene/core/src/java/org/apache/lucene/codecs/
grep -rn "class .*DocValuesConsumer\|class .*DocValuesProducer" \
lucene/core/src/java/org/apache/lucene/codecs/lucene*/ | head
The format splits into a consumer (write side, DocValuesConsumer) and a
producer (read side, DocValuesProducer) — the same writer/reader split you've
now seen for postings (BlockTree) and points (BKD). It's the consistent shape of
every Lucene format.
Global ordinals at the format level
A terms agg needs one ordinal space for the whole shard, but each segment has
its own. Lucene builds an OrdinalMap: it merges the per-segment sorted
dictionaries into one shard-global sorted dictionary and produces, per segment, a
mapping localOrd → globalOrd. Now an aggregator can bucket by global ordinal
across all segments and resolve to text once at the end.
flowchart LR
SA["Seg A: 0=apple 1=pear"] --> OM[OrdinalMap]
SB["Seg B: 0=pear 1=plum"] --> OM
OM --> G["Global: 0=apple 1=pear 2=plum"]
OM --> MapA["A: local 0->0, 1->1"]
OM --> MapB["B: local 0->1, 1->2"]
grep -rn "class OrdinalMap\|build(" \
lucene/core/src/java/org/apache/lucene/index/OrdinalMap.java
grep -rn "MultiDocValues\|getSortedSetValues\|OrdinalMap" \
lucene/core/src/java/org/apache/lucene/index/MultiDocValues.java
This OrdinalMap is precisely what the OpenSearch
docvalues/fielddata chapter calls "global
ordinals" — built lazily on first agg, cached per shard, invalidated when segments
change (a refresh). eager_global_ordinals: true just moves the OrdinalMap build
to refresh time. The Lucene class is the implementation; the OpenSearch chapter is
the operational behavior. Read both.
How OpenSearch enables and reads doc values
By default OpenSearch sets doc_values: true on keyword, numeric, date,
boolean, ip, and geo_point fields — and false on text (a token stream
has no single value). The field mapper declares the DocValuesType, and at query
time OpenSearch's IndexFieldData reads the matching Lucene accessor.
# Where field types declare doc values:
grep -rn "hasDocValues\|DocValuesType\|SortedSetDocValuesField\|SortedNumericDocValuesField\|NumericDocValuesField" \
server/src/main/java/org/opensearch/index/mapper/KeywordFieldMapper.java \
server/src/main/java/org/opensearch/index/mapper/NumberFieldMapper.java
# The bridge from OpenSearch fielddata to Lucene doc-values accessors:
grep -rn "getSortedSetDocValues\|getNumericDocValues\|SortedSetDocValues\|LeafFieldData" \
server/src/main/java/org/opensearch/index/fielddata/ | head
The chain for a keyword terms agg, end to end:
flowchart TD
Agg["terms agg on tags.keyword"] --> IFD["IndexFieldData (OpenSearch)"]
IFD --> LFD["LeafFieldData per segment"]
LFD --> SSDV["SortedSetDocValues (Lucene)"]
SSDV --> Ord["nextOrd() -> local ordinals"]
Ord --> GO["OrdinalMap: local -> global ordinal"]
GO --> Bucket["bucket by global ord; lookupOrd at the end"]
That's the same picture the OpenSearch deep dive draws as MappedFieldType → IndexFieldData → LeafFieldData → Lucene accessor — now you can name the Lucene
end of it: SortedSetDocValues + OrdinalMap, encoded in .dvd/.dvm by the
DocValuesFormat.
Reading exercise
# 1. The five types and their accessors.
grep -rn "enum DocValuesType" lucene/core/src/java/org/apache/lucene/index/DocValuesType.java
grep -rn "class SortedSetDocValues\|class NumericDocValues\|class SortedNumericDocValues" \
lucene/core/src/java/org/apache/lucene/index/
# 2. Ordinals.
grep -rn "lookupOrd\|nextOrd\|getValueCount\|ordValue" \
lucene/core/src/java/org/apache/lucene/index/SortedSetDocValues.java \
lucene/core/src/java/org/apache/lucene/index/SortedDocValues.java
# 3. The format: consumer/producer and files.
ls lucene/core/src/java/org/apache/lucene/codecs/lucene*/
grep -rn "DocValuesConsumer\|DocValuesProducer" lucene/core/src/java/org/apache/lucene/codecs/ | head
# 4. Global ordinals (OrdinalMap).
grep -rn "class OrdinalMap\|OrdinalMap.build" lucene/core/src/java/org/apache/lucene/index/OrdinalMap.java
# 5. OpenSearch side (cross-link to docvalues-fielddata.md).
grep -rn "SortedSetDocValuesField\|NumericDocValuesField\|hasDocValues" \
server/src/main/java/org/opensearch/index/mapper/KeywordFieldMapper.java
# 6. The files on disk.
find /path/to/shard/index -name "*.dvd" -o -name "*.dvm"
Answer:
- Contrast a row store (stored fields) and a column store (doc values) and say which access pattern each is built for.
- Name the five
DocValuesTypeflavors, the read API for each, and an OpenSearch field type that uses each. - Explain ordinals: what is stored per doc for a
keyword, why aggregations bucket on ordinals, and whenlookupOrdis called. - Why do doc values not count against the fielddata circuit breaker? Tie it to
where the
.dvdbytes live. - Explain
OrdinalMap(global ordinals) at the format level and how it relates toeager_global_ordinalsin OpenSearch. - Trace a
keywordtermsagg from the OpenSearchIndexFieldDatato the LuceneSortedSetDocValuesandOrdinalMap.
Common bugs and symptoms
| Symptom | Root cause | Where to look |
|---|---|---|
Fielddata is disabled on text fields | sorting/aggregating text, which has no doc values | use a keyword multi-field; docvalues-fielddata.md |
| Sort on a field errors / returns garbage | doc_values: false set on the mapping | re-enable doc values; reindex |
| First agg after refresh slow, later fast | OrdinalMap (global ordinals) rebuilt lazily after segment change | eager_global_ordinals; docvalues-fielddata.md |
High-cardinality keyword agg heavy | huge global-ordinal dictionary per shard | reconsider composite/cardinality; eager ords |
Disk size dominated by .dvd | many high-cardinality doc-values fields | drop doc_values on fields never sorted/aggregated |
Script doc['f'].value throws | field has no doc values | enable doc values or use _source access |
Validation: prove you understand this
- Draw the column layout for one
keywordfield across three docs, showing the sorted dictionary and the per-doc ordinals. - Explain why ordinals make both sorting and
termsaggregation fast, and wherelookupOrdenters. - Name the five
DocValuesTypes and give the Lucene accessor and an OpenSearch field type for each. - Explain the
DocValuesFormatconsumer/producer split and the.dvd/.dvmfiles, and one numeric encoding technique it uses. - Define
OrdinalMapand connect it precisely to the OpenSearch notion of global ordinals andeager_global_ordinals. - Trace, naming every layer, how a
termsagg ontags.keywordreads values from OpenSearch down to the LuceneSortedSetDocValuesin a.dvd.