Architecture overview
LeanCorpus is a segment-centric full-text search engine. Documents are indexed into immutable segments, each a self-contained set of codec files on disk. A commit atomically publishes a set of segments via a segments_N manifest. Readers open the committed segments via memory-mapped I/O.
graph TD
subgraph Writer
DWPT1[DWPT 1]
DWPT2[DWPT 2]
DWPT3[DWPT N]
end
subgraph Flush
DWPT1 -. flush .-> SEG1[seg_0.*]
DWPT2 -. flush .-> SEG2[seg_1.*]
DWPT3 -. flush .-> SEG3[seg_2.*]
end
subgraph Commit
SEG1 --> MANIFEST[segments_N]
SEG2 --> MANIFEST
SEG3 --> MANIFEST
end
subgraph Reader
MANIFEST --> SM[SearcherManager]
SM --> IS1[IndexSearcher]
SM --> IS2[IndexSearcher]
end
Merge[Background Merge] -. reads .-> SEG1
Merge -. reads .-> SEG2
Merge -. writes .-> MERGED[seg_3.*]
MERGED -. new commit .-> MANIFEST
Indexing pipeline
Documents flow into a
DocumentsWriterPerThreadpool. Each thread gets its own DWPT with private in-memory buffers. No lock contention during document analysis.DWPTs flush independently. When a DWPT's RAM buffer fills (controlled by
RamBufferSizeMB), it writes a complete segment to disk. Flushes happen outside the write lock, so indexing continues on other threads.A
Commit()snapshots all flushed segments into asegments_Nfile. The commit is atomic: the manifest file is written, fsynced, then atomically renamed into place. Readers see either the old manifest or the new one, never a partial commit.Background merges consolidate segments. A
MergeSchedulerpicks small segments and merges them into larger ones. Merges never block commits: the merged segment appears in a subsequent commit, and the source segments are removed once no reader references them.
Segment files
Each segment is a set of files sharing a segment ID prefix (e.g. seg_0). Every binary file starts with the LeanCorpus magic header and a format version.
| File | Contents |
|---|---|
seg_0.seg |
Segment metadata: doc count, field names, index sort, delete generation |
seg_0.dic |
Term dictionary (FST): field\0term to postings offset |
seg_0.pos |
Postings: block-packed doc IDs, frequencies, positions |
seg_0.fdt |
Stored fields data blocks, optionally compressed |
seg_0.fdx |
Stored fields index: block offsets for random lookup |
seg_0.nrm |
Per-document field-length norms |
seg_0.fln |
Per-field token counts for BM25 and statistics |
seg_0.num |
Sparse numeric field index by doc ID |
seg_0.bkd |
BKD tree for numeric range queries |
seg_0.dvn |
Numeric DocValues (single-valued, sorting/aggregation) |
seg_0.dvs |
Sorted DocValues (single-valued string ordinals) |
seg_0.dss |
Sorted-set DocValues (multi-valued string ordinals) |
seg_0.dsn |
Sorted-numeric DocValues (multi-valued numeric) |
seg_0.dvb |
Binary DocValues (multi-valued byte columns) |
seg_0.vec |
Dense float vectors per field |
seg_0.hnsw |
HNSW graph for approximate nearest neighbour |
seg_0.tvd / seg_0.tvx |
Term vectors (data and index) |
seg_0.pbs |
Parent bitset for block-join queries |
seg_0.del |
Deleted-document bitset |
segments_N |
Commit manifest: live segment IDs, generation, CRC32 trailer |
write.lock |
Writer lock file |
Files are validated at open time: the IndexValidator.Check method and leancorpus-cli check verify magic headers, format versions, and structural integrity for every codec file in every segment.
I/O model
LeanCorpus uses MMapDirectory for all reads. Segment files are opened as memory-mapped files, and the OS page cache handles I/O. This means:
- Warm reads hit the page cache with no syscall overhead.
- Cold reads fault pages in on demand, without an explicit readahead thread.
- Multiple searchers on the same index share the same physical pages.
- No buffer-pool tuning. The OS manages eviction under memory pressure.
Writes use buffered IndexOutput wrappers with sequential streaming. Atomic commit relies on File.Move (rename) semantics: the manifest is written to a temporary path, fsynced, then renamed over the previous segments_N.
Searcher lifecycle
SearcherManager owns the reader lifecycle:
- On construction, it opens an
IndexSearcheron the latest commit. - A background timer polls for new
segments_Nfiles atRefreshInterval. - When a new commit is detected, a fresh
IndexSearcheris opened and swapped in atomically. - Old searchers are disposed once all outstanding leases are released.
Callers acquire a SearcherLease (via AcquireLease() or the UsingSearcher convenience method) to pin a searcher for the duration of a query. The lease prevents disposal while queries are in flight.
Segment reader leases
Internally, SegmentReader state is protected by a lease mechanism. Heavy resources (postings decoders, DocValues readers, vector graphs, FST terms dictionaries) are opened lazily on first access and cached per reader. A QueryLease acquired before a search loop prevents eviction of cached state while the loop runs. This bounds resident memory without compromising correctness under concurrent access.
Deletions
Deleted documents are tracked via per-segment bitsets (seg_N.del). A delete is buffered in memory and flushed at commit time. The bitset is consulted during scoring: deleted documents are skipped. Merges physically remove deleted documents, reclaiming disk space.
Soft deletes layer on top: a soft-deleted document is excluded from results but retained on disk until the retention period expires and a merge reclaims it.
Index sorting
When IndexSort is configured, segments are sorted by the specified fields during flush. This enables early termination for sorted queries (fetch top-N by sort key without scoring all matches) and improves compression of DocValues columns by grouping similar values.