Two memory systems we shipped. Both running.
A 1,547-file natural-language search system running on Aman’s laptop. A per-character RPG memory engine with hybrid graph + vector + BM25 retrieval and a 13-stage agentic recall pipeline. Both built solo. Both production-quality. This page is the architecture walk-through — not a brochure.
file-finder hybrid fusion ↔ Vaelith polyglot stores
Small animated above-fold visual that alternates between the file-finder retrieval fusion (left) and the Vaelith three-store layout (right).
Ask in plain English. Get the file back.
A Telegram-driven natural-language file search system that indexes a real human’s mess — PDFs scattered across a laptop, a phone synced via Syncthing, and a Samsung Notes app spitting out opaque AgADxxx_4321.jpg filenames. We run it daily. These are the numbers it’s posting right now.
Fixed-weight fusion, not RRF
We chose 0.7 · vector + 0.3 · keyword deliberately. Reciprocal Rank Fusion smears the signal when embeddings have a 0.5–0.7 baseline cosine on English text. We wanted the vector dominance to be tunable in production, not laundered through rank inversion.
The 12-second throttle is the architecture Free-tier Gemini gives us 15 requests per minute. We treat that as a hard design constraint, not an afterthought to apologise for. The whole indexer batches, cools, and rotates across multiple keys so the system never trips a quota mid-write.
7 knobs, ablation-driven Synonyms, extension boost, wrong-extension penalty, UUID penalty (cached Telegram blobs always lose to human filenames), folder-synonym boost, filename-overlap, filename-coverage. Each one was added because an ablation result demanded it — not because it sounded clever.
file-finder retrieval pipeline
Telegram in → handler → parser (Gemini Flash JSON) → retriever (Chroma top-200) → parent_id dedupe → 7-knob enhancements → bge-reranker → optional MMR → reasoner (send_file / ask_clarification / no_match) → Telegram out. Side branches show the watcher → extractor → embedder write path and the edit-history JSONL sidecar.
file-finder — architecture deep-dive
Chunking strategy — per-file-type-aware
Every file gets a summary chunk at index 0: filename + folder path + head + tail. That summary alone catches a surprising number of queries before any vector math runs — people remember filenames and locations more than they remember content. Paginated documents (PDF, DOCX) get one chunk per page so a hit can resolve to the right page. Long unpaginated text uses a 1500-char sliding window with 200-char overlap. Hard cap: 20 chunks per file. Embed input capped at 1800 chars per chunk. That ceiling matters — without it, a single 400-page transcript would dominate the index and starve everything else of recall budget.
Hybrid fusion math — the load-bearing 5 lines
The fusion block sits in query/retriever.py. Both scores are normalized 0–1 before mixing. We pull 200 candidates (not 20) so the reranker has headroom. Then a parent_id dedupe collapses to one row per file — best chunk wins.
# query/retriever.py — load-bearing 5 lines
_VECTOR_WEIGHT = 0.7
_KEYWORD_WEIGHT = 0.3
combined = _VECTOR_WEIGHT * v + _KEYWORD_WEIGHT * kscore
Why dedupe is load-bearing: Gemini embeddings have a 0.5–0.7 baseline cosine on plain English text. Raw vector scores aren’t the signal — relative ordering after dedupe is. If you don’t collapse parent files, the top-20 ends up being twenty chunks of the same PDF and the actual answer falls off the visible result page.
7 ranking knobs — each justified by ablation
- Synonym expansion — query “lease” also fires “rental agreement”, “tenancy”.
- Extension boost ×2 — if the query mentions “the PDF” and a candidate ends in
.pdf, lift it. - Wrong-extension penalty — the reciprocal. Query says “the spreadsheet” and the candidate is a PDF? Push it down.
- UUID penalty — Telegram caches images as
AgADxxx_4321.jpg. These should lose to anything with a human-typed filename. Critical signal: a single regex saves the entire system from a corpus of opaque names. - Folder-synonym boost — query mentions “taxes”, candidate is in
/2024-Returns/. Boost. - Filename-overlap — rewards exact token matches in the filename above content matches.
- Filename-coverage bonus — rewards a filename that contains MOST of the query tokens, not just one.
Each one is gated by an env var: FILE_FINDER_ABLATE_KNOB=<name>. We can drop a knob and re-run the 50-query benchmark to see exactly how much it earned. No black-box ranking. No “we tuned it and it works”.
Edit-history JSONL sidecar — survives Chroma rebuilds
One JSONL file per indexed file, at {chroma_path}/edits/{file_id}.jsonl. Two event types: upsert and delete. Append-only. This is the pattern most RAG teams design in 18 months after shipping, when product asks for “the lease before the addendum” and the index can’t answer. We designed it in on day one. Because the sidecar is filesystem-resident, not Chroma-resident, it survives a full vector-store rebuild — which we do every time the embedding model changes.
File watcher — debounced, single worker
watchdog Observer with a 3-second debounce. Save a file twice in two seconds: one index pass, not two. A single worker thread drains the queue and runs index_single_file per change. Single-writer keeps Chroma happy and rate-limit math sane.
Free-tier engineering as architecture posture
FILE_FINDER_BATCH_DELAY_S=12.0 by default — that’s the throttle that keeps a free-tier Gemini key under 15 RPM with headroom. Multi-key round-robin in shared/llm_round_robin.py rotates across home-email accounts when one cools. Per-provider cooldown circuit breaker (shared/llm_cooldown.py): Gemini quota = 1 hour cool, Mistral = 60s, stub ProviderUnavailable exception = 24 hours dark. The system never tries a provider it knows is down.
Per-provider Chroma collections
Chroma fixes vector dimensionality at collection creation. So we have files_lmstudio_2560 and files_gemini_3072 as separate collections, never mixed. The indexer refuses to fall back across providers — that would corrupt a dim-locked collection. The live retriever, in contrast, does fall back when LMStudioUnavailable fires — it queries the Gemini collection instead. Asymmetric: writes are strict, reads are forgiving.
file-finder hybrid fusion math
Vector-score box (0–1) plus BM25-score box (0–1) → ×0.7 / ×0.3 multiply → sum into combined → sorted top-K → parent_id dedupe rectangle. Annotation explains why dedupe is load-bearing given Gemini’s 0.5–0.7 baseline cosine on English.
file-finder ingestion + edit-history sidecar
Three source clouds (PC / Syncthing phone / Samsung Notes Windows app) → walker filter → extractor dispatcher (chips per file type) → chunker (summary + page/window chunks) → embedder (LM Studio primary, Gemini fallback in indexer rail grayed) → per-provider Chroma collection. Branch: edit-history JSONL sidecar with a time axis to convey its append-only-temporal nature.
A memory engine that thinks like a character.
Vaelith is a narrative RPG engine where every NPC carries an evolving, per-character memory store. The retrieval surface doesn’t treat memory as embedding similarity — it treats it as stateful, emotion-warped, trauma-aware. Same prompt, two characters, two different remembered pasts. The retrieval pipeline is 13 stages deep and runs on three different stores at once.
Vaelith 13-stage retrieval pipeline
Vertical stack with a latency-budget rail on the right (lite 30–80ms / standard 200–500ms / full 600–900ms). Stages: 0 pre-flight → 1 hard filter → 2 multi-query fan-out (literal / emotional / relational / HyDE) → 3 RRF fusion → 4 sub-pills (decay / graph / formative / grief / rumination / graph-walk) → 5 avoidance suppression (trauma adjacency cone) → 7 lens-prefixed bge-rerank → 8 chunk surfacing → 9 partial hits → 10 assembly → 10b side-channel (parallel rail, NOT injected as candidates) → W1 async write-back to recall_log.
Per-NPC memory store Each character’s episodic memories, semantic beliefs, and chunk embeddings live in their own LanceDB table. Two NPCs remembering the same scene encode it differently — one as betrayal, one as a misunderstanding — and the retrieval respects that.
14-axis relationship graph Trust, fear, debt, respect, romantic charge, familiarity — fourteen axes per edge, stored in Kuzu. The retriever can graph-walk to ask “what does this character remember about people two hops from the target?”
6-term live decay function Recall strength is computed at query time from six terms multiplied together: time since last access, access count, encode-time emotional intensity, graph centrality, pinned flag, and current trauma intrusion frequency. The stored decay value is for offline sort-order only — live recall always recomputes.
Sub-50ms involuntary recall, zero LLM calls Trauma-triggered intrusions run on a separate fast path: ANN over a state-vector index, FTS over a denormalized sensory-anchor table, and a Kuzu HAS_TRAUMA traversal. Hard 50ms cap. No model call. Bypasses avoidance suppression because that’s what trauma does.
Stage 7 lens-prefixed reranking The bge-reranker doesn’t see “query × candidate”. It sees “lens-prefix × query × candidate” — where the lens-prefix is a compact string encoding the character’s current emotional state, recent goals, and relationship to the speaker. The reranker scores through the character’s eyes.
Vaelith — architecture deep-dive
The hybrid atomic record (Phase 1A schema)
Every memory is one atomic record with four layered blocks. Skeleton holds the prose summary and a list of verbatim_quotes. Perspective warp holds the per-character distortion: salience, valence, dominant_emotion, intensity, interpretation_bias. Epistemic provenance tracks how the character came to know it: acquisition (witnessed / told / overheard / lied_to), confidence, is_secret, tainted_by_liar. Optional open_loop block flags an active promise, debt, threat, or oath. Optional trauma block holds severity, trigger anchors, resolution state, and intrusion frequency. The atomic record is intentionally fat — that’s what makes downstream stages cheap.
Polyglot stores per save folder
- SQLite (WAL) — the coordinator. Owns the outbox for cross-store writes (the
SealTransactionpayload is gzipped JSON), every tunable lives in*_tuningtables (no magic numbers in code), plusrecall_log, working memory,override_judge_state, scheduler. - Kuzu — the graph. 14-axis relationship edges with familiarity decay. The epistemic graph (who told whom, who was tainted by a liar). Trauma anchors. Faction membership. Temporal edges with
superseded_atsemantics so “the way it was before” is queryable. - LanceDB — per-NPC vector + BM25 hybrid store.
episodic_memories,episodic_chunkswith Matryoshka 2560/256 embeddings (use the long vector for high-stakes recall, the short one for fan-out), andsemantic_memories.belief_vecfor “what this character believes about the world” in vector form.
Cross-store contract
SQLite-authoritative with Kuzu reconciliation. SQLite commit is the durability point — if it commits, the memory exists. LanceDB and Kuzu writes happen post-commit. If Kuzu writes fail, we enqueue a kuzu_repair_belief job that replays from the SQLite outbox until the graph catches up. Not ACID, but recoverable. UUIDv7 round-trips across all three stores so a memory ID means the same thing everywhere.
13-stage retrieval pipeline
Multi-query fan-out generates four parallel sub-queries (literal / emotional / relational / HyDE), runs them all, then merges via RRF. Stage 4 applies a stack of biases: live decay, graph reweighting, formative-memory bump, grief bump, rumination signal, graph-walk injection. Stage 5 is avoidance suppression — candidates inside the trauma adjacency cone get pushed down (the character looks away from what hurts). Stage 7 is the lens-prefixed bge-reranker. Stage 10b is the parallel side-channel: group beliefs and cultural facts get returned in a separate field, not injected as candidates — the prompt builder chooses whether to use them.
Live decay function — six terms, multiplied at query time
Recall strength = f(current_tick - last_accessed) × g(access_count) × encode-time emotion_intensity × graph_centrality × pinned flag × trauma current_intrusion. Stored column exists only for offline sort-order. Live recall recomputes from current state because the character’s current emotional condition is itself an input — you can’t cache that.
Involuntary pipeline — the 50ms fast path
Runs per beat. Hard 50ms cap. Zero LLM calls. Three lanes in parallel: state-match (ANN over state_vec), sensory match (FTS over the denormalized sensory_anchors_flat table), and the trauma path (Kuzu HAS_TRAUMA traversal). The trauma path can fire on a small detail — smell, sound, posture — and bypasses avoidance suppression because intrusive memory bypasses avoidance in real psychology too. We modelled the pathology.
Consolidation (Phase 1E)
A job queue with priority bands 90 (highest) down to 15. Decay maintenance runs daily. Stage promotion walks raw → scene_summary → arc_summary → core. Reconsolidation is probabilistic lens replacement: when an existing memory’s pressure term crosses 5, the lens gets re-rolled against current state — the same event remembered differently because the character has changed. Trauma resolution runs as a small state machine. Sleep recombination harvests episodic memories into semantic memories. Then cleanup.
Annotated RetrievalQuery extract
What the orchestrator passes into the pipeline. Every field tells the downstream stages how to behave:
# vaelith/retrieval/types.py — RetrievalQuery (extract)
class RetrievalQuery(BaseModel):
npc_id: UUID
speaker_id: UUID
text: str
intent_type: Literal["recall", "plan", "reflect", "react"]
scoring_profile: Literal["lite", "standard", "full"] # picks 30/200/900ms rail
vector_resolution: Literal[256, 2560] # Matryoshka tier
lens_prefix: str # compact char-state encoding
fan_out: list[Literal["literal", "emotional", "relational", "hyde"]]
trauma_aware: bool = True # enables stage-5 cone
include_side_channel: bool = False # 10b group / cultural
budget_ms: int # hard cap, not advisory
scoring_profile picks the latency rail. vector_resolution picks the Matryoshka tier — short vectors for fan-out, long vectors for the final rerank candidates. lens_prefix is the compact character-state string that prefixes every reranker call. budget_ms is a hard cap — the pipeline drops stages from the tail end if it’s running long. Graceful degradation is built into the contract.
Vaelith polyglot store + cross-store write order
Three boxes (LanceDB per-NPC, Kuzu graph, SQLite WAL) inside a “save folder” container. SealTransaction → SQLite outbox (gzipped) → SQLite commit (durable point) → LanceDB writes (single-writer per collection) → Kuzu writes → on Kuzu failure, enqueue kuzu_repair_belief job. Callout: UUIDv7 round-trips across all three stores.
If you’re evaluating us as a vendor, here’s the read.
1. Production RAG at real scale
Most public RAG demos are 50-document toys. file-finder runs on 1,547 heterogeneous files with a measured 93/95 top-3 hit rate on a held-out benchmark — a number a serious vendor would actually publish. If you need a retrieval system that holds up beyond a hackathon, you need a team that runs benchmarks against their own daily corpus.
2. Edit-aware indices, designed in from day one
The edit-history JSONL sidecar pattern is the thing most teams retrofit 18 months after shipping, when product asks “the version of this contract before the addendum”. We designed it in before we’d ingested a single PDF. If your domain has versioned documents, this matters more than you’ll predict at the start.
3. Free-tier engineering as architecture posture
The same discipline that ships file-finder on a free-tier Gemini key will ship your MVP without burning your budget. Multi-key round-robin, per-provider cooldown circuit breakers, batch throttles tuned to the public quota — we build assuming the API bill is a constraint, not a footnote.
4. Stateful, emotion-warped retrieval
If your agent needs to recall things through a state — a customer’s history with you, an NPC’s relationship to the speaker, a user’s evolving preferences — the retrieval surface has to respect that state, not just compute cosine similarity. We’ve already built that surface. Vaelith’s lens-prefixed reranking pattern transplants directly to commercial agentic memory.
What’s public, what’s private.
We share systems, not secrets. Everything below the “public” column is fair game for client work, blog posts, conference talks. The “private” column stays ours.
| Public | Private |
|---|---|
| Design docs (markdown architecture write-ups) | Full prompt files |
| Fusion math & weights (0.7 / 0.3, decay terms) | Ablation results on private corpora |
| Schema shapes & field semantics | Per-NPC stage timings & production telemetry |
| Benchmark methodology & query construction | The personal corpus content itself |
| One standalone OSS module per system (forthcoming) | Production codebases |
If you need a memory layer this real, talk to us.
We do diagnose calls Mon–Fri. Bring an actual architectural problem — the harder it is, the better the conversation. We’ll tell you what we’d build, what we’d refuse to build, and roughly what it costs — on the call.