BaseX 13 · UPDINDEX · org.basex.index.ft

The full-text index that survives updates

How the full-text index is kept valid across every update operation, why in-place changes were ruled out, and what the pieces on disk and in memory do.

Immutable segmentsOrdered x/y/z structures holding node IDs, each with a sparse set of the units it supersedes. Never modified, only written and merged.
One buffer in memoryThe newest entries live in a per-length token map, backed by an append-only log. Written as the next segment past a threshold.
Everything deferredUpdates only record touched node IDs. One pass at the end of the transaction re-lexes each affected unit exactly once.
Implemented 2026-09-10 · design document .claude/Observations/fulltext-updindex.md1 / 12

Why not update in place, like the text index?

2 / 12
replace value of "the united states" 3 tokens theunitedstates posting list · 2,000,000 references · rewritten 440,000 references · rewritten 271,000 references · rewritten cost per update = Σ list lengths of its tokens, not the size of the change
In-place maintenance pays for the posting lists of every token in the changed node. On XMark 1 GB the text index already shows this: replacing a frequent value costs about 465 ms, a unique one a few milliseconds.

The text index model

UpdatableDiskValues reads and rewrites the complete ID list of every key it touches. For a text index that is one key per node and the lists are short unless the value is frequent.

Why it breaks for full-text

Every node has many tokens, and the common ones have lists proportional to the database. A single edit would cost the sum of those lists.

What every engine does instead

Immutable segments, deletion marks and merging: Lucene and everything built on it, SQLite FTS5, InnoDB, Oracle Text. BaseX already had the machinery, because FTBuilder writes partial indexes when memory runs out and merges them at the end.

On disk: numbered segments, one log, one meta key

3 / 12
inf: FTXSEGS = "1,3,4" age: oldest → newest ftx1 x lengths y tokens z id/pos refs (no s: oldest) ftx3 x y z s superseded unit IDs ftx4 x y z s buffer (RAM) token → refs id → generation appended at put ftxb log ftx2 was merged into ftx4 and deleted; numbers are never reused. Files not listed in FTXSEGS are orphans of an interrupted write and are deleted at open. Without UPDINDEX: the unnumbered ftxx/ftxy/ftxz with PRE values, unchanged since 12.0.
The same x/y/z format as before, now numbered and holding node IDs. One meta key distinguishes the two layouts.

One key decides everything

FTXSEGS present means numbered segments, node IDs mapped through data.pre(id), an updatable index and storage version 13.0. Absent means the 12.0 layout, PRE values, OLDSTORAGE, and the first update invalidates the index as before.

No legacy read path

A 12.0 database opened with UPDINDEX simply carries a non-updatable index until the next OPTIMIZE rebuilds it segmented. Databases without UPDINDEX stay byte-identical (pinned by IndexFormatTest).

Supersede sets stay sparse

File s lists the units this segment re-indexed or excluded. Deleted units need no mark: IdPreMap.pre returns −1 for them.

Liveness: a reference is live unless something newer supersedes its unit

4 / 12
ftx1 ftx3 ftx4 buffer unit Aunit Bunit C A · refs A · refs s = {A} B · refs node deleted: pre(B) = −1, no mark needed C · refs s = {C} C · gen 2 ids = {C} newer(ftx1) = {C} ∪ {A} ∪ {C} = {A, C} newer(ftx3) = {A} ∪ {C} = {A, C} newer(ftx4) = {C} newer(buffer) = ∅
ftx3 also re-indexed C earlier, so its own set says {C}; the buffer's later put of C makes that copy dead too. Each segment holds one derived union, so the test per hit is one set lookup plus data.pre(id).

The rule

A reference for unit i in segment s is live iff pre(i) ≠ −1 and no segment newer than s, the buffer included, supersedes i.

The derived union

FTSegment.newer is the union of the supersede sets of all newer segments and of the buffer. It is recomputed from the files at open and after every merge; a put adds its ID to the union of every existing segment.

Consequences

  • A unit's references never straddle two segments, so exactly one holder is live.
  • Sets hold one int per unit changed since the last full merge: sparse by construction.
  • Deleted references are purged lazily, when their segment is merged.

The buffer: append only, generations instead of removal

5 / 12
tokens[length] : TokenObjectMap<IntList> len 3 len 5 len 6 "new" → [7,0,1] "entry" → [7,1,1] [7,1,2] "second"→ [7,0,2] stale: gen 1 ≠ generations[7] live: gen 2 = generations[7] generations : IntMap id → gen 7 → 2 (key set = supersede set) ftxb log, one record per put [7, 2, "new",0, "entry",1] [7, 2, "second",0, "entry",1] ← last record of an ID wins on replay unit 7 re-put: "new entry" → "second entry"
Unit 7 was indexed twice within one buffer lifetime. Its first triples stay in place but carry generation 1 and are ignored; the write of the buffer as a segment drops them.

One operation

put(id, tokens, positions) bumps the ID's generation, appends the triples under it, and writes a log record. An empty token list records an exclusion (a rename out of the included names).

Why no removal

An editor saving the same document repeatedly, or many changes below one included element, re-puts the same units. Filtering by generation at query time and at segment write costs nothing; a forward map and deletion would.

Bounded by the threshold

The count of references appended since the last segment write is checked after every unit. Past 1 million (SPLITSIZE × 1 000 000), the buffer becomes segment n, the log is deleted, and the count restarts. This bounds memory, the log and its replay.

An update: touch IDs now, index once at the end

6 / 12
Data hooks (transaction) insert · delete · rename touch(id) touched : IntSet IDs only, no values finishUpdate sort by PRE, skip nodes inside an already walked subtree id > lastid: new root walk subtree, put every unit existing node unit? lex and put : exclude size ≤ threshold size > threshold buffer.put(id, tokens) newer(every segment) += id FTBuilder route one segment, split + merge appended ≥ threshold write buffer as segment n > 8 segments? merge policy Hook cost: an ID per node, plus the included ancestors under FTMIXED. Why defer: a thousand changes below one included element lex it once, and a subtree root is one stable ID where a PRE range shifts with every insert. lastid at the previous finish separates new from existing IDs.
All update paths end in Data.finishUpdate; Optimize.finish runs before it, so optimize() processes the touched set first as well.

What the hooks record

insert(pre, size)each top-level element, text or document node of the range; under FTMIXED also the included ancestors
delete(pre, size)nothing (deleted IDs map to −1); under FTMIXED the included ancestors
text changearrives as delete + insert of the same node, so the text node itself
rename(pre, ELEM)the child text nodes; under FTMIXED the element
attributes, comments, PIsnothing

Failure handling

If the lexer cannot be built or an I/O error hits the log, a segment or a merge, the index is dropped and ftindex cleared. An update never fails because of the full-text index.

Merging: one mechanism, one policy

7 / 12
before ftx1 · 560 MB ftx5 · 3 MB s = {A} ftx6 · 90 MB s = {A, B} ftx7 · 2 MB s = {C} ftx8 · 40 MB … 9 segments k-way merge by (length, token) liveness per input, sorted refs after ftx1 ftx6 s = {A, B} ftx9 · 5 MB s = {C} ftx8 … 8 segments The output takes the position of its newest input. Its set is not the plain union {A, C}: ftx6 sits between the inputs and re-indexed A itself, so ftx6 holds A's live references. A union carrying A would have killed them. IDs superseded by an in-between segment are dropped.
The policy picked the two segments below one eighth of the total size. Adjacency is not required; the set rule keeps non-adjacent merges correct.

Mechanism

FTBuilder.merge(inputs, output, live) is the same k-way merge that joins the builder's partial files, with one predicate per input: pre(id) ≠ −1 and id ∉ newer(input). Tokens whose references all died are skipped; surviving references are sorted by ID and position.

Policy

  • After a segment write, if more than 8 segments exist: merge those below 1/8 of the total size. A transaction never rewrites the base.
  • OPTIMIZE and db:optimize: write the buffer, merge everything into one segment. 22 s for the 568 MB XMark index.
  • AUTOOPTIMIZE: the same, but only when superseded units exceed 10 % of lastid.

Merges run inside finishUpdate under the write lock; a background merger would change the locking model. Inputs are deleted as soon as the output is open.

Queries: every segment and the buffer, one sorted result

8 / 12
FTIndex.iter exact · wildcard · fuzzy ftx1 read refs, drop dead ftx3 … ftx4 binary search per length buffer map lookup, live generation id → pre (pre, pos) lists concatenated FTCache sort by (pre, pos) → FTIndexIterator, unchanged for callers
Fuzzy search runs the prefix-pruned distance walk on each segment's sorted tokens and a Levenshtein test on the buffer's keys of the relevant lengths; wildcards scan the matching length groups.

Unchanged contract

Callers still receive PRE values grouped with their match positions. The 12.0 path with a single unnumbered segment goes through the same code with the ID mapping switched off.

Upper bounds, by design

costs, size and the counts of ft:tokens and INFO INDEX sum the segments' entries. Superseded and deleted references are counted until the next merge; a token present in several segments appears once, because entries is a k-way merge of the per-segment iterators by (length, token).

Measured

On XMark 1 GB with 712 buffered references, ft:search for will took 156 ms warm, the same as before the change; fuzzy 265 ms.

Classes: a facade over segments, a buffer and the builder

9 / 12
Data / DiskData hooks, flush FTIndex extends ValueIndex FTSegment[] FTBuffer IntSet touched FTLexer (lazy) iter · costs · entries · finish · optimize · merge policy build(range), merge(inputs, live) FTBuilder FTSegmentWriter write(prefix) FTList sequential reader of one x/y/z structure MetaData.ftsegments FTXSEGS, written by DiskData.write FTSegment: the former FTIndex disk reader plus the s file and the derived newer union. FTFuzzy now takes the token file per call.
The facade is the only class the rest of BaseX sees; segment and buffer are package-private.

Reused, not rewritten

  • FTBuilder gained a range build and a static merge with liveness predicates; its partial-file splitting is the builder route for a 1 GB insert.
  • FTSegmentWriter extracts the x/y/z writing that writeIndex and the old merge each had inline.
  • ValueIndex hooks were unified beforehand, so Data has no full-text branches.

Two small API changes

ValueIndex.optimize(boolean auto) distinguishes AUTOOPTIMIZE from an explicit optimize; IndexBuilder.splitFactor(type) is public so the threshold shares the builder's constant.

Numbers on XMark 1 GB

10 / 12
MeasurementBefore (no full-text index or non-updatable)After (updatable full-text index)
CREATE DB with FTINDEX128–134 s, 67 s of which full-text112 s with UPDINDEX, index 568 MB, one segment
200 single-text replacements, old values frequent (text-index posting-list cost)41 s40 s, median 15 ms, max 593 ms
200 single-text replacements, old values unique4.6 s, median 25 ms3.4 s, median 15 ms, max 31 ms
ft:search will, 190 k hits, warm140–220 ms156 ms with 712 buffered refs; 140 ms after merge
ft:search will, fuzzy, 392 k hits, warm265–360 ms265 ms
OPTIMIZE (full merge of the index)rebuild: 67 s of lexing22 s streaming merge
db:add of the 1 GB document into an empty databasenot possible with a kept index135 s through the builder route, same hit counts as CREATE DB

Ryzen 7 PRO 5850U, F: drive, SPLITSIZE 0, 2.4 GB heap. Update rows measured with the database held open; the first row's 465 ms outliers are the text index rewriting the ID list of a frequent key, which the full-text design avoids by construction. FTIndexBaselineTest in basex-tests reproduces every row.

Compatibility and what still costs something

11 / 12

Opening databases

12.0 databaseopened unchanged, PRE values; the first update invalidates the index; OPTIMIZE rebuilds it segmented if UPDINDEX is on
13.0, no UPDINDEX12.0 layout and OLDSTORAGE; 12.0 can open it
13.0, UPDINDEX + indexnumbered ID segments, storage version 13.0; 12.0 refuses with H_DB_FORMAT

Crash consistency

Equal to the other UPDINDEX structures: segments, log and meta data are written in finishUpdate after the table. Unlisted files are removed at open; an incomplete log record is truncated on replay.

Documented costs

  • Under FTMIXED, an update below an included element re-lexes that element. Including a document element means re-indexing the document on every change; include leaf elements instead.
  • Counts are upper bounds until the next merge.
  • Deletions alone never trigger an automatic merge; the flush policy purges them when small segments are merged.
  • A missing external stop-word file at update time drops the index instead of failing the update.

Not measured yet

FTMIXED at the 1 GB scale, and query latency at 4 and 8 segments on a large database. Both are exercised functionally by FTIndexUpdateTest with a lowered threshold.

Where to look

12 / 12
FTIndexfacade: hooks, finish, buffer-to-segment writes, merge policy, optimize(auto), orphan cleanup, INFO INDEX output (segments, superseded, buffered)
FTSegmentone immutable x/y/z(/s) structure; exact, wildcard and fuzzy lookups with liveness and ID mapping; entry iterators
FTBuffertoken maps with generations, log replay and append, entries per length, write as segment
FTBuilderbuild(first, last, prefix), static merge(data, inputs, output, live), IDs under UPDINDEX
FTSegmentWriterwrites x/y/z for builder, merge and buffer
MetaData / DiskDataftsegments (FTXSEGS), update() keeps ftindex iff set, legacy() false iff set
Optimizethreads auto from Optimize.finish into ValueIndex.optimize(boolean)
Inspectreports listed segment files that are missing
TestsFTIndexUpdateTest (18 cases), IndexUpdateConcurrencyTest, UpdIndexTest, UpdIndexRandomTest, FTIndexBaselineTest; IndexFormatTest guards the 12.0 layout
Design document.claude/Observations/fulltext-updindex.md: rationale, defaults, deviations, baseline and validation numbers