Architecture overview
LeanCorpus is a segment-centric search engine. Indexing writes immutable segments, commits publish a coherent set of segments, and searchers open one committed view through memory-mapped I/O.
flowchart TD
subgraph Writer
A[Application threads] --> P[DocumentsWriterPerThread pool]
P --> D1[DWPT 1]
P --> D2[DWPT 2]
P --> DN[DWPT N]
end
subgraph Flush
D1 -. RAM or document limit .-> S1[seg_0 files]
D2 -. RAM or document limit .-> S2[seg_1 files]
DN -. RAM or document limit .-> S3[seg_2 files]
end
S1 --> C[segments_N commit]
S2 --> C
S3 --> C
C --> M[SearcherManager]
M --> R[IndexSearcher lease]
S1 -. source .-> G[Background merge]
S2 -. source .-> G
G -. replacement .-> S4[seg_3 files]
S4 --> NC[Next commit]
Core invariants
- Segment contents do not change after publication.
- A commit manifest names only complete files.
- A searcher sees one commit generation, never a mixture.
- Deletions hide documents immediately after commit and are reclaimed physically by merges.
- Segment files remain available while a searcher, snapshot, or retained commit can still reference them.
- Readers validate format and bounds before trusting file contents.
Indexing pipeline
Each producer acquires a documents-writer-per-thread buffer. Analysis, postings accumulation, stored fields, DocValues, numeric points, and vectors are collected in private state. This reduces lock contention between indexing threads.
A buffer flushes when a RAM, per-thread, or document threshold is reached. Flush writes a complete segment. Concurrent flush and merge limits keep memory and storage pressure bounded.
The writer has two different visibility boundaries:
- a flush creates files but does not make them visible to ordinary committed readers;
- a commit publishes a new
segments_Ngeneration containing the selected segments and deletion state.
Commit lifecycle
sequenceDiagram
participant App
participant Writer as IndexWriter
participant Store as MMapDirectory and filesystem
participant Manager as SearcherManager
App->>Writer: Commit()
Writer->>Writer: Drain accepted indexing work
Writer->>Store: Flush segment and deletion files
Writer->>Store: Write statistics sidecar
opt DurableCommits
Writer->>Store: fsync files
Writer->>Store: fsync directory metadata
end
Writer->>Store: Atomically publish segments_N
Writer-->>App: Commit complete
Manager->>Store: Detect newer generation
Manager->>Manager: Open and validate replacement searcher
Manager->>Manager: Atomically swap current searcher
The manifest is published last. If a process stops during an earlier step, recovery can ignore incomplete files and retain the previous complete generation.
Background merging
Merge policy chooses compatible source segments. The merge scheduler writes a new immutable segment while indexing and commits continue. A later commit replaces the source segments with the merged output.
Source files are not immediately deletable. Active searchers, snapshots, or retained commits may still own them. This is why on-disk size can temporarily exceed the current commit's logical size.
Merges also:
- apply deletions physically;
- rebuild term dictionaries and postings;
- remap DocValues, vectors, HNSW nodes, parent markers, and sort metadata;
- preserve codec and scoring invariants;
- improve query locality by reducing segment count.
Segment files
A segment uses a common identifier such as seg_0 across its files. Required and optional files include:
| File | Contents |
|---|---|
.seg |
Segment metadata, fields, document count, sort, vectors, and deletion generation |
.dic |
FST term dictionary mapping field\0term to postings metadata |
.pos |
Block-packed document IDs, frequencies, positions, offsets, and optional payloads |
.fdt, .fdx |
Stored-field block data and random-access block index |
.nrm, .fln |
Norms, field boosts, and exact field-length data |
.num, .numl |
Sparse numeric and 64-bit integer field indexes |
.bkd, .bkdl |
BKD trees for double and 64-bit integer range search |
.dvn, .dvnl |
Numeric and 64-bit integer DocValues |
.dvs, .dss |
Sorted and sorted-set string DocValues |
.dsn, .dsnl |
Sorted-numeric DocValues |
.dvb |
Binary DocValues |
.vec, .vq, .hnsw |
Exact vectors, quantised vectors, and HNSW graph |
.tvd, .tvx |
Term-vector data and index |
.pbs |
Parent markers for block join |
.del |
Roaring bitmap of deleted documents |
segments_N |
Commit manifest and commit checksum |
stats_N.json |
Recoverable collection-statistics sidecar |
write.lock |
Single-writer ownership |
Formats do not all share one universal magic-header and CRC layout. Versioned CodecKit envelopes, streaming headers, unframed metadata, and the commit checksum are distinct. See Storage formats for the byte-level design.
Search execution
flowchart LR
Q[Query] --> W[Rewrite and fingerprint]
W --> L{Structure}
L -->|Terms or patterns| F[FST term lookup]
L -->|Numeric points| B[BKD traversal]
L -->|Vector| H[HNSW or exact scan]
F --> P[Postings decode]
B --> X[Matching document IDs]
H --> X
P --> S[Similarity or constant scoring]
X --> S
S --> T[Top-N collector]
T --> D[TopDocs]
Term queries seek through the FST and decode postings. Numeric ranges prune BKD cells. Vector queries use HNSW when available and exact flat search otherwise. Compound queries combine these primitives.
BM25 uses collection-wide statistics so scores remain comparable across segments. Optional Block-Max WAND can skip postings blocks whose score upper bounds cannot enter the current top-N.
Internal data structures
| Structure | Role |
|---|---|
| FST | Compact term dictionary and automaton traversal |
| Packed integers | Block encoding for postings and related integer streams |
| BKD tree | Recursive numeric-space partitioning |
| HNSW | Approximate nearest-neighbour candidate graph |
| Roaring bitmap | Sparse and dense document-ID sets for deletions and filters |
| DocValues | Column-oriented values for sorting, faceting, collapsing, and aggregation |
The search internals page describes their algorithms and contributor invariants.
I/O model
MMapDirectory maps immutable files and lets the operating-system page cache manage the working set.
- Warm reads are served from resident pages.
- Cold reads fault pages on demand.
- Searchers over the same files can share physical pages.
- Managed allocation and resident mapped memory are different measurements.
IndexInput provides bounded seeking and slices. IndexOutput provides sequential writing. Atomic file publication, directory fsync, and transient open retries stay behind the Store boundary.
See Store and file I/O.
Searcher lifecycle
SearcherManager opens the latest commit and polls for newer generations. It opens a complete replacement before swapping it into service.
flowchart TD
T[Refresh timer or MaybeRefresh] --> G[Read latest generation]
G --> N{Newer than current?}
N -->|No| W[Keep current searcher]
N -->|Yes| O[Open and validate replacement]
O --> V{Open succeeded?}
V -->|No| F[Record failure and keep current]
V -->|Yes| S[Swap current searcher]
S --> L[Old searcher waits for leases]
L --> D[Dispose readers and mappings]
Callers use AcquireLease() or UsingSearcher. A lease prevents the selected searcher from being disposed while a query is in flight.
Segment readers open heavier structures lazily and retain them in a bounded cache. Query leases protect active structures from eviction during an execution loop.
Deletions and soft deletions
Deletes are resolved to per-segment document IDs and published with a commit. Search paths check live-document state before collecting a hit. A later merge omits deleted documents.
Soft deletion adds retention metadata. Soft-deleted documents are not searchable, but merge reclamation waits until SoftDeleteRetentionSeconds has elapsed.
Index sorting
IndexSort physically orders documents within newly flushed and merged segments. Matching sorts can terminate early after enough competitive documents. Grouping similar values can also improve DocValues compression.
Index sorting changes document-ID assignment and write cost. Configure it before building the corpus when applications rely on consistent behaviour across every segment.