Modules · Context Transfer Engine
The indexer module
The indexer sits in the storage chain above the CTE core, watches every write that passes through it, and keeps a tokenized copy of each blob's text in memory. Keyword (BM25) searches are answered from that index, so a query never reads blob bytes.
- Library
- clio_cte_indexer
- Default pool
- 564.0
- Source
- context-transfer-engine/indexer
- Module ids
- kIndexSweep 100, kReindexScan 101
What it does
The CTE core once implemented SemanticSearch by reading and
tokenizing every candidate blob on each query. That work now lives in the
indexer module, which maintains a forward index: for every
indexed blob (a "document") it stores the tag id, the resolved tag name, the
blob name, the per-term frequencies and the document length. A query
filters the documents by two regular expressions, computes BM25 statistics
over the matched set, and returns the top-k results. The core itself no
longer accepts the search method, so a client that wants search must be
pointed at the indexer or at a pool that forwards into it.
Use the indexer when data written through the CTE is text-like (logs, notes, JSON, source, metadata documents) and you want to find blobs by the words they contain, from Python, from the CLI, or from the Context Exploration Engine. Binary data can be left out of the index with the scope regexes described below.
Where it sits
The indexer is an interposer: it speaks the core's task interface,
handles a few verbs itself, and forwards everything else to
next_pool_id unchanged. Its handlers are coroutines that run
nested calls on the co-located core container rather than issuing new client
requests, as described on the chain design
page.
In the default chain shipped in
context-runtime/config/clio_default.yaml the order is:
cache(563.0) -> indexer(564.0) -> [compressor(562.0) ->] replication(561.0) -> core(512.0)The indexer sits above the compressor so that when it has to read a blob back it sees logical, uncompressed bytes. It must be composed after the pool it forwards to and before the pools that forward into it (the cache, the filesystem module, the CAE core).
Composing it
Minimal composition, adapted from the indexer restart test and the default configuration:
compose:
- mod_name: clio_cte_core
pool_name: cte_main
pool_query: local
pool_id: "512.0"
storage:
- path: "ram::cte_dram"
bdev_type: "ram"
capacity_limit: "512MB"
score: 1.0
- mod_name: clio_cte_indexer
pool_name: clio_cte_indexer
pool_query: local
pool_id: "564.0"
next_pool_id: "512.0"
index_log_path: "${CLIO_STORAGE_ROOT}/cte_indexer_index"
# index_sweep_period_ms: 100
# index_wal_compact_bytes: "8MB"
# tag_re: ".*"
# blob_re: ".*"Clients then bind to the indexer pool instead of the bare core. In C++
that means constructing a clio::cte::core::Client on pool
564.0; for the Python bindings and other processes that use the shared
client singleton, set CLIO_CTE_POOL=564.0 (or the id of the
chain top, usually the cache at 563.0) before initializing. Writes that
bypass the indexer are not indexed until a backfill picks them up.
Operations
From indexer/clio_mod.yaml, the module intercepts these core
verbs, with the same method ids as the core:
- PutBlob, MultiPutBlob, TruncateBlob. Forwarded down first. On success the affected (tag, blob) key is marked dirty for asynchronous re-indexing. Replica-addressed puts are not indexed, since the primary write already was.
- DelBlob, DelTag. Forwarded down first; on success the document, or every document of the tag, is removed from the index and from the dirty set. Deleting a tag also forgets that the tag was seen, so a recreated tag triggers a fresh backfill.
- RenameTag. Forwarded, then the cached tag name and every affected document's tag name are rewritten.
- UpdateTagNames (filesystem create, rename, unlink): forwarded, then the whole tag-name cache is dropped and documents re-resolve their names before the next search.
- SemanticSearch terminates here and is answered from the index.
Two module-specific verbs are numbered above the core's id space:
- IndexSweep (100) is a periodic task the module schedules for itself. It drains the dirty set and compacts the write-ahead log when it has grown past the threshold.
- ReindexScan (101) is the on-demand backfill. The C++
client's
AsyncReindexScan(tag_regex, blob_regex, pool_query)enumerates this node's existing blobs that match both regexes (intersected with the module's own scope), enqueues them, and reportsblobs_enqueued_. The default pool query is Broadcast, so every node scans its own slice. There is no Python or CLI wrapper for this verb at the time of writing.
How indexing works
- Enqueue. A successful put, multi-put or truncate adds its (tag, blob) key to a dirty map. Keys coalesce: ten overwrites of one blob cost one re-tokenization. This is the only indexing cost on the put acknowledgement path.
- First-insertion backfill. The first write a tag
receives after the indexer was composed also schedules a one-time scan of
that tag's pre-existing blobs (a local
BlobQueryagainst the pool below). Seen tags are remembered, and persisted, so this fires once per tag. - Drain. The periodic sweep, or a search, pops dirty keys. For each one the module asks the core for the blob's current size, resolves the tag name, checks the scope regexes, reads the blob's bytes through a nested call on the local core container, and tokenizes them. A blob whose size is zero or that cannot be read is dropped from the index rather than kept as an empty document.
- Search barrier. Every
SemanticSearchdrains the dirty set completely before evaluating the query, so a search issued after an acknowledged write sees that write. The sweep only bounds how stale the index can be between searches.
Tokenization lowercases ASCII and splits on anything that is not an ASCII letter or digit, keeping runs of two or more characters. Bytes above 0x7F are separators. Query text is tokenized the same way.
Keyword search
A search carries a tag regex, a blob-name regex, the query text and
k. Both regexes use full-string matching
(std::regex_match), so "doc.*" matches
doc7 but "doc" does not. The matched documents
form the working set; document frequencies and the average document length
are computed over that set only, so scores mean "rank within this filter".
The implementation is Okapi BM25 with k1 = 1.5 and
b = 0.75. Results are sorted by descending score and cut to
k; k = 0 returns every matched document. An empty
query returns matched documents with score 0. A malformed regex fails the
task with return code 1.
Each container holds the index for the blobs its node owns. A search sent with a Broadcast pool query runs on every container and the runtime merges the shards; a Local query searches only the submitting node's slice.
From Python
The Python binding exposes the core client's search method directly. The
smoke test context-transfer-engine/wrapper/python/test_cte_semantic_search.py
does the following:
import os
os.environ.setdefault("CLIO_CTE_POOL", "564.0") # bind the client to the indexer
import clio_cte_core_ext as cte
tag = cte.Tag("semsearch_corpus")
tag.PutBlob("doc0", b"Apollo 11 landed on the Moon ...", 0)
client = cte.get_cte_client()
results = client.SemanticSearch(
tag_regex=".*",
blob_regex="^doc[0-9]+$",
query_text="moon space telescope lunar exploration",
k=5,
pool_query=cte.PoolQuery.Local(),
)
for r in results: # list[SemanticSearchResult], sorted by score
print(r.blob_name, r.score, r.tag_id.major_, r.tag_id.minor_)k defaults to 10 and pool_query to Broadcast.
The same client also offers BlobQuery(tag_regex, blob_regex,
max_blobs, pool_query), which returns (tag name, blob name) pairs by
name alone. Name queries are answered by the core's metadata, not by the
indexer, and need no index. See the
Python API page for the rest of the
client.
Configuration
next_pool_id- The pool this indexer forwards to, as
"major.minor". Usually the replication pool (561.0), or the compressor (562.0) when compression is enabled. Unset falls back to the canonical core pool 512.0. index_sweep_period_ms- Period of the background drain. Default 100. 0 disables the sweep; the index is then updated only when a search runs, and the write-ahead log is compacted only on clean shutdown.
tag_re,blob_re- Index scope. Only blobs whose full tag name matches
tag_reand whose blob name matchesblob_reare tokenized. Defaults are".*". Out-of-scope content is never read, uses no index memory and writes no log records. A regex that fails to compile is logged and the scope is opened to everything. index_log_path- When set, the module keeps a snapshot at this path and an append-only
log at
<path>.wal, and a restart restores the index from them. Environment variables in the path are expanded. Empty (the default) keeps the index in memory only. index_wal_compact_bytes- Log size that triggers a snapshot and log truncation, checked by the sweep. Default 8 MiB; accepts size suffixes.
Setting CLIO_INDEXER_PASSIVE=1 in the runtime's environment
turns the module into a forward-only interposer: puts and deletes still
flow through, but nothing is enqueued or indexed. It exists for triage and
for measuring interposition cost separately from indexing cost.
Persistence and restart
- With
index_log_pathset, every index mutation (document replaced, document deleted, tag deleted, tag renamed, tag first seen) is appended to the log and flushed. On create, the module loads the snapshot, replays the log last-wins, drops restored documents that fall outside the current scope, writes a fresh snapshot and truncates the log. - The snapshot includes still-dirty keys and un-run tag backfills, so a clean shutdown resumes exactly where it stopped. A restart never rescans storage.
- Crash window: content acknowledged after the last log record was
written may stay unindexed until the blob is rewritten or a
ReindexScanis issued. The index is derived state; the scan repairs it. - Without a log path a restart comes up with an empty index. Each tag
is backfilled on its first write, and the rest can be pulled in with
ReindexScan.
The header comment in indexer/clio_mod.yaml and the restart
test's compose file still describe the index as "rebuilt from storage" on
restart. The runtime code restores from the snapshot and log and does not
rescan; this page follows the code.
Behaviors worth knowing
- Read-your-writes: a search after an acknowledged write includes it, because the search drains the dirty set first. The cost is that a search issued right after a large burst of writes pays for indexing them.
- The index is in process memory and grows with the number of indexed
documents and distinct terms. Use
tag_reandblob_reto keep binary or bulk data out of it. - Every search iterates all documents in the container's index to apply the regex filters; there is no inverted index. Tag-name matches are memoized per tag within a query.
- Partial writes and truncates are re-indexed by reading the blob's current bytes back from the chain, so very large blobs with frequent small updates cost a full read per drain.
- Handlers that cannot allocate a read buffer leave the previous index entry in place and log a warning.
Limits
- Tokenization is ASCII only; non-ASCII text is not indexed as words.
- Scoring is exact-token BM25. There is no stemming, no embedding model and no fuzzy matching; "semantic" in the method name refers to content search as opposed to name search.
- The persistence files are binary and versioned but not shared across nodes; each container keeps its own.
ReindexScanis reachable only from the C++ client.
Testing
context-transfer-engine/test/integration/indexer_restart: writes documents through the indexer over a file-backed tier withindex_log_pathset, restarts the runtime, and verifies searches against the restored index. Built when the indexer client target exists.context-transfer-engine/wrapper/python/test_cte_semantic_search.py: Python smoke test covering result type, descending order, thekcap, the empty query and a non-matching blob regex.- The Context Assimilation Engine's summarizer test configurations
(
context-assimilation-engine/test/unit) also compose an indexer pool in their chains.