Artifact indexing through MemoryService¶
MemoryService is the public API for indexing and retrieving artifacts. IndexStore, MemoryIndex, snapshots, builders, and persistence paths are internal. See the API migration guide for the Rust visibility changes.
Artifact flow¶
- Extract text and metadata in the host application.
- Represent the artifact as a
SourceDocumentwith a stable document ID. - Call
MemoryService::upsertfor each new or changed document. - Call
refreshto publish the batch. - Search through the service.
use membrick::{MemoryService, PipelineOptions, SourceDocument};
use std::collections::BTreeMap;
let mut memory = MemoryService::in_memory(PipelineOptions::default());
memory.upsert(SourceDocument::with_stable_doc_id_from_source(
"artifacts/report.txt".into(),
"The quarterly report describes quartz deployment costs.".into(),
"quarterly report".into(),
None,
vec!["Costs".into()],
vec![],
None,
None,
));
memory.refresh()?;
let results = memory.search_with_filters(
"quartz costs", "artifacts", None, 5, &BTreeMap::new(),
)?;
# Ok::<(), anyhow::Error>(())
Use MemoryService::at_path(path, options) for persistence. Use add and add_batch with typed requests for user/session memories; these methods publish their changes internally. upsert is the explicit bulk source-document path and requires refresh to publish the batch.
Implementation reference¶
The following storage and snapshot details describe crate internals. Types such as IndexStore and MemoryIndex mentioned below cannot be imported by library clients; use MemoryService as shown above.
Artifact to SourceDocument Mapping¶
Each artifact should be normalized into one SourceDocument.
Recommended mapping:
doc_id- stable artifact id
source- source URI, file path, or artifact key
content- extracted plain text
concept- canonical label, title, or filename-derived subject
headings- optional structure extracted from the artifact
links- optional references to related documents or artifacts
timestamp- optional creation or fetch time
doc_length- extracted text length
author_agent- optional provenance
Example:
use membrick::SourceDocument;
let doc = SourceDocument {
doc_id: "artifact-123".to_string(),
source: "s3://bucket/report.pdf".to_string(),
content: "normalized extracted text".to_string(),
concept: "quarterly report".to_string(),
headings: vec!["Summary".to_string(), "Financials".to_string()],
links: vec![],
timestamp: None,
doc_length: 24,
author_agent: None,
};
Storage Model¶
The preferred storage model is:
- one corpus root
- one local
.membrick/index root under that corpus
If the corpus root is:
then the default index root should be:
Suggested layout:
Where:
semantic/- persisted semantic records and binary core
metadata.json- index schema/layout metadata for validation and migration checks
IndexStore and MemoryService persist semantic records and rebuild their immutable MemoryIndex query snapshot from those records. The snapshot owns the Tantivy lexical index used by active searches. They do not maintain a second persistent lexical/ index. Existing legacy lexical/ directories are left untouched. The standalone build_query_snapshot_from_records helper can still create a persistent Tantivy index at the resolved lexical path.
This keeps index ownership local to the corpus and avoids guessing unrelated global paths.
Index Location Modes¶
Conceptually, the library should support these modes:
- in-memory
- no filesystem state
- under corpus root
- default persistent mode using
.membrick/ - explicit path
- caller provides an explicit index root
Current API surface:
IndexStore::in_memory(options)IndexStore::for_corpus(corpus_root, options)IndexStore::at_path(index_root, options)resolve_store_paths(corpus_root, options)
The intended rule is:
- default to
InMemorywhen no persistent root is available - default to
UnderCorpusRootwhen indexing a filesystem corpus - only use a shared or external index root when explicitly configured
Index creation and opening should be centralized in one place. The code should not create Tantivy indexes independently in multiple files or subsystems.
Multi-Root Operation¶
If there are multiple corpus roots or multiple index locations, they should not implicitly mutate one shared physical index directory.
Instead, the clean model is:
- one corpus root -> one
IndexStore - many corpus roots -> many
IndexStores - query all stores and merge results
This is effectively a sharded query model.
Example:
IndexStore A -> results A
IndexStore B -> results B
IndexStore C -> results C
merge/rerank -> final results
This is safer and simpler than trying to make several unrelated filesystem locations behave as one writable Tantivy index.
Explicit Global Index Root¶
If one logical global index is desired, it should be explicit.
For example:
with:
/var/lib/membrick/indexes/my-index/lexical/
/var/lib/membrick/indexes/my-index/semantic/
/var/lib/membrick/indexes/my-index/metadata.json
This should be an explicit configuration choice, not something inferred from multiple input locations.
Versioning and Migration Policy¶
Persistent IndexStore roots now use metadata.json as a minimal formal versioning contract.
Current metadata includes:
schema_versionlayout_versioncrate_versionindex_location
Behavior:
- when a persistent store is opened, existing metadata is loaded and validated
- schema or layout mismatches cause store initialization to fail explicitly
- when a persistent store is created or refreshed, metadata is written back to disk
This is a minimal migration policy, not a full migration framework. It does, however, make schema/layout incompatibilities explicit instead of relying only on path conventions.
Internal Layering¶
The current internal layering is:
SourceDocument- public normalized input document
DocRecord- internal per-document indexed artifact
MemoryIndex- built immutable query structure, including its Tantivy lexical index
IndexStore- mutable orchestration layer over source documents, cached
DocRecords, tombstones, and the current builtMemoryIndex
This means the “indexed document” layer already exists in practice today: DocRecord is that layer.
From the user-facing API perspective:
- users provide
SourceDocument - the library internally derives and caches
DocRecord - the library builds or refreshes
MemoryIndexfrom cachedDocRecords
DocRecord is therefore an internal document-level indexing artifact, not the primary user-facing ingestion contract.
How IndexStore Works Today¶
IndexStore is the internal mutable implementation owned by MemoryService.
In the current design, refresh() is the boundary where mutable document state is turned into the immutable queryable snapshot.
It stores:
PipelineOptionsHashMap<String, SourceDocument>HashMap<String, DocRecord>- tombstone set for removed document ids
- optional
MemoryIndex - dirty flag
Current supported operations:
new(options)with_documents(options, docs)upsert(doc)remove(doc_id)refresh()query(query, top_k)len()is_empty()is_dirty()tombstones()source_documents()records()
Behavior:
upsertreplaces or inserts aSourceDocumentremovedeletes a document from the mutable source set and records a tombstonerefreshconverts the current mutable state into a rebuilt immutable semanticMemoryIndexif the index is dirtyqueryis the internal query path and uses a fresh semantic snapshot- unchanged documents reuse cached
DocRecords during refresh IndexLocationdetermines where semantic persistence is stored; theMemoryIndexTantivy index is rebuilt as part of the query snapshot
This means the write model is now mutable at the API layer, even though the underlying query index is still rebuilt in batch.
Another way to think about it:
upsert/removeedit the working setrefresh()compiles the semantic working set into the current searchable snapshot, which owns its Tantivy lexical indexquery()searches the published snapshot
Chunk Lifecycle Metadata¶
Chunk lifecycle is now tracked separately from raw chunk content.
Design intent:
- keep
SectionChunkas content/span identity - keep lifecycle/versioning in a separate metadata layer
- allow history and lineage without polluting raw chunk payloads
Current metadata shape:
chunk_iddoc_idlineage_keyversionis_latestsupersedes_chunk_idupdated_at_mschange_reason
Persistence:
- lifecycle is written with semantic state under
.membrick/semantic/ - currently persisted both:
- inline in
records.jsonaschunk_lifecycle - in a dedicated
chunk_lifecycle.json
Transition behavior:
- on
upsert+refresh, each rebuilt chunk is matched by deterministic lineage key (doc + heading + line span) - if same
chunk_idalready exists: - lifecycle entry remains current (
is_latest = true) - if lineage exists but
chunk_idchanged: - prior latest is marked
is_latest = false - new lifecycle entry is created with
version = old + 1 supersedes_chunk_idpoints to prior latest chunk id- if lineage is new:
- new lifecycle entry starts at
version = 1,is_latest = true - on
remove(doc_id): - lifecycle entries for that doc are removed from the active store
Current query semantics:
- snapshots query current chunks only
- lifecycle metadata is used for evolution tracking and auditability, not for historical chunk retrieval by default
- historical chunk metadata remains available, but old chunk content is not part of active
DocRecord.section_chunks
Temporal Fact / Assertion Layer¶
Lifecycle metadata is enough to track how documents and chunks change over time. It is not enough to answer "what was true when" questions on its own.
The next layer should therefore be a separate temporal fact store that is derived from chunks, but not mixed into raw chunk payloads.
Recommended role separation:
SectionChunk.timestamp- source/event time for the chunk
ChunkLifecycleMeta.updated_at_ms- when the chunk version changed in the index
DocumentLifecycleMeta.updated_at_ms- when the document version changed in the index
TemporalFact- semantic assertion layer with validity intervals
Suggested TemporalFact shape:
fact_idsubjectpredicateobjectorvalueunitscopevalid_fromvalid_tosource_doc_idsource_chunk_idsource_chunk_versionchunk_timestampconfidenceis_latest
How it should be used:
- Derive facts from chunks during indexing.
- Keep provenance back to the source chunk and document.
- Version facts when later evidence changes the asserted state.
- Query the fact layer for:
as_of(date)between(start, end)timeline(subject)diff(date1, date2)
What this is not:
- it is not another chunking layer
- it is not a replacement for
DocRecord - it is not a reason to store
tcommit-style graph jargon in the public API
Markdown supersession¶
When a Markdown document replaces an earlier document whose wording or timestamp is not enough to establish the relationship, declare the relationship in frontmatter:
The referenced document remains in the indexed corpus for historical inspection, but default project searches omit it and return the replacement instead. This explicit relationship is intentionally preferred over guessing semantic replacement between unrelated passages.
Implementation direction:
- store facts in the existing memory store as a dedicated collection or namespace
- keep the document/chunk lifecycle model intact
- use the fact layer only for temporal state reasoning, not for ordinary retrieval
Current Drawback of the Implemented Approach¶
The current design is intentionally conservative.
Main tradeoff:
- update semantics are incremental
- lexical updates are incremental, but semantic index construction is still batch-oriented
In practice:
- one changed artifact updates Tantivy incrementally, but can still trigger a full semantic
MemoryIndexrebuild on next refresh or query - the first query after updates may pay the rebuild cost
- the library keeps both source documents and an immutable snapshot in memory
This is acceptable for small and medium corpora and is much simpler than true low-level incremental mutation of the frozen query structures.
Why We Kept MemoryIndex Separate Internally¶
The internal MemoryIndex is optimized for fast batch construction and fast queries. It uses compact global structures such as:
- flattened chunk metadata
- postings vectors
- tries
- interval trees
- lexical BM25 index
Those structures are efficient to build and query, but awkward to mutate incrementally. Supporting in-place updates would require a deeper redesign of query internals.
What Is Still Missing¶
The current implementation is useful, but it is not yet a full incremental indexing system.
Remaining gaps:
- true incremental semantic postings update
- semantic
MemoryIndexis still rebuilt from cachedDocRecords - richer concurrent publication model
- current background refresh is internal-only and simple rather than a full multi-reader / multi-writer snapshot system
- see IndexStore and MemoryIndex Concurrency for the recommended large-corpus publication model
- stronger freshness policy controls
- current public API intentionally avoids exposing multiple refresh/query modes, and does not yet implement advanced scheduling or debouncing policies
Semantic Persistence¶
Persistent stores now use .membrick/semantic/ for semantic state.
Current files:
records.json- persisted semantic document records
core.bin- binary semantic core used to rebuild the semantic
MemoryIndex
Behavior:
- on
refresh(), semantic state is written into the resolved semantic path - on persistent store initialization, semantic state is loaded from disk when compatible metadata and semantic files are present
- restored semantic records are used to rebuild the lexical index inside the published
MemoryIndexsnapshot
This means the documented semantic/ path is active. IndexStore no longer repopulates a separate lexical index on open.
Snapshot as an Internal Concept¶
The library still uses the concept of a snapshot internally:
- mutable state exists inside the internal
IndexStore - the queryable
MemoryIndexis the frozen semantic and lexical search snapshot refresh()materializes that semantic snapshot from current mutable state
What is intentionally not introduced right now is a separate public snapshot manager subsystem.
That is deliberate for two reasons:
- the current synchronous
refresh()path is sufficient for the current scale and workflow - introducing a dedicated snapshot manager now would add concurrency and lifecycle complexity without solving the harder low-level incremental indexing problem
So at this stage:
- snapshot is a real internal concept
- snapshot manager is not a required standalone abstraction yet
Recommended Next Steps¶
Phase 3¶
If scale demands it, redesign internals for true incremental updates:
- stable doc and chunk ids
- mutable postings maps
- incremental lexical indexing
- efficient remove and replace operations
This is a larger project and should only be pursued if performance data shows the current snapshot model is too expensive.
Before that larger redesign, prefer the lighter concurrency path documented in IndexStore and MemoryIndex Concurrency: one published query generation plus at most one in-flight replacement generation per logical IndexStore. A generation may be one MemoryIndex today or a segmented set of MemoryIndex values in a future large-corpus design.
Recommended Host Architecture¶
For an artifact system, the recommended architecture remains:
- Artifact store
- source of truth for fetched artifacts
- Normalization layer
- converts artifacts into
SourceDocument - Mutable artifact index state
- public
MemoryService - Immutable query snapshot
- internal
MemoryIndex, produced byrefresh()
This keeps responsibilities clear:
- artifact system owns fetching, storage, OCR, file parsing, and metadata
membrickowns chunking, enrichment, indexing, and retrieval
Summary¶
Current state:
- artifact-friendly document type is implemented
- library-facing pipeline config is implemented
- internal mutable
IndexStorewithupsert/remove/refresh/queryis implemented - per-document
DocRecordcache is implemented DocRecordalready serves as the internal per-document indexed artifact layer- tombstone tracking is implemented
- configurable lexical snapshot persistence is implemented
refresh()is the mutable-to-immutable boundary- internal
MemoryIndexis still rebuilt in batch from cached records
So membrick now supports artifact ingestion in a practical way, but it does not yet provide true low-level incremental indexing.