<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title/><link>https://aretascodes.dev/</link><atom:link href="https://aretascodes.dev/index.xml" rel="self" type="application/rss+xml"/><description/><generator>HugoBlox Kit (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Fri, 15 May 2026 00:00:00 +0000</lastBuildDate><image><url>https://aretascodes.dev/media/icon_hu_2ab4f4763b27c75b.png</url><title/><link>https://aretascodes.dev/</link></image><item><title>CogniVault Backend Explained, Part 1 · Meet the Backend: Three Processes, Four Layers</title><link>https://aretascodes.dev/blog/backend-explained-meet-the-backend/</link><pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/blog/backend-explained-meet-the-backend/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;All abbreviations are fully explained in the appendix at the bottom of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;When people first open the CogniVault repository, the question I hear most is some version of: &lt;em&gt;&amp;ldquo;Where do I even start?&amp;rdquo;&lt;/em&gt; There&amp;rsquo;s a RAG agent, a FAISS index, a DBOS workflow, an Ollama host — and if you&amp;rsquo;re transitioning into tech, every one of those words is a closed door.&lt;/p&gt;
&lt;p&gt;This series opens the doors one at a time. No prior RAG knowledge assumed, every abbreviation spelled out, and every claim checkable against the
. If you&amp;rsquo;ve already read my
, think of this series as the guided tour that should have come first.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s map this out.&lt;/p&gt;
&lt;h2 id="the-whole-app-is-three-processes"&gt;The whole app is three processes&lt;/h2&gt;
&lt;p&gt;CogniVault lets you chat with your own documents and turn them into quizzes, workshops, flashcards, and mindmaps — and nothing ever leaves your machine. (The &lt;em&gt;why&lt;/em&gt; behind that constraint is its own story:
.)&lt;/p&gt;
&lt;p&gt;You might expect an app like that to be a sprawl of microservices. It&amp;rsquo;s three processes:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Process&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The Python backend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One FastAPI app on port 8000 — it also serves the compiled React frontend as static files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ollama&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The local model server on port 11434, running the AI models&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PostgreSQL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One Docker container, used &lt;em&gt;only&lt;/em&gt; for workflow checkpoints — never for your documents&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Everything else — your files, the search index, your chat history, your quiz scores — is a plain file on disk. That&amp;rsquo;s not laziness; it&amp;rsquo;s the privacy argument made physical. You can open every byte the app stores with a text editor and a SQLite browser.&lt;/p&gt;
&lt;h2 id="the-four-layers"&gt;The four layers&lt;/h2&gt;
&lt;p&gt;Before we name technologies, here&amp;rsquo;s the mental model I want you to keep for the whole series. The backend is four layers, top to bottom:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Layer 1 — the web layer.&lt;/strong&gt; A FastAPI application receives every HTTP request and routes it to one of six routers: chat (&lt;code&gt;/rag&lt;/code&gt;), knowledge management (&lt;code&gt;/upload&lt;/code&gt;, &lt;code&gt;/ingest&lt;/code&gt;), study tools (&lt;code&gt;/api/study/*&lt;/code&gt;), progress (&lt;code&gt;/api/progress/*&lt;/code&gt;), voice (&lt;code&gt;/api/transcribe&lt;/code&gt;), and chat history (&lt;code&gt;/api/history&lt;/code&gt;). FastAPI (a modern Python web framework) also auto-generates interactive API documentation at &lt;code&gt;/api/docs&lt;/code&gt;, which is the best way to explore the backend without reading a line of code.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Layer 2 — the intelligence layer.&lt;/strong&gt; Two AI models with two different jobs. &lt;code&gt;gemma4:e4b&lt;/code&gt; &lt;em&gt;generates&lt;/em&gt;: chat answers, reasoning, image analysis, and tool calls. &lt;code&gt;embeddinggemma&lt;/code&gt; &lt;em&gt;embeds&lt;/em&gt;: it turns text into vectors (lists of numbers that capture meaning) so similar ideas can be found mathematically. Both run inside Ollama — think of Ollama as Docker, but for AI models.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Layer 3 — the retrieval layer.&lt;/strong&gt; A search engine over your documents that combines &lt;em&gt;semantic&lt;/em&gt; search (find things that mean the same) with &lt;em&gt;keyword&lt;/em&gt; search (find the exact string). Part 3 of this series is entirely about this layer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Layer 4 — the persistence layer.&lt;/strong&gt; Four storage systems, each picked for one job: a FAISS index plus a JSON file for searchable knowledge, SQLite for study data, PostgreSQL for workflow checkpoints, and plain JSON files for chat history.&lt;/p&gt;
&lt;h2 id="one-diagram-every-major-piece"&gt;One diagram, every major piece&lt;/h2&gt;
&lt;div class="mermaid"&gt;flowchart TB
subgraph CLIENT["Browser"]
UI["React Frontend&lt;br/&gt;(compiled, served by FastAPI)"]
end
subgraph SERVER["FastAPI Backend — port 8000"]
ROUTERS["6 Routers&lt;br/&gt;rag · knowledge · study ·&lt;br/&gt;progress · audio · history"]
AGENT["RAG Agent&lt;br/&gt;(Strands SDK, 6 tools)"]
VDB["VectorDB&lt;br/&gt;FAISS + BM25 + RRF"]
INGEST["Ingestion&lt;br/&gt;(DBOS durable workflow)"]
GEN["Study generators&lt;br/&gt;quiz · workshop · cards · mindmap"]
PROG["Progress tracker&lt;br/&gt;+ 25 achievements"]
end
subgraph OLLAMA["Ollama — port 11434"]
GEMMA["gemma4:e4b&lt;br/&gt;chat · thinking · vision · tools"]
EMBED["embeddinggemma&lt;br/&gt;text to vectors"]
end
subgraph STORAGE["Local storage"]
FAISSF["vector_store.faiss + .json"]
SQLITE["progress.db (SQLite)"]
PG["PostgreSQL&lt;br/&gt;workflow state only"]
DOCS["docs/ folder + chat_history.json"]
end
UI --&gt; ROUTERS
ROUTERS --&gt; AGENT --&gt; VDB
AGENT --&gt; GEMMA
VDB --&gt; EMBED
ROUTERS --&gt; INGEST --&gt; EMBED
INGEST --&gt; PG
INGEST --&gt; FAISSF
VDB --- FAISSF
ROUTERS --&gt; GEN --&gt; GEMMA
GEN --&gt; SQLITE
ROUTERS --&gt; PROG --&gt; SQLITE
ROUTERS --&gt; DOCS
&lt;/div&gt;
&lt;p&gt;Keep this picture handy — Parts 2, 3, and 4 each zoom into one region of it.&lt;/p&gt;
&lt;h2 id="the-tech-stack-and-why-each-piece-earned-its-place"&gt;The tech stack, and why each piece earned its place&lt;/h2&gt;
&lt;p&gt;The full dependency list lives in &lt;code&gt;requirements.txt&lt;/code&gt;. Here&amp;rsquo;s what matters, grouped by job:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Serving requests.&lt;/strong&gt; FastAPI defines the endpoints and validates every request and response with Pydantic (a data-validation library — think of it as a strict customs officer for JSON). Uvicorn is the ASGI server (Asynchronous Server Gateway Interface — the Python standard that lets one process juggle many simultaneous requests) that actually runs it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thinking.&lt;/strong&gt; Ollama serves &lt;code&gt;gemma4:e4b&lt;/code&gt; — the &lt;code&gt;e4b&lt;/code&gt; tag is the roughly four-billion effective-parameter variant, about a 9.6 GB download — and &lt;code&gt;embeddinggemma&lt;/code&gt; (about 622 MB). The agent behaviour is built with the Strands Agents SDK, which wraps the model in a loop where it can call tools, read the results, and only then answer. (Where I run Ollama relative to Docker is a deliberate choice with a story behind it:
.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Finding things.&lt;/strong&gt; FAISS (Facebook AI Similarity Search — Meta&amp;rsquo;s vector search library) handles semantic lookups; &lt;code&gt;rank-bm25&lt;/code&gt; handles keyword lookups; a formula called Reciprocal Rank Fusion merges the two. Part 3 unpacks all of this.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reading documents.&lt;/strong&gt; &lt;code&gt;pypdf&lt;/code&gt; for PDFs, with an OCR fallback (Optical Character Recognition — turning pictures of text into actual text) for scanned pages via &lt;code&gt;pymupdf&lt;/code&gt; and Tesseract. Word, PowerPoint, and Excel each get their own extractor. &lt;code&gt;trafilatura&lt;/code&gt; pulls clean article text out of web pages.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Not losing work.&lt;/strong&gt; DBOS makes the ingestion pipeline durable — every step is checkpointed in PostgreSQL so a crash resumes instead of restarting. Part 2 shows this in action.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Remembering.&lt;/strong&gt; SQLite — a complete database engine that lives in a single file, &lt;code&gt;progress.db&lt;/code&gt; — holds your study sessions, achievements, quizzes, workshops, flashcard decks, and mindmaps.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="appendix-abbreviations-in-this-post"&gt;Appendix: Abbreviations in this post&lt;/h2&gt;
&lt;p&gt;This series&amp;rsquo; promise is &amp;ldquo;no unexplained abbreviations,&amp;rdquo; so here is the table I wish every technical tutorial shipped with.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Plain-English meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large Language Model&lt;/td&gt;
&lt;td&gt;A neural network trained on huge amounts of text that can read and generate language&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Retrieval-Augmented Generation&lt;/td&gt;
&lt;td&gt;Fetch relevant passages from &lt;em&gt;your&lt;/em&gt; documents first, then let the model answer from them — instead of from its training memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;The set of URLs the frontend calls to talk to the backend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ASGI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Asynchronous Server Gateway Interface&lt;/td&gt;
&lt;td&gt;The Python standard that lets the server handle many requests concurrently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;The universal text format for structured data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NDJSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Newline-Delimited JSON&lt;/td&gt;
&lt;td&gt;A stream where each line is its own JSON object — ideal for streaming AI answers chunk by chunk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Meta&amp;rsquo;s library for storing vectors and finding the most similar ones fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BM25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Best Match 25&lt;/td&gt;
&lt;td&gt;A classic keyword-ranking formula — the 25th ranking function developed in the Okapi information-retrieval system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reciprocal Rank Fusion&lt;/td&gt;
&lt;td&gt;A formula for merging multiple ranked result lists using only the ranks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ANN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Approximate Nearest Neighbour&lt;/td&gt;
&lt;td&gt;A speed shortcut many vector databases take. CogniVault deliberately uses an &lt;em&gt;exact&lt;/em&gt; index instead — precise, and plenty fast at personal-library scale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System (the research project it grew from)&lt;/td&gt;
&lt;td&gt;A library that checkpoints workflow steps in a database so crashed jobs resume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQL / SQLite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Structured Query Language / SQLite&lt;/td&gt;
&lt;td&gt;The language of relational databases / a tiny database that lives in one file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OCR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Optical Character Recognition&lt;/td&gt;
&lt;td&gt;Turning pictures of text (scans) into machine-readable text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SHA-256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Secure Hash Algorithm, 256-bit&lt;/td&gt;
&lt;td&gt;A fingerprint function — any file maps to a unique hash, used to detect changed files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CORS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cross-Origin Resource Sharing&lt;/td&gt;
&lt;td&gt;Browser rules controlling which websites may call the API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SSRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Server-Side Request Forgery&lt;/td&gt;
&lt;td&gt;An attack where a server is tricked into fetching internal URLs — the URL-import endpoint guards against it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MCQ&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-Choice Question&lt;/td&gt;
&lt;td&gt;One of the two quiz question types&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;KB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Knowledge Base&lt;/td&gt;
&lt;td&gt;All your ingested, searchable documents&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;(Every claim in this series can be checked directly against the
— the relevant file is named whenever it matters, and the repository README maps the full architecture.)&lt;/p&gt;
&lt;h2 id="the-takeaway"&gt;The takeaway&lt;/h2&gt;
&lt;p&gt;Strip away the abbreviations and CogniVault is a small system: one web server, one model runtime, one durability database, and a handful of files. The sophistication isn&amp;rsquo;t in the part count — it&amp;rsquo;s in how a few well-chosen pieces cooperate. That cooperation is what the next three parts are about.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt;
— how a 1,000-page scanned PDF becomes something the AI can search in seconds, and why the pipeline survives a crash at page 800.&lt;/p&gt;</description></item><item><title>CogniVault Backend Explained, Part 2 · From File to Searchable Knowledge</title><link>https://aretascodes.dev/blog/backend-explained-ingestion/</link><pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/blog/backend-explained-ingestion/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;All abbreviations are fully explained in the appendix at the bottom of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;An LLM cannot &amp;ldquo;open&amp;rdquo; your PDF. That sentence surprises a lot of newcomers, so let&amp;rsquo;s sit with it for a second: when you chat with your documents in CogniVault, the model never touches the original files. Something has to happen &lt;em&gt;between&lt;/em&gt; &amp;ldquo;I dropped a file into the browser&amp;rdquo; and &amp;ldquo;the AI just quoted page 47 back at me.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;That something is &lt;strong&gt;ingestion&lt;/strong&gt;, and it&amp;rsquo;s the subject of this part. In
we drew the whole map; today we zoom into one region — the conveyor belt that turns files into searchable knowledge.&lt;/p&gt;
&lt;h2 id="the-conveyor-belt"&gt;The conveyor belt&lt;/h2&gt;
&lt;p&gt;Think of ingestion as a four-station assembly line:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Extract&lt;/strong&gt; the text out of each file — even scanned ones.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Chunk&lt;/strong&gt; it into pieces small enough to fit into a prompt.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Embed&lt;/strong&gt; each chunk — turn it into a vector (a list of numbers that captures its meaning) so similar ideas land near each other in vector space.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Store&lt;/strong&gt; vectors and metadata so they can be searched later.&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="mermaid"&gt;flowchart TD
A["Upload&lt;br/&gt;POST /upload&lt;br/&gt;saved to docs/"] --&gt; B
subgraph WF["DBOS durable workflow"]
B["Step 1&lt;br/&gt;Which files changed?&lt;br/&gt;SHA-256 fingerprints"] --&gt; C["Step 2&lt;br/&gt;Extract text&lt;br/&gt;per-format + OCR fallback"]
C --&gt; D["Chunk&lt;br/&gt;1000 chars, 100 overlap"]
D --&gt; E["Step 3&lt;br/&gt;Embed&lt;br/&gt;embeddinggemma, batches of 5"]
E --&gt; F["Step 4&lt;br/&gt;Save&lt;br/&gt;FAISS index + metadata JSON"]
end
F --&gt; G["Reload in-memory index&lt;br/&gt;instantly searchable"]
&lt;/div&gt;
&lt;p&gt;Simple enough. The interesting engineering is in the failure cases — so let&amp;rsquo;s start there.&lt;/p&gt;
&lt;h2 id="the-factory-ledger-why-the-pipeline-cant-lose-work"&gt;The factory ledger: why the pipeline can&amp;rsquo;t lose work&lt;/h2&gt;
&lt;p&gt;Embedding a large library takes minutes. What happens when your laptop goes to sleep at page 800 of a 1,000-page manual? With a plain Python script: everything restarts from page 1.&lt;/p&gt;
&lt;p&gt;CogniVault instead writes the pipeline as a &lt;strong&gt;DBOS durable workflow&lt;/strong&gt;. Picture a factory where every station stamps a permanent ledger the moment it finishes a box. If the power cuts out, nobody rebuilds finished boxes — the workers read the ledger and resume at the first unstamped entry.&lt;/p&gt;
&lt;p&gt;DBOS is that ledger, and PostgreSQL is the book it&amp;rsquo;s written in. Each pipeline station is a checkpointed step; on restart, completed steps return their recorded results instantly and execution continues from the first unfinished one. A failed embedding batch is simply retried.&lt;/p&gt;
&lt;p&gt;This is also what powers the live progress timeline in the UI: starting an ingestion returns a &lt;code&gt;workflow_id&lt;/code&gt;, and the frontend polls a status endpoint that reports which steps have completed, which are running, and which are still waiting.&lt;/p&gt;
&lt;p&gt;I wrote a whole deep dive on this mechanism — including what happens when you &lt;code&gt;kill -9&lt;/code&gt; the process mid-ingest — in
.&lt;/p&gt;
&lt;h2 id="fingerprints-not-faith-sha-256-change-detection"&gt;Fingerprints, not faith: SHA-256 change detection&lt;/h2&gt;
&lt;p&gt;Re-embedding your whole library every time you add one file would be wasteful. So before any work happens, the pipeline computes each file&amp;rsquo;s &lt;strong&gt;SHA-256 hash&lt;/strong&gt; (a content fingerprint — change one character in the file and the fingerprint changes completely) and compares it to the fingerprint stored with the file&amp;rsquo;s existing chunks:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Never seen before&lt;/strong&gt; → ingest it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fingerprint changed&lt;/strong&gt; → the old chunks are &lt;em&gt;soft-deleted&lt;/em&gt; and the file is re-ingested.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fingerprint identical&lt;/strong&gt; → skip it entirely.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Why &amp;ldquo;soft&amp;rdquo;-deleted? Because the FAISS index type CogniVault uses cannot remove individual vectors. Stale chunks are just marked &lt;code&gt;deleted: true&lt;/code&gt; in the metadata; their vectors stay in the index but every search filters them out. It&amp;rsquo;s an honest, boring solution — and it never corrupts the index.&lt;/p&gt;
&lt;h2 id="every-format-gets-its-own-treatment"&gt;Every format gets its own treatment&lt;/h2&gt;
&lt;p&gt;Here&amp;rsquo;s a detail that separates a demo from a product. A naive pipeline extracts &amp;ldquo;all the text&amp;rdquo; and calls it a day. CogniVault gives each format an extractor that preserves the &lt;em&gt;structure&lt;/em&gt; that retrieval will need later:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Page by page, keeping page numbers (those become citations later). Any page yielding fewer than 50 characters is presumed scanned and sent to OCR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scanned page&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The page is rendered to an image at roughly 144 dpi, then Tesseract OCR (Optical Character Recognition — reading text out of images) extracts the words&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Markdown&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Split on headings; each section chunk gets a breadcrumb prefix like &lt;code&gt;[Section: Intro &amp;gt; Setup]&lt;/code&gt; so its embedding carries the document hierarchy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CSV&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Rows grouped 20 per chunk — and &lt;em&gt;every&lt;/em&gt; chunk is prefixed with the header row, so the model always knows the column names&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Excel&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Same row-group idea per sheet, prefixed &lt;code&gt;[Sheet: name]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PowerPoint&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One chunk per slide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Word&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Paragraphs plus table cells&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Web pages&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fetched on request and stripped to clean article text — behind an SSRF guard (Server-Side Request Forgery protection: the server refuses to fetch private or internal addresses)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Ask yourself why the CSV detail matters. If chunk 14 of a spreadsheet is just twenty naked rows of numbers, no search will ever connect it to the question &amp;ldquo;what was the Q3 budget?&amp;rdquo; Prefix it with the header row, and the chunk &lt;em&gt;knows&lt;/em&gt; it contains budget columns. Structure is retrieval fuel.&lt;/p&gt;
&lt;h2 id="chunking-1000-characters-with-a-100-character-safety-overlap"&gt;Chunking: 1,000 characters with a 100-character safety overlap&lt;/h2&gt;
&lt;p&gt;Long text is split into pieces of about 1,000 characters, with neighbouring pieces overlapping by 100. The overlap is insurance: a sentence sliced at a chunk boundary still appears whole in one of the two neighbours, so no idea falls into the gap between chunks.&lt;/p&gt;
&lt;h2 id="embedding-and-saving"&gt;Embedding and saving&lt;/h2&gt;
&lt;p&gt;Chunks are embedded by &lt;code&gt;embeddinggemma&lt;/code&gt; (via Ollama) in batches of five — each chunk becomes one vector. The vectors are normalised and appended to a FAISS index; alongside it, a JSON file records each chunk&amp;rsquo;s source filename, page number, category, fingerprint, and the text itself. The index holds the numbers; the JSON holds the meaning.&lt;/p&gt;
&lt;p&gt;One choice worth highlighting for beginners: this is an &lt;strong&gt;exact&lt;/strong&gt; index, not an approximate one. Many vector databases use ANN (Approximate Nearest Neighbour) shortcuts that trade a little accuracy for speed at massive scale. At personal-library scale you don&amp;rsquo;t need the trade — CogniVault checks every vector on every search and is still fast.&lt;/p&gt;
&lt;h2 id="the-whole-journey-end-to-end"&gt;The whole journey, end to end&lt;/h2&gt;
&lt;div class="mermaid"&gt;%%{init: {'sequence': {'actorFontSize': 28, 'messageFontSize': 24, 'loopTextFontSize': 22, 'noteFontSize': 22}}}%%
sequenceDiagram
actor U as You
participant F as Frontend
participant B as FastAPI
participant W as DBOS Workflow
participant O as Ollama (embeddinggemma)
participant V as FAISS + metadata
U-&gt;&gt;F: Drag and drop a file, pick a category
F-&gt;&gt;B: POST /upload
B-&gt;&gt;B: Validate type and size, save to docs/
F-&gt;&gt;B: POST /ingest
B-&gt;&gt;W: Start durable workflow
B--&gt;&gt;F: workflow_id
loop Poll status
F-&gt;&gt;B: GET /ingest/status/{workflow_id}
B--&gt;&gt;F: Step list (drives the progress timeline)
end
W-&gt;&gt;W: SHA-256 change detection
W-&gt;&gt;W: Extract text (per format, OCR if scanned)
W-&gt;&gt;W: Chunk (1000 chars / 100 overlap)
W-&gt;&gt;O: Embed in batches of 5
O--&gt;&gt;W: Vectors
W-&gt;&gt;V: Append vectors + metadata
B--&gt;&gt;F: SUCCESS — index reloaded
F--&gt;&gt;U: "Knowledge Sync Complete"
&lt;/div&gt;
&lt;h2 id="the-takeaway"&gt;The takeaway&lt;/h2&gt;
&lt;p&gt;Ingestion is where most RAG quality is actually won or lost — long before any clever prompting. Page numbers preserved, headers carried into every spreadsheet chunk, scans rescued by OCR, and a ledger that makes the whole thing crash-proof: none of it is glamorous, all of it shows up later as answers that cite the right page.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id="appendix-abbreviations-in-this-post"&gt;Appendix: Abbreviations in this post&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large Language Model&lt;/td&gt;
&lt;td&gt;A neural network trained on huge amounts of text that can read and generate language&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System&lt;/td&gt;
&lt;td&gt;The library that checkpoints workflow steps in PostgreSQL so crashed jobs resume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SHA-256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Secure Hash Algorithm, 256-bit&lt;/td&gt;
&lt;td&gt;A content fingerprint — change one byte of a file and the hash changes completely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OCR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Optical Character Recognition&lt;/td&gt;
&lt;td&gt;Reading text out of images — the rescue path for scanned PDF pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SSRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Server-Side Request Forgery&lt;/td&gt;
&lt;td&gt;An attack where a server is tricked into fetching internal URLs; the URL importer blocks it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;The vector index the embeddings are appended to&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ANN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Approximate Nearest Neighbour&lt;/td&gt;
&lt;td&gt;The accuracy-for-speed shortcut CogniVault deliberately does &lt;em&gt;not&lt;/em&gt; take&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;dpi&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Dots Per Inch&lt;/td&gt;
&lt;td&gt;Image resolution — scanned pages are rendered at ~144 dpi before OCR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;The format of the chunk-metadata file beside the FAISS index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF / CSV&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Document Format / Comma-Separated Values&lt;/td&gt;
&lt;td&gt;Two of the eight-plus supported file formats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;The endpoints (&lt;code&gt;/upload&lt;/code&gt;, &lt;code&gt;/ingest&lt;/code&gt;, &lt;code&gt;/ingest/status/…&lt;/code&gt;) driving the flow&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt;
— hybrid retrieval, the six-tool agent, and the two-phase stream that shows the model think before it answers.&lt;/p&gt;</description></item><item><title>CogniVault Backend Explained, Part 3 · How a Question Becomes a Cited Answer</title><link>https://aretascodes.dev/blog/backend-explained-rag-agent/</link><pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/blog/backend-explained-rag-agent/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;All abbreviations are fully explained in the appendix at the bottom of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;You type a question. A few seconds later you get an answer with footnotes — the exact documents and pages it came from. This part walks through everything that happens in between.&lt;/p&gt;
&lt;p&gt;In
we built the knowledge base: every document chunked, embedded, and indexed. Now we get to &lt;em&gt;use&lt;/em&gt; it — and this is where CogniVault stops being a pipeline and starts being interesting.&lt;/p&gt;
&lt;h2 id="two-librarians-because-one-keeps-failing-you"&gt;Two librarians, because one keeps failing you&lt;/h2&gt;
&lt;p&gt;Imagine a library with one librarian who organises everything by &lt;em&gt;vibe&lt;/em&gt;. Ask her about &amp;ldquo;server downtime procedures&amp;rdquo; and she&amp;rsquo;s brilliant — she understands what you mean and finds documents that discuss the concept, whatever words they use. But ask her for &amp;ldquo;Error Code 404B&amp;rdquo; and she shrugs, handing you general networking guides. She doesn&amp;rsquo;t do exact strings.&lt;/p&gt;
&lt;p&gt;Down the hall is a second librarian with a card catalogue. He finds the exact string &amp;ldquo;404B&amp;rdquo; instantly — but ask him a conceptual question phrased differently from the source text, and he finds nothing at all.&lt;/p&gt;
&lt;p&gt;These are the two halves of search:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Semantic search (FAISS)&lt;/strong&gt; — your question is embedded into a vector, and the index finds chunks whose vectors point the same way (technically: cosine similarity — how closely two arrows align). Great for meaning, blind to exact identifiers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keyword search (BM25)&lt;/strong&gt; — a scoring formula that rewards chunks containing your &lt;em&gt;exact&lt;/em&gt; words, weighted by how distinctive those words are. Great for identifiers, blind to synonyms.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;CogniVault asks &lt;strong&gt;both librarians every time&lt;/strong&gt;, then merges their answers with &lt;strong&gt;Reciprocal Rank Fusion (RRF)&lt;/strong&gt; — a formula that combines ranked lists using only the &lt;em&gt;positions&lt;/em&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;score(chunk) = sum over both lists of 1 / (60 + rank)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A chunk ranked highly by either librarian scores well; a chunk both of them liked floats to the top. The elegance is what&amp;rsquo;s &lt;em&gt;missing&lt;/em&gt;: you never have to reconcile FAISS&amp;rsquo;s similarity scores with BM25&amp;rsquo;s completely different scale, because ranks are the only input. The constant 60 comes straight from the original 2009 research paper, and yes, it&amp;rsquo;s cited in the code.&lt;/p&gt;
&lt;p&gt;A few implementation details worth knowing: both searches deliberately over-fetch (at least 20 candidates each) so the fusion has material to work with; very weak semantic matches are dropped, but a keyword-perfect chunk can still be rescued through fusion; and the final answer uses the top 7 chunks. I benchmarked this whole setup against pure vector search in
if you want the war stories.&lt;/p&gt;
&lt;h2 id="the-agent-a-model-that-decides-for-itself"&gt;The agent: a model that decides for itself&lt;/h2&gt;
&lt;p&gt;Here&amp;rsquo;s the second idea that trips up beginners: CogniVault&amp;rsquo;s chat is not &amp;ldquo;paste chunks into a prompt, get an answer.&amp;rdquo; It&amp;rsquo;s an &lt;strong&gt;agent&lt;/strong&gt; — a model running in a loop where it can &lt;em&gt;choose&lt;/em&gt; to call tools, read their results, and only then answer.&lt;/p&gt;
&lt;p&gt;Built with the Strands Agents SDK, the agent gets six tools:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;search_knowledge_base&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The core RAG tool — runs the hybrid search above, returns chunks with source and page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list_documents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;See what&amp;rsquo;s in the vault&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;analyze_document&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Structured analysis of one document: topics, entities, facts, summary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;compare_documents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Answer a question by comparing two documents side by side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;calculator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Safe maths — the expression is parsed into a syntax tree and only whitelisted operators run. No &lt;code&gt;eval()&lt;/code&gt;, ever&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;current_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The date and time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;There is no hard-coded routing. The &lt;em&gt;model&lt;/em&gt; reads your question and decides which tools to call, guided by its system prompt. Ask &amp;ldquo;compare the two contracts on termination clauses&amp;rdquo; and it reaches for &lt;code&gt;compare_documents&lt;/code&gt;; ask &amp;ldquo;what&amp;rsquo;s 15% of 2,340&amp;rdquo; and it uses the calculator instead of hallucinating arithmetic.&lt;/p&gt;
&lt;p&gt;Two safety details I want beginners to notice, because they&amp;rsquo;re the difference between a toy and a product: a &lt;strong&gt;fresh agent is constructed for every request&lt;/strong&gt; (no shared state bleeding between concurrent chats), and the document-analysis tools call the model &lt;em&gt;directly&lt;/em&gt; rather than through the agent — otherwise an agent calling a tool that calls the agent could recurse forever.&lt;/p&gt;
&lt;h2 id="watching-the-model-think"&gt;Watching the model think&lt;/h2&gt;
&lt;p&gt;When you send a message, the response streams back as &lt;strong&gt;NDJSON&lt;/strong&gt; (Newline-Delimited JSON — each line of the stream is its own small JSON object). And it arrives in two phases:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 1 — thinking.&lt;/strong&gt; Gemma&amp;rsquo;s reasoning chain streams first, rendered in the collapsible panel above the answer. It&amp;rsquo;s deliberately best-effort: if it fails for any reason, the answer still comes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 2 — the agent answer.&lt;/strong&gt; Tools run, citations appear in the Sources panel the moment the search completes — &lt;em&gt;before&lt;/em&gt; the answer finishes writing — and the answer text streams in.&lt;/p&gt;
&lt;div class="mermaid"&gt;flowchart TB
Q["Your question&lt;br/&gt;(plus optional images, files, scope)"] --&gt; P1
subgraph STREAM["POST /rag — one NDJSON stream"]
P1["Phase 1: Thinking&lt;br/&gt;reasoning chunks stream first"]
P1 --&gt; P2["Phase 2: Agent&lt;br/&gt;fresh per request, history restored"]
P2 --&gt;|"decides to call"| T["search_knowledge_base"]
T --&gt; D["FAISS&lt;br/&gt;semantic"]
T --&gt; S["BM25&lt;br/&gt;keywords"]
D --&gt; RRF["RRF fusion — top 7 chunks"]
S --&gt; RRF
RRF --&gt;|"chunks + citations"| P2
P2 --&gt; OUT["citations, then answer text,&lt;br/&gt;then a memory-usage report"]
end
&lt;/div&gt;
&lt;p&gt;Each line in the stream is typed: &lt;code&gt;thinking&lt;/code&gt;, &lt;code&gt;metadata&lt;/code&gt; (a citation), &lt;code&gt;text&lt;/code&gt; (answer), &lt;code&gt;memory&lt;/code&gt; (how full the conversation budget is), or &lt;code&gt;error&lt;/code&gt;. The frontend just reads lines and routes them to the right panel. I dissected this design — and why thinking comes &lt;em&gt;before&lt;/em&gt; the tool calls — in
.&lt;/p&gt;
&lt;h2 id="a-memory-budget-not-a-bottomless-pit"&gt;A memory budget, not a bottomless pit&lt;/h2&gt;
&lt;p&gt;Gemma&amp;rsquo;s context window (the amount of text the model can consider at once) is 128K tokens, but CogniVault doesn&amp;rsquo;t let conversation history sprawl across all of it. Each chat session gets a budget of 48,000 characters — roughly 12,000 tokens. Exceed it, and the &lt;em&gt;oldest&lt;/em&gt; question-answer pair quietly drops out first, keeping the bulk of the window free for what matters: your current question and the retrieved chunks.&lt;/p&gt;
&lt;p&gt;Two resilience touches worth stealing for your own projects:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Restart survival.&lt;/strong&gt; In-memory history dies with the process. So the first message in a session after a backend restart rebuilds its history from the chat log the frontend persists. Multi-turn memory survives reboots.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Edit and regenerate.&lt;/strong&gt; Editing an earlier message rewinds the stored history to that point before re-asking — the model genuinely forgets the timeline that no longer exists.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="scope-pinning-the-ai-to-specific-documents"&gt;Scope: pinning the AI to specific documents&lt;/h2&gt;
&lt;p&gt;One last feature, and a lesson about small local models. You can pin a chat to specific files or a category. The filter travels with the request &lt;em&gt;and&lt;/em&gt; a mandatory-search instruction is injected into both the system prompt and the user message itself.&lt;/p&gt;
&lt;p&gt;Why both? Because small models sometimes skip instructions that live only in the system prompt — but they can&amp;rsquo;t ignore what&amp;rsquo;s inside the question. Belt and braces. When you work with 4-billion-parameter models instead of frontier ones, you learn to make instructions impossible to miss rather than hoping they&amp;rsquo;re followed.&lt;/p&gt;
&lt;h2 id="the-takeaway"&gt;The takeaway&lt;/h2&gt;
&lt;p&gt;A cited answer is four systems cooperating: two retrievers covering each other&amp;rsquo;s blind spots, a fusion formula that needs nothing but ranks, an agent that picks its own tools, and a stream that shows its work. None of the four is exotic on its own — the product is the cooperation.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="appendix-abbreviations-in-this-post"&gt;Appendix: Abbreviations in this post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Retrieval-Augmented Generation&lt;/td&gt;
&lt;td&gt;Retrieve relevant passages from your own documents first; let the model answer from them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;The semantic (meaning-based) half of hybrid search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BM25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Best Match 25&lt;/td&gt;
&lt;td&gt;The keyword half — a classic ranking formula from the Okapi information-retrieval system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reciprocal Rank Fusion&lt;/td&gt;
&lt;td&gt;Merges the two ranked lists using only each chunk&amp;rsquo;s rank: &lt;code&gt;score = Σ 1/(60 + rank)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NDJSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Newline-Delimited JSON&lt;/td&gt;
&lt;td&gt;A stream where each line is its own complete JSON object — the chat response format&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;The universal text format for structured data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AST&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Abstract Syntax Tree&lt;/td&gt;
&lt;td&gt;The parsed form of an expression — how the calculator does maths without &lt;code&gt;eval()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large Language Model&lt;/td&gt;
&lt;td&gt;A neural network trained on huge amounts of text that can read and generate language&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SDK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Software Development Kit&lt;/td&gt;
&lt;td&gt;A library of building blocks — here, Strands, which provides the agent loop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;K&lt;/strong&gt; (in 128K)&lt;/td&gt;
&lt;td&gt;Kilo (thousand)&lt;/td&gt;
&lt;td&gt;128K tokens ≈ 128,000 tokens — Gemma&amp;rsquo;s context window&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt;
— the same machinery pointed at generating quizzes, workshops, flashcards, and mindmaps, plus a table of every byte the app stores and exactly where it lives.&lt;/p&gt;</description></item><item><title>CogniVault Backend Explained, Part 4 · Study Tools, Progress, and the Privacy Receipts</title><link>https://aretascodes.dev/blog/backend-explained-study-hub-privacy/</link><pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/blog/backend-explained-study-hub-privacy/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;All abbreviations are fully explained in the appendix at the bottom of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In
we followed a question through hybrid retrieval and the agent loop to a cited answer. In this final part, the same machinery gets pointed at a different goal: &lt;em&gt;teaching you&lt;/em&gt; — and then we close the series by auditing the project&amp;rsquo;s central promise: nothing leaves your machine.&lt;/p&gt;
&lt;h2 id="one-recipe-four-study-tools"&gt;One recipe, four study tools&lt;/h2&gt;
&lt;p&gt;CogniVault generates quizzes, multi-lesson workshops, flashcard decks, and mindmaps from your documents. Four different outputs — but under the hood, one shared five-step recipe:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Retrieve.&lt;/strong&gt; The same hybrid search from Part 3, but instead of your question, the probe is a broad query like &lt;em&gt;&amp;ldquo;key concepts, definitions, important facts, main ideas&amp;rdquo;&lt;/em&gt;, scoped to the documents you selected. Up to 15 representative chunks come back.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Prompt from a template.&lt;/strong&gt; The instructions sent to Gemma are not buried in Python — they&amp;rsquo;re editable Markdown files in &lt;code&gt;backend/prompts/&lt;/code&gt; (&lt;code&gt;quiz.md&lt;/code&gt;, &lt;code&gt;flashcards.md&lt;/code&gt;, and so on). Drop a modified copy into &lt;code&gt;backend/prompts/custom/&lt;/code&gt; and it overrides the shipped version on the very next request. No restart, no code change. Prompt engineering as configuration.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Constrain the output.&lt;/strong&gt; Asking a small local model to &amp;ldquo;please return JSON&amp;rdquo; works most of the time — and &lt;em&gt;most of the time&lt;/em&gt; is a production bug. CogniVault uses Ollama&amp;rsquo;s grammar-constrained generation (&lt;code&gt;format=&amp;quot;json&amp;quot;&lt;/code&gt;), which makes invalid JSON impossible rather than unlikely, plus low temperature for consistency. The full saga of getting reliable structure out of a 4-billion-parameter model is in
.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Validate defensively.&lt;/strong&gt; Every generated item is checked field by field, and malformed items are &lt;em&gt;dropped&lt;/em&gt; rather than failing the whole batch. Small models occasionally fumble one question out of ten; a product shouldn&amp;rsquo;t collapse because of it.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Persist.&lt;/strong&gt; Everything lands in SQLite, so quizzes are resumable, workshop progress survives restarts, and flashcard statuses are remembered per deck.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Here&amp;rsquo;s the recipe in motion for a quiz:&lt;/p&gt;
&lt;div class="mermaid"&gt;%%{init: {'sequence': {'actorFontSize': 28, 'messageFontSize': 24, 'loopTextFontSize': 22, 'noteFontSize': 22}}}%%
sequenceDiagram
actor U as You
participant F as Study Hub UI
participant B as FastAPI
participant V as VectorDB
participant O as Ollama (gemma4:e4b)
participant S as SQLite
U-&gt;&gt;F: Pick scope, difficulty, question count
F-&gt;&gt;B: POST /api/study/quiz/generate
B-&gt;&gt;V: Hybrid search, scoped to your documents
V--&gt;&gt;B: Up to 15 representative chunks
B-&gt;&gt;B: Render the quiz.md prompt template
B-&gt;&gt;O: chat(format="json", low temperature)
O--&gt;&gt;B: Grammar-constrained JSON
B-&gt;&gt;B: Validate each question, drop bad ones
B-&gt;&gt;S: Save quiz (resumable later)
B--&gt;&gt;F: Typed response
F--&gt;&gt;U: Play, submit, score — and maybe a new badge
&lt;/div&gt;
&lt;p&gt;The four tools differ only in their template and their shape: quizzes produce multiple-choice and true/false questions with explanations; workshops produce an outline first and then write each lesson &lt;em&gt;on demand&lt;/em&gt; when you open it; flashcards produce front/back pairs; mindmaps produce a topic tree that the frontend renders as an interactive diagram. (That renderer is its own adventure:
.)&lt;/p&gt;
&lt;h2 id="sessions-that-track-themselves"&gt;Sessions that track themselves&lt;/h2&gt;
&lt;p&gt;Most study apps make you press a start button, and most people forget. CogniVault takes a different stance: &lt;strong&gt;study sessions are inferred, not declared&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Every chat message either extends the current session or — after a 15-minute idle gap — quietly starts a new one. Walk away for coffee, come back, keep working: same session. Come back tomorrow: new session. No buttons, no forgetting.&lt;/p&gt;
&lt;p&gt;Each message also records a tiny event (timestamp, whether you used a scope filter or attachments) into &lt;code&gt;progress.db&lt;/code&gt; — a SQLite database, which is a complete relational database living in a single file. Eleven tables hold everything: sessions, message events, earned badges, quiz attempts and saved quizzes, workshops and lessons, decks and cards, and mindmaps.&lt;/p&gt;
&lt;p&gt;One engineering note worth copying: the tracking call inside the chat endpoint is wrapped so that it can &lt;em&gt;never&lt;/em&gt; block or break the chat. Analytics must be a passenger, never a driver.&lt;/p&gt;
&lt;h2 id="25-badges-defined-as-data"&gt;25 badges, defined as data&lt;/h2&gt;
&lt;p&gt;The achievements aren&amp;rsquo;t scattered through the code as &lt;code&gt;if&lt;/code&gt; statements. They live in one JSON file — 25 entries, each with a code, a name, an icon, the metric it watches, and a target. After each relevant action, an evaluator checks every definition against the database and persists anything newly earned. Some badges form ladders, each pointing to its next level.&lt;/p&gt;
&lt;p&gt;Declarative beats imperative here for a simple reason: adding badge number 26 means adding a JSON entry, not writing new logic. The design behind the streaks, the idle-gap rule, and the 90-day heatmap got its own post:
.&lt;/p&gt;
&lt;h2 id="voice-input-without-a-cloud-microphone"&gt;Voice input, without a cloud microphone&lt;/h2&gt;
&lt;p&gt;The microphone button is powered by &lt;strong&gt;faster-whisper&lt;/strong&gt; — OpenAI&amp;rsquo;s Whisper speech-recognition model re-implemented on a faster inference engine — running on your CPU with int8 quantisation (8-bit numbers instead of 32-bit: smaller, faster, accurate enough). No audio ever leaves the machine.&lt;/p&gt;
&lt;p&gt;The model is lazy-loaded on the first transcription so app startup stays instant, and if faster-whisper isn&amp;rsquo;t installed at all, the frontend simply hides the mic button. Features should degrade, not detonate.&lt;/p&gt;
&lt;h2 id="the-privacy-receipts"&gt;The privacy receipts&lt;/h2&gt;
&lt;p&gt;The series began with a promise: &lt;em&gt;nothing leaves your machine.&lt;/em&gt; Promises are cheap — here&amp;rsquo;s the audit. Every byte CogniVault stores, and where it lives:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data&lt;/th&gt;
&lt;th&gt;Location&lt;/th&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Your uploaded files&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docs/&lt;/code&gt; folder&lt;/td&gt;
&lt;td&gt;The original files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search vectors&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vector_store.faiss&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;FAISS binary index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chunk text and metadata&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vector_store.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File-to-category map&lt;/td&gt;
&lt;td&gt;&lt;code&gt;categories.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chat sessions&lt;/td&gt;
&lt;td&gt;&lt;code&gt;chat_history.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sessions, badges, quizzes, workshops, decks, mindmaps&lt;/td&gt;
&lt;td&gt;&lt;code&gt;progress.db&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ingestion checkpoints&lt;/td&gt;
&lt;td&gt;PostgreSQL (local Docker volume)&lt;/td&gt;
&lt;td&gt;DBOS system tables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The AI models themselves&lt;/td&gt;
&lt;td&gt;Ollama&amp;rsquo;s local model store&lt;/td&gt;
&lt;td&gt;Model weights&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Nothing in that table is on someone else&amp;rsquo;s computer. Inference goes to &lt;code&gt;localhost&lt;/code&gt;. Embeddings go to &lt;code&gt;localhost&lt;/code&gt;. The only outbound request the backend ever makes is the URL-import feature — at your explicit request, and guarded against fetching private addresses. The app even surfaces these stats live in its Privacy Vault Audit panel.&lt;/p&gt;
&lt;p&gt;And because trust needs more than a table: the whole backend is covered by a pytest suite you can run yourself — the approach is documented in
.&lt;/p&gt;
&lt;h2 id="series-wrap-up"&gt;Series wrap-up&lt;/h2&gt;
&lt;p&gt;Four parts, one architecture:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;
&lt;/strong&gt; — three processes, four layers, and a decoder ring for the jargon&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;
&lt;/strong&gt; — a durable, format-aware pipeline that turns any document into searchable vectors&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;
&lt;/strong&gt; — two retrievers covering each other&amp;rsquo;s blind spots, fused by rank, driven by an agent&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Part 4&lt;/strong&gt; — the same machinery generating study materials, tracking progress without buttons, and a storage map with no cloud rows in it&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If there&amp;rsquo;s one theme, it&amp;rsquo;s this: &lt;strong&gt;boring, verifiable choices in service of privacy&lt;/strong&gt;. Exact search instead of approximate. SQLite files instead of hosted databases. Grammar-constrained JSON instead of hopeful parsing. Soft deletes instead of clever index surgery. Every piece is something you can open, read, and check — which is exactly the point.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="appendix-abbreviations-in-this-post"&gt;Appendix: Abbreviations in this post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;The structured format the generators force the model to produce&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQLite / SQL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;(SQL = Structured Query Language)&lt;/td&gt;
&lt;td&gt;A complete relational database living in one file, &lt;code&gt;progress.db&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MCQ&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-Choice Question&lt;/td&gt;
&lt;td&gt;One of the two quiz question types (the other is true/false)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CPU&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Central Processing Unit&lt;/td&gt;
&lt;td&gt;Where Whisper runs — no graphics card required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;int8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8-bit integer (quantisation)&lt;/td&gt;
&lt;td&gt;Storing model weights as small integers: smaller, faster, accurate enough&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Artificial Intelligence&lt;/td&gt;
&lt;td&gt;Software performing tasks that normally need human intelligence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;The endpoints the Study Hub and dashboard call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;The vector index in the privacy-receipts table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System&lt;/td&gt;
&lt;td&gt;The durable-workflow library whose checkpoints live in PostgreSQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SSRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Server-Side Request Forgery&lt;/td&gt;
&lt;td&gt;The attack class the URL importer guards against&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PNG / PDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Network Graphics / Portable Document Format&lt;/td&gt;
&lt;td&gt;Two of the mindmap export formats (plus Markdown)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SVG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scalable Vector Graphics&lt;/td&gt;
&lt;td&gt;The browser drawing format behind the interactive mindmap rendering&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Next steps:&lt;/strong&gt; clone
and read along — the README maps the full architecture, and every claim in this series can be checked directly against the code in &lt;code&gt;backend/&lt;/code&gt;. And if you want the deep-dive versions of these topics, the
picks up where this tour ends.&lt;/p&gt;</description></item><item><title>Part 3 · CogniVault Architecture: Why We Keep Ollama Out of Docker</title><link>https://aretascodes.dev/blog/cognivault-deployment-architecture/</link><pubDate>Wed, 03 Jun 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/blog/cognivault-deployment-architecture/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;All abbreviations are fully explained in the appendix at the bottom of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The golden rule of modern software deployment is containerization. Put everything in Docker to isolate the dependencies, and it will run the exact same way on every machine.&lt;/p&gt;
&lt;p&gt;When initially designing CogniVault, the impulse was to put the FastAPI server, the PostgreSQL database, and the Ollama LLM engine all inside a single, secure Docker network.&lt;/p&gt;
&lt;p&gt;But we didn&amp;rsquo;t. We left Ollama running natively on the host machine. Let&amp;rsquo;s break down why.&lt;/p&gt;
&lt;h2 id="the-gpu-passthrough-problem"&gt;The GPU Passthrough Problem&lt;/h2&gt;
&lt;p&gt;Think of your GPU like the kitchen in a restaurant. The chefs (your AI models) need to &lt;em&gt;be in the kitchen&lt;/em&gt; — standing at the stove, hands on the equipment. Now imagine telling the chefs they must cook from a sealed meeting room down the hall, passing instructions through a serving hatch. Technically food might still come out. It will not come out fast.&lt;/p&gt;
&lt;p&gt;That sealed room is a container. Large Language Models like Gemma 4 need direct, unhindered access to your hardware&amp;rsquo;s GPU (like Apple Silicon&amp;rsquo;s Unified Memory or a dedicated Nvidia card) to generate text fast enough for a real-time chat interface. And the picture varies by platform:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;On macOS&lt;/strong&gt;, Docker runs containers inside a lightweight virtual machine — and there is currently &lt;strong&gt;no GPU (Metal) passthrough at all&lt;/strong&gt;. An Ollama container on a Mac runs CPU-only. For a chat app, that&amp;rsquo;s disqualifying on its own.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;On Linux&lt;/strong&gt;, Nvidia GPU passthrough exists and works, but it requires extra toolkit configuration that breaks the &amp;ldquo;it just works&amp;rdquo; philosophy of local development.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Running Ollama natively sidesteps the whole category of problems.&lt;/p&gt;
&lt;h2 id="the-bridge-solution"&gt;The Bridge Solution&lt;/h2&gt;
&lt;p&gt;CogniVault uses a split deployment model, separating the application logic from the heavy AI processing.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The Secure Rooms (Docker):&lt;/strong&gt; PostgreSQL — which holds the DBOS workflow ledger from
— lives in a &lt;strong&gt;Docker Bridge Network&lt;/strong&gt; (a private virtual network). Isolated, clean, reproducible.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Main Building (Native Host):&lt;/strong&gt; Ollama runs directly on your Mac, Windows, or Linux host OS, giving it direct metal access to your GPU.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;CogniVault actually ships &lt;strong&gt;two run modes&lt;/strong&gt;, and it&amp;rsquo;s worth being precise about them:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The default (&lt;code&gt;scripts/start.sh&lt;/code&gt;):&lt;/strong&gt; only PostgreSQL runs in Docker. The FastAPI backend runs natively too (&lt;code&gt;python -m backend.main&lt;/code&gt;), right next to Ollama. Simplest possible loop for local development.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The fully containerized mode (&lt;code&gt;docker-compose.yaml&lt;/code&gt;):&lt;/strong&gt; the FastAPI app joins Postgres inside the compose network. In this mode the app container reaches the native Ollama engine through a special Docker routing address: &lt;code&gt;host.docker.internal:11434&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Either way, the rule stays the same: &lt;strong&gt;the model never goes in the box.&lt;/strong&gt;&lt;/p&gt;
&lt;div class="mermaid"&gt;graph TD
Client[📱 Browser / User] --&gt;|HTTP: 8000| App
subgraph Host Machine [Host OS: Native GPU Access]
Ollama[🧠 Ollama Engine]
Models[(gemma4:e4b)]
Ollama &lt;--&gt; Models
subgraph Docker Compose Network
App[🖥️ FastAPI App Container]
Postgres[(🐘 PostgreSQL)]
App &lt;--&gt;|Internal Port 5432| Postgres
end
App &lt;--&gt;|host.docker.internal:11434| Ollama
end
&lt;/div&gt;
&lt;h3 id="what-about-the-vector-database"&gt;What about the Vector Database?&lt;/h3&gt;
&lt;p&gt;You might notice FAISS isn&amp;rsquo;t a container here. Unlike massive SQL databases, FAISS is extremely lightweight. In CogniVault, FAISS runs directly inside the FastAPI Python process&amp;rsquo;s memory and saves its data to a local folder. It doesn&amp;rsquo;t need its own container.&lt;/p&gt;
&lt;p&gt;By keeping the heavy LLM lifting on the metal and the bookkeeping in containers, we get the balance that notoriously trips up local AI development: zero dependency conflicts combined with maximum AI performance.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id="see-it-in-action"&gt;See It In Action&lt;/h3&gt;
&lt;p&gt;That wraps up the CogniVault architecture series! If you want to run this 100% local, privacy-first Study Companion on your own hardware:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Grab the code:&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Watch the walkthrough:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id="appendix-abbreviations-in-this-post"&gt;Appendix: Abbreviations in this post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GPU&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Graphics Processing Unit&lt;/td&gt;
&lt;td&gt;The hardware that makes local model inference fast; containers struggle to reach it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large Language Model&lt;/td&gt;
&lt;td&gt;A neural network trained on huge amounts of text that can read and generate language&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Artificial Intelligence&lt;/td&gt;
&lt;td&gt;Software performing tasks that normally need human intelligence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;The set of URLs the frontend calls to talk to the backend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTTP&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HyperText Transfer Protocol&lt;/td&gt;
&lt;td&gt;The protocol browsers and APIs use to exchange requests and responses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Operating System&lt;/td&gt;
&lt;td&gt;macOS, Windows, or Linux — where Ollama runs natively&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System&lt;/td&gt;
&lt;td&gt;The durable-workflow library whose ledger lives in the Postgres container (see Part 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Structured Query Language&lt;/td&gt;
&lt;td&gt;The language of relational databases like PostgreSQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;The in-process vector index — deliberately &lt;em&gt;not&lt;/em&gt; a separate container&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;VM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Virtual Machine&lt;/td&gt;
&lt;td&gt;The hidden layer Docker uses on macOS — and the reason Mac containers can&amp;rsquo;t reach the GPU&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;</description></item><item><title>Part 2 · CogniVault Architecture: Durable Ingestion with DBOS</title><link>https://aretascodes.dev/blog/cognivault-ingestion-pipeline/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/blog/cognivault-ingestion-pipeline/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;All abbreviations are fully explained in the appendix at the bottom of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In a basic local AI setup, adding documents to your database is usually just a simple Python script. You open a PDF, chop the text into chunks, turn those chunks into math (embeddings), and save them.&lt;/p&gt;
&lt;p&gt;This works great for a five-page essay. But what happens when you are ingesting a 1,000-page technical manual and your laptop goes to sleep at page 800?&lt;/p&gt;
&lt;p&gt;The script dies. When you wake your laptop up, you have to start all over from page 1, wasting time and compute power. A simple script wasn&amp;rsquo;t going to cut it for CogniVault. We needed a &lt;strong&gt;Durable Workflow&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 id="the-factory-ledger-dbos"&gt;The Factory Ledger (DBOS)&lt;/h2&gt;
&lt;p&gt;Think of data ingestion like a factory assembly line. If the power goes out, the workers shouldn&amp;rsquo;t have to rebuild every product from scratch. They should just look at a permanent ledger, see exactly which box they were packing when the lights went out, and resume from there.&lt;/p&gt;
&lt;p&gt;CogniVault uses a framework called &lt;strong&gt;DBOS (Database-Oriented Operating System)&lt;/strong&gt; backed by a PostgreSQL database to act as this ledger.&lt;/p&gt;
&lt;p&gt;Every step of the ingestion process records its completion in Postgres. If the server crashes mid-way, nothing dramatic happens in the moment — the magic is on restart: DBOS reads the ledger, sees which steps already finished, replays their recorded results instantly, and resumes from the first unfinished step.&lt;/p&gt;
&lt;p&gt;One important boundary: Postgres holds &lt;strong&gt;only the ledger&lt;/strong&gt; — which steps ran and what they returned. Your documents, chunks, and vectors never live there. They go to a FAISS index plus a JSON metadata file on disk.&lt;/p&gt;
&lt;h2 id="sha-256-hashing-the-idempotency-trick"&gt;SHA-256 Hashing: The Idempotency Trick&lt;/h2&gt;
&lt;p&gt;The system also needs to be smart about re-uploads. If you fix a typo in a massive document and upload it again, you don&amp;rsquo;t want the system to waste 10 minutes re-embedding the whole thing.&lt;/p&gt;
&lt;p&gt;CogniVault achieves &lt;strong&gt;Idempotency&lt;/strong&gt; (the ability to run the same operation multiple times without changing the result beyond the initial application) with the workflow&amp;rsquo;s very first step: it scans the &lt;code&gt;docs/&lt;/code&gt; folder and generates a &lt;strong&gt;SHA-256 hash&lt;/strong&gt; (a unique digital fingerprint) for every file.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If the hash is new, it processes the file.&lt;/li&gt;
&lt;li&gt;If the hash has changed (because you edited the file), it soft-deletes the old text chunks and only re-embeds the new version.&lt;/li&gt;
&lt;li&gt;If the hash is identical, it skips the file entirely.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We can see here how this flows logically:&lt;/p&gt;
&lt;div class="mermaid"&gt;graph TD
Raw[📄 Uploaded Document] --&gt; DBOS[🐘 DBOS Workflow Starts]
subgraph Durable Ingestion Pipeline
DBOS --&gt;|Step 1| Hash{Hash Check SHA-256}
Hash --&gt;|Unchanged| Skip[Skip Processing]
Hash --&gt;|New / Changed| Extract[✂️ Step 2: Extract Text per Document]
Extract --&gt; Chunk[Chunk: 1000 chars, 100 overlap]
Chunk --&gt;|Step 3, batches of 5| Embed[🔢 embeddinggemma Embeddings]
Embed --&gt;|Step 4| Save[(💾 FAISS Index + Metadata JSON)]
end
Save --&gt;|Workflow Complete| Done[✅ Ready for Search]
&lt;/div&gt;
&lt;p&gt;(A detail for the curious: the checkpointed &lt;em&gt;steps&lt;/em&gt; are the scan, the per-document extraction, each embedding batch, and the save. The chunking in between is fast pure-Python work, so it simply re-runs as part of the workflow body — checkpointing it would cost more than redoing it.)&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id="whats-next"&gt;What&amp;rsquo;s Next?&lt;/h3&gt;
&lt;p&gt;By wrapping the ingestion pipeline in DBOS, the system transforms from a fragile script into a resilient, production-grade state machine.&lt;/p&gt;
&lt;p&gt;Now that our data is safely ingested, how do we deploy this entire pipeline without melting our laptop&amp;rsquo;s GPU?
&lt;strong&gt;Read Part 3: Why We Keep Ollama Out of Docker&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;You can also explore the DBOS implementation directly in the &lt;code&gt;backend/services/ingest.py&lt;/code&gt; file of the
.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="appendix-abbreviations-in-this-post"&gt;Appendix: Abbreviations in this post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System&lt;/td&gt;
&lt;td&gt;A library that checkpoints workflow steps in a database so crashed jobs resume instead of restarting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SHA-256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Secure Hash Algorithm, 256-bit&lt;/td&gt;
&lt;td&gt;A fingerprint function: any file maps to a unique 64-character hash; change one byte and the hash changes completely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Document Format&lt;/td&gt;
&lt;td&gt;The document format whose text (and scans) the pipeline extracts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Meta&amp;rsquo;s vector-search library — where the embeddings actually live&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;The text format used for the chunk-metadata file stored next to the FAISS index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Artificial Intelligence&lt;/td&gt;
&lt;td&gt;Software performing tasks that normally need human intelligence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GPU&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Graphics Processing Unit&lt;/td&gt;
&lt;td&gt;The hardware that makes local model inference fast — the subject of Part 3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;</description></item><item><title>Part 1 · CogniVault Architecture: Why Standard RAG Isn't Enough (Hybrid Search)</title><link>https://aretascodes.dev/blog/cognivault-retrieval-loop/</link><pubDate>Mon, 01 Jun 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/blog/cognivault-retrieval-loop/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;All abbreviations are fully explained in the appendix at the bottom of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Vector search is the process of finding the most similar items in a dataset based on their vector embeddings. This is how RAG systems usually work.
But what happens when you need to find the most similar items in a dataset based not only on their semantic meaning but also on the exact wording of the query?&lt;/p&gt;
&lt;p&gt;This becomes critical when the information you&amp;rsquo;re looking for isn&amp;rsquo;t just related but must match a specific string or keyword exactly.&lt;/p&gt;
&lt;h2 id="two-ways-of-finding-a-book"&gt;Two ways of finding a book&lt;/h2&gt;
&lt;p&gt;Picture a good local bookshop. The owner has read everything, and she recommends by &lt;em&gt;feel&lt;/em&gt;. Tell her you loved &lt;em&gt;The Martian&lt;/em&gt; and she hands you &lt;em&gt;Project Hail Mary&lt;/em&gt; — different title, different plot, but the same DNA: a lone scientist, an impossible survival problem, jokes under pressure. Ask for &amp;ldquo;something like &lt;em&gt;Pride and Prejudice&lt;/em&gt;&amp;rdquo; and you&amp;rsquo;ll walk out with &lt;em&gt;Emma&lt;/em&gt;. She isn&amp;rsquo;t matching words. She&amp;rsquo;s matching &lt;em&gt;meaning&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Now ask her a different kind of question: &amp;ldquo;I need the book with ISBN 978-0-553-41802-6,&amp;rdquo; or &amp;ldquo;the manual that mentions error code 404B on the cover.&amp;rdquo; Her superpower is useless here. No amount of literary intuition finds an exact string. For that, you walk to the till and check the &lt;strong&gt;catalogue&lt;/strong&gt; — a boring, literal index that knows exactly which shelf holds which identifier, and nothing about vibes.&lt;/p&gt;
&lt;p&gt;A well-run bookshop needs both. So does a well-run RAG system:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;FAISS — Facebook AI Similarity Search (the well-read owner):&lt;/strong&gt; a vector index that finds chunks of text whose &lt;em&gt;meaning&lt;/em&gt; is mathematically close to your prompt. Brilliant for &amp;ldquo;how is the practical exam structured?&amp;rdquo;, blind to &amp;ldquo;§3 Absatz 2&amp;rdquo;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;BM25 — Best Match 25 (the catalogue):&lt;/strong&gt; a classic keyword-scoring algorithm that rewards exact word matches, weighted by how rare and distinctive those words are. Brilliant for identifiers and quoted phrases, blind to paraphrase.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;CogniVault runs &lt;strong&gt;both&lt;/strong&gt; retrievers on every search — this is &lt;strong&gt;Hybrid Search&lt;/strong&gt; — and then merges the two ranked lists with a formula called &lt;strong&gt;Reciprocal Rank Fusion (RRF)&lt;/strong&gt;. RRF scores each chunk purely by its &lt;em&gt;position&lt;/em&gt; in each list: a chunk ranked highly by either retriever scores well, and a chunk both retrievers agree on rises to the top. Because only ranks are used, the two retrievers&amp;rsquo; incompatible scoring scales never have to be reconciled.&lt;/p&gt;
&lt;h2 id="the-agent-decides-when-to-search"&gt;The agent decides when to search&lt;/h2&gt;
&lt;p&gt;Here&amp;rsquo;s the part most diagrams get backwards (mine included, in an earlier draft): retrieval doesn&amp;rsquo;t happen &lt;em&gt;before&lt;/em&gt; the model gets involved. It happens &lt;em&gt;inside&lt;/em&gt; the model&amp;rsquo;s own loop.&lt;/p&gt;
&lt;p&gt;CogniVault wraps Gemma in the &lt;strong&gt;Strands Agents SDK&lt;/strong&gt;. The model receives your question along with a set of &lt;strong&gt;Tools&lt;/strong&gt; (pre-written Python functions like &lt;code&gt;search_knowledge_base&lt;/code&gt;, &lt;code&gt;calculator&lt;/code&gt;, or &lt;code&gt;compare_documents&lt;/code&gt;). It then reasons about the question and &lt;em&gt;decides for itself&lt;/em&gt; whether — and which — tools to call. For most document questions it calls &lt;code&gt;search_knowledge_base&lt;/code&gt;, reads the retrieved chunks, and only then writes its answer, grounded in what it found.&lt;/p&gt;
&lt;p&gt;Here is the architectural blueprint of that loop:&lt;/p&gt;
&lt;div class="mermaid"&gt;graph TD
Client[📱 User Query] --&gt; App[🖥️ FastAPI Server]
subgraph AgentLoop["The Strands Agent Loop (powered by Gemma 4)"]
App --&gt; Agent[🧠 Agent reasons about the question]
Agent --&gt;|Decides to search| Search[search_knowledge_base]
subgraph Hybrid Search Engine
Search --&gt;|Semantic| FAISS[(FAISS Vector)]
Search --&gt;|Exact match| BM25[(BM25 Keyword)]
FAISS --&gt; RRF{RRF Fusion}
BM25 --&gt; RRF
end
RRF --&gt;|Best chunks + citations| Agent
Agent --&gt;|Grounded answer| Answer[Streamed response]
end
Answer --&gt; Client
&lt;/div&gt;
&lt;p&gt;One subtlety worth noting: the agent &lt;em&gt;is&lt;/em&gt; Gemma. There is no separate &amp;ldquo;formatting model&amp;rdquo; at the end — the same model that decided to search also writes the final answer, now with the retrieved chunks in front of it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id="whats-next"&gt;What&amp;rsquo;s Next?&lt;/h3&gt;
&lt;p&gt;Building a toy RAG app is easy, but building one that actually retrieves the exact document you need requires hybrid engines and an agent that knows when to use them.&lt;/p&gt;
&lt;p&gt;Want to see how this system safely ingests massive documents without losing work when something crashes?
&lt;strong&gt;Read Part 2: Durable Ingestion with DBOS&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Or, if you prefer to jump straight into the code, the hybrid search lives in &lt;code&gt;backend/services/vector_db.py&lt;/code&gt; of the
.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="appendix-abbreviations-in-this-post"&gt;Appendix: Abbreviations in this post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Retrieval-Augmented Generation&lt;/td&gt;
&lt;td&gt;Retrieve relevant passages from your own documents first; let the model answer from them instead of from training memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Meta&amp;rsquo;s library for storing vectors and finding the most similar ones fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BM25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Best Match 25&lt;/td&gt;
&lt;td&gt;A keyword-ranking formula — the 25th ranking function developed in the Okapi information-retrieval system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reciprocal Rank Fusion&lt;/td&gt;
&lt;td&gt;A formula that merges multiple ranked lists using only each item&amp;rsquo;s rank: &lt;code&gt;score = Σ 1/(k + rank)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large Language Model&lt;/td&gt;
&lt;td&gt;A neural network trained on huge amounts of text that can read and generate language&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SDK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Software Development Kit&lt;/td&gt;
&lt;td&gt;A library of building blocks — here, Strands, which provides the agent loop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;The set of URLs the frontend calls to talk to the backend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ISBN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;International Standard Book Number&lt;/td&gt;
&lt;td&gt;The unique identifier printed on every published book — the catalogue&amp;rsquo;s best friend&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;</description></item><item><title>Gemma CogniVault</title><link>https://aretascodes.dev/projects/cognivault/</link><pubDate>Mon, 25 May 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/projects/cognivault/</guid><description>&lt;h2 id="overview"&gt;Overview&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Gemma CogniVault&lt;/strong&gt; is a 100% local, privacy-first AI study companion. Your documents stay on your hardware. Inference runs via Ollama on &lt;code&gt;localhost&lt;/code&gt;. No telemetry, no embeddings sent to third parties, no exceptions. A live Privacy Vault Audit Panel confirms zero external connections at runtime.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s also genuinely capable — Gemma 4&amp;rsquo;s full surface (completion, vision, tools, reasoning) running on your laptop, wrapped in an app that turns your documents into &lt;strong&gt;quizzes, multi-lesson workshops, flashcard decks, and visual mindmaps&lt;/strong&gt;, with a learning-progress dashboard and 25 achievement badges.&lt;/p&gt;
&lt;h2 id="whats-inside"&gt;What&amp;rsquo;s inside&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM &amp;amp; Embeddings&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ollama · &lt;code&gt;gemma4:e4b&lt;/code&gt; · &lt;code&gt;embeddinggemma&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Agent Framework&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Strands Agents SDK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Backend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;FastAPI · Python 3.10+ · Pydantic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vector Search&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;FAISS IndexFlatIP + BM25Okapi · Reciprocal Rank Fusion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Document Parsing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;pypdf · python-docx · python-pptx · openpyxl · trafilatura&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OCR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;pytesseract · pymupdf · Pillow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Audio&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;faster-whisper&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Workflow Engine&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;DBOS + PostgreSQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Frontend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;React 19 · TypeScript · Vite · Tailwind v4 · Framer Motion · TanStack Query&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="four-sections"&gt;Four sections&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Section&lt;/th&gt;
&lt;th&gt;What it&amp;rsquo;s for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;💬 Chat&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ask anything about your documents. Cited answers, scope filter, voice, attachments.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;📚 Knowledge Base&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Upload, categorise, and manage your documents. SHA-256 change detection on re-upload.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;🎓 Study Hub&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Four AI-powered study modes: Quiz · Workshop · Flashcards · Mindmaps.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;📊 Dashboard&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Total study time, current streak, 25 achievement badges, 90-day activity heatmap.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="highlights"&gt;Highlights&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;🧠 Thinking Mode&lt;/strong&gt; — collapsible reasoning panel streams Gemma 4&amp;rsquo;s chain of thought before the answer&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;🔍 Hybrid Retrieval&lt;/strong&gt; — FAISS dense + BM25 keyword fused with Reciprocal Rank Fusion&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;🖼️ Multimodal&lt;/strong&gt; — attach images, PDFs, and DOCX inline in chat&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;🛟 Durable workflows&lt;/strong&gt; — DBOS-checkpointed ingestion; crash-safe and resumable&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;🏆 25 achievement badges&lt;/strong&gt; — auto-tracked across chat, quizzes, workshops, flashcards, mindmaps&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;🔒 Vault Audit Panel&lt;/strong&gt; — live &amp;ldquo;zero external connections&amp;rdquo; indicator&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="writing-about-it"&gt;Writing about it&lt;/h2&gt;
&lt;p&gt;I&amp;rsquo;m publishing a series of posts unpacking the engineering decisions behind CogniVault — privacy framing, the retrieval stack, the agent loop, ingestion durability, getting JSON out of a local model, drawing mindmaps without a graph library, the gamification layer, and how the test suite avoids needing any infrastructure to run.&lt;/p&gt;
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;See the
for the full series.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="try-it"&gt;Try it&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git clone https://github.com/ndimoforaretas/local-gemma-rag.git
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; local-gemma-rag
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./scripts/setup.sh &lt;span class="c1"&gt;# one-time&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./scripts/start.sh
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then open
.&lt;/p&gt;</description></item><item><title>Part 8 · Testing a Local-AI App: 351 Tests, Zero Infrastructure</title><link>https://aretascodes.dev/blog/testing-local-ai-app/</link><pubDate>Mon, 25 May 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/blog/testing-local-ai-app/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Part of a series on building
. Previously:
.
All abbreviations are fully explained in the appendix at the bottom of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;CogniVault has &lt;strong&gt;351 tests across 22 files&lt;/strong&gt; (at the time of writing — the suite grows with the app). None of them need Ollama. None of them need Postgres. None of them need a real PDF, a microphone, or an internet connection. The whole suite runs in &lt;strong&gt;about three seconds&lt;/strong&gt; on my laptop.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s not because there isn&amp;rsquo;t much to test — the surface is wide. It&amp;rsquo;s because the test suite is built around one principle: &lt;strong&gt;mock at the edge, real everywhere else.&lt;/strong&gt; This post is about what &amp;ldquo;the edge&amp;rdquo; means in a local-AI app, and how to draw the line so the suite stays useful instead of decorative.&lt;/p&gt;
&lt;h2 id="the-22-test-files"&gt;The 22 test files&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;What it covers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_api.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The HTTP endpoints (upload, ingest, RAG, history, KB browsing)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_tools.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Calculator, clock, KB search tool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_thinking.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Two-phase stream, thinking tokens, session isolation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_chat_attachments.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Multi-file attach, PDF/DOCX extraction, size limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_chat_memory.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Session history budget, trimming, restart rebuild&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_doc_scope_filter.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Per-request ContextVar isolation, search filtering&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_doc_tools.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;list_documents&lt;/code&gt;, &lt;code&gt;analyze_document&lt;/code&gt;, &lt;code&gt;compare_documents&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_edit_regenerate.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;History rewind, trim_history_to_turns validation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_structure_chunking.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Markdown header splits, CSV row batches, doc types&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_ocr_fallback.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OCR trigger threshold, graceful degradation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_new_formats.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PPTX, XLSX, HTML extractors, extension routing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_docx_url.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DOCX ingestion and URL import (with the SSRF guard)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_reingest.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SHA-256 change detection, idempotency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_vector_db.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;BM25, FAISS, RRF fusion, hybrid search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_audio.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whisper transcription endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_progress.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sessions, daily aggregation, achievement criteria&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_prompts.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The prompt-template loader and custom overrides&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_vault_stats.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The Privacy Vault Audit numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_quiz.py&lt;/code&gt; / &lt;code&gt;test_workshop.py&lt;/code&gt; / &lt;code&gt;test_flashcards.py&lt;/code&gt; / &lt;code&gt;test_mindmaps.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Per-mode parsing, endpoints, achievements&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Everything that &lt;em&gt;can&lt;/em&gt; be tested in isolation is tested in isolation. Everything that needs to be tested through the FastAPI layer is, but the &lt;em&gt;only&lt;/em&gt; things mocked are the calls that cross the process boundary.&lt;/p&gt;
&lt;h2 id="what-gets-mocked-what-doesnt"&gt;What gets mocked, what doesn&amp;rsquo;t&lt;/h2&gt;
&lt;p&gt;The single most important question in a project like this: where do you stub?&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript3" data-lang="gdscript3"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="n"&gt;React&lt;/span&gt; &lt;span class="n"&gt;frontend&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="err"&gt;←─&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;backend&lt;/span&gt; &lt;span class="n"&gt;tests&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="err"&gt;│&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="err"&gt;▼&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt; &lt;span class="n"&gt;handlers&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="err"&gt;←─&lt;/span&gt; &lt;span class="n"&gt;tested&lt;/span&gt; &lt;span class="n"&gt;directly&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;TestClient&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="err"&gt;│&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="err"&gt;▼&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="err"&gt;←─&lt;/span&gt; &lt;span class="n"&gt;tested&lt;/span&gt; &lt;span class="n"&gt;directly&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vector_db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rag_agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;generators&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="err"&gt;│&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="err"&gt;├─►&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="n"&gt;FAISS&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;BM25&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="err"&gt;←─&lt;/span&gt; &lt;span class="n"&gt;real&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fast&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="err"&gt;├─►&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="n"&gt;SQLite&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="err"&gt;←─&lt;/span&gt; &lt;span class="n"&gt;real&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;against&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;tmp_path&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="err"&gt;├─►&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="n"&gt;DBOS&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="err"&gt;←─&lt;/span&gt; &lt;span class="n"&gt;patched&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="n"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="n"&gt;Postgres&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="err"&gt;├─►&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="n"&gt;Ollama&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="err"&gt;←─&lt;/span&gt; &lt;span class="n"&gt;patched&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;each&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;s import site&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="err"&gt;└─►&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="n"&gt;Whisper&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="err"&gt;←─&lt;/span&gt; &lt;span class="n"&gt;stubbed&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="mi"&gt;145&lt;/span&gt; &lt;span class="n"&gt;MB&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="nb"&gt;load&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The rule of thumb: &lt;strong&gt;anything that crosses a process or network boundary, mock. Anything in-process, run for real.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;FAISS and BM25 are real because they&amp;rsquo;re libraries we link into the test process. SQLite is real because it&amp;rsquo;s a file. DBOS is patched because launching it expects a Postgres connection, and that&amp;rsquo;s network. Ollama is patched because it&amp;rsquo;s HTTP. Whisper is stubbed because loading a 145 MB model in a unit test is silly.&lt;/p&gt;
&lt;p&gt;That principle keeps the test suite fast (no I/O the OS can&amp;rsquo;t handle in milliseconds) and meaningful (the real code paths through retrieval, chunking, parsing, scope filtering all execute).&lt;/p&gt;
&lt;h2 id="mocking-ollama"&gt;Mocking Ollama&lt;/h2&gt;
&lt;p&gt;Most CogniVault tests need &lt;em&gt;some&lt;/em&gt; model output, but they don&amp;rsquo;t care what model produced it. Each service imports the &lt;code&gt;ollama&lt;/code&gt; module directly, so the tests patch that reference &lt;strong&gt;at the service&amp;rsquo;s own import site&lt;/strong&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Real pattern from test_quiz.py&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;unittest.mock&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;patch&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;backend.services&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;quiz_generator&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_quiz_parses_questions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;fake&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;message&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;content&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;questions&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;VALID_MCQ&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;})}}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quiz_generator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ollama&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;mock_ollama&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;mock_ollama&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;return_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fake&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;quiz_generator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generate_quiz&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;difficulty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;beginner&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;num_questions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;question_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;mcq&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A streaming variant feeds chunk sequences instead of a single response, used by the RAG and thinking tests. The key property: one &lt;code&gt;patch.object&lt;/code&gt; against the module the service actually uses. No deep mock hierarchies, no fragile string paths into third-party internals. Easy to read in a code review, easy to debug when a test fails.&lt;/p&gt;
&lt;h2 id="mocking-dbos"&gt;Mocking DBOS&lt;/h2&gt;
&lt;p&gt;DBOS expects &lt;code&gt;launch()&lt;/code&gt; to connect to Postgres. The shared &lt;code&gt;client&lt;/code&gt; fixture in &lt;code&gt;conftest.py&lt;/code&gt; simply patches the &lt;code&gt;dbos&lt;/code&gt; instance before the app is exercised:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Real pattern from conftest.py&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;A FastAPI TestClient with DBOS launch mocked out — no Postgres needed.&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;backend.services.ingest.dbos&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;mock_dbos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;mock_dbos&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;launch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MagicMock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;backend.main&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;TestClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The decorated workflow steps still execute as ordinary Python functions — we lose the durability semantics, but the tests aren&amp;rsquo;t testing durability, they&amp;rsquo;re testing the &lt;em&gt;business logic inside the steps&lt;/em&gt; (hash detection, extraction, chunking). The durability layer has its own tests upstream, in DBOS&amp;rsquo;s own suite.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a second isolation layer that runs on &lt;strong&gt;every&lt;/strong&gt; test automatically: an autouse fixture points the docs folder, FAISS index, and metadata file at a per-test &lt;code&gt;tmp_path&lt;/code&gt; via environment variables, so no test can ever touch real data on disk.&lt;/p&gt;
&lt;h2 id="real-sqlite-with-one-override"&gt;Real SQLite, with one override&lt;/h2&gt;
&lt;p&gt;Progress tracking, achievements, quiz storage, deck CRUD — all SQLite. The progress tracker exposes a single test seam: a module-level path override.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Real pattern from test_quiz.py&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autouse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_isolate_progress_db&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;monkeypatch&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;monkeypatch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;progress_tracker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;_db_path_override&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;progress_test.db&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Every test gets a fresh database file; the schema auto-creates on first use. No connection pooling drama, no leaked state between tests, no in-memory &lt;code&gt;:memory:&lt;/code&gt; gymnastics. Just a temp file per test.&lt;/p&gt;
&lt;p&gt;This is the kind of test that catches bugs an SQL-level mock would never see — a missing index, a botched migration, a constraint that doesn&amp;rsquo;t fire. SQLite is fast enough on every machine I&amp;rsquo;ve ever owned that &amp;ldquo;use the real database&amp;rdquo; isn&amp;rsquo;t even a trade-off.&lt;/p&gt;
&lt;h2 id="the-testclient-pattern"&gt;The TestClient pattern&lt;/h2&gt;
&lt;p&gt;For HTTP tests, FastAPI&amp;rsquo;s &lt;code&gt;TestClient&lt;/code&gt; runs the app in-process. The upload, the validation, the chunking, the vector-store update, the response serialisation — every layer runs for real. Only the calls that would leave the process (the Ollama embedding call inside ingestion, the model call inside generation) are patched. That&amp;rsquo;s the right line: the test verifies the &lt;em&gt;integration&lt;/em&gt; of those layers, but doesn&amp;rsquo;t depend on an external service.&lt;/p&gt;
&lt;p&gt;The streaming endpoint tests use a slightly different style — they iterate the response body and parse each &lt;strong&gt;NDJSON&lt;/strong&gt; line (one JSON envelope per line, as described in
) — but the principle is identical.&lt;/p&gt;
&lt;h2 id="coverage-gaps-i-accept"&gt;Coverage gaps I accept&lt;/h2&gt;
&lt;p&gt;Three things the test suite &lt;em&gt;doesn&amp;rsquo;t&lt;/em&gt; cover:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The frontend.&lt;/strong&gt; No React testing in this suite — that&amp;rsquo;s a separate concern. Most failures show up in API tests anyway, because the frontend is a thin client over a typed API.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Real Ollama prompt quality.&lt;/strong&gt; Whether &lt;code&gt;gemma4:e4b&lt;/code&gt; actually produces &lt;em&gt;useful&lt;/em&gt; quiz questions is not a thing tests can answer. That&amp;rsquo;s evaluation, not testing. It belongs in a separate harness with a real model running.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Race conditions across DBOS workflow restarts.&lt;/strong&gt; The resume path is exercised at the logic level, but the full state space of &amp;ldquo;what happens if Postgres goes away at this exact instant&amp;rdquo; is too large to enumerate.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;These are conscious gaps. The test suite is for catching regressions in code I wrote; it&amp;rsquo;s not a replacement for evaluation, integration testing, or actual chaos engineering.&lt;/p&gt;
&lt;h2 id="what-the-suite-is-actually-for"&gt;What the suite is actually for&lt;/h2&gt;
&lt;p&gt;Two things, in order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Refactor confidence.&lt;/strong&gt; When I rip out the agent loop and put a new one in, do the tests still pass? If yes, the API contracts I care about haven&amp;rsquo;t drifted.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PR review surface.&lt;/strong&gt; Every PR runs the suite in CI. A green run is a precondition for merge. The suite is loud enough that a real regression makes the noise.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Notice what it &lt;em&gt;isn&amp;rsquo;t&lt;/em&gt; for: proving the model works. It can&amp;rsquo;t. Tests can pin behaviour but they can&amp;rsquo;t pin quality. That&amp;rsquo;s a different muscle, and it belongs in a different harness.&lt;/p&gt;
&lt;h2 id="whats-worth-borrowing"&gt;What&amp;rsquo;s worth borrowing&lt;/h2&gt;
&lt;p&gt;If you&amp;rsquo;re building a local-AI app and your tests need Ollama running:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Patch the &lt;code&gt;ollama&lt;/code&gt; module at each service&amp;rsquo;s import site with &lt;code&gt;patch.object(service_module, &amp;quot;ollama&amp;quot;)&lt;/code&gt; — one seam per service, no shims required.&lt;/li&gt;
&lt;li&gt;Give your DB layer a path override and run against a &lt;code&gt;tmp_path&lt;/code&gt; SQLite file.&lt;/li&gt;
&lt;li&gt;Use an autouse fixture to redirect every on-disk artefact (docs folder, index files) to &lt;code&gt;tmp_path&lt;/code&gt;, so no test can touch real data even by accident.&lt;/li&gt;
&lt;li&gt;For each external service (model, audio, workflow engine), draw the seam at the process boundary. Test everything above it with real code.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The result is a suite where every test runs in any environment, finishes in milliseconds, and exercises the actual integration of every layer of code you wrote. 351 tests in about three seconds isn&amp;rsquo;t an optimisation, it&amp;rsquo;s a side-effect of mocking only at the edges.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="appendix-abbreviations-in-this-post"&gt;Appendix: Abbreviations in this post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Continuous Integration&lt;/td&gt;
&lt;td&gt;Automatically running the test suite on every push/PR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pull Request&lt;/td&gt;
&lt;td&gt;A proposed code change — merged only when the suite is green&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;The HTTP surface the TestClient exercises in-process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTTP&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HyperText Transfer Protocol&lt;/td&gt;
&lt;td&gt;The protocol the (in-process) endpoint tests speak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Retrieval-Augmented Generation&lt;/td&gt;
&lt;td&gt;The retrieval-then-answer pipeline under test&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;KB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Knowledge Base&lt;/td&gt;
&lt;td&gt;The indexed document collection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Real in tests — it&amp;rsquo;s an in-process library&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BM25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Best Match 25&lt;/td&gt;
&lt;td&gt;The keyword index — also real in tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reciprocal Rank Fusion&lt;/td&gt;
&lt;td&gt;The rank-merging formula covered by &lt;code&gt;test_vector_db.py&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQLite / SQL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;(SQL = Structured Query Language)&lt;/td&gt;
&lt;td&gt;The real, file-based database every progress test runs against&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System&lt;/td&gt;
&lt;td&gt;The durable-workflow library — patched so no Postgres is needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OCR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Optical Character Recognition&lt;/td&gt;
&lt;td&gt;The scanned-PDF fallback with its own trigger-threshold tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SSRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Server-Side Request Forgery&lt;/td&gt;
&lt;td&gt;The URL-import attack class covered in &lt;code&gt;test_docx_url.py&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NDJSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Newline-Delimited JSON&lt;/td&gt;
&lt;td&gt;The streaming format the endpoint tests parse line by line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SHA-256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Secure Hash Algorithm, 256-bit&lt;/td&gt;
&lt;td&gt;The content fingerprint behind the re-ingest tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CRUD&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Create, Read, Update, Delete&lt;/td&gt;
&lt;td&gt;The basic storage operations for decks, quizzes, and maps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF / DOCX / PPTX / XLSX / HTML&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Document Format / Word / PowerPoint / Excel / HyperText Markup Language&lt;/td&gt;
&lt;td&gt;The extractor formats with dedicated tests&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;That&amp;rsquo;s the series. Eight posts on the parts of
I&amp;rsquo;m most proud of — and a handful I&amp;rsquo;d build differently. If any of it was useful to you, the code is open source at
, and the
is on YouTube.&lt;/p&gt;
&lt;p&gt;Your data. Your hardware. Your AI. Your vault.&lt;/p&gt;</description></item><item><title>Part 7 · Gamifying Learning: 25 Badges, Idle-Gap Sessions, and a 90-Day Heatmap</title><link>https://aretascodes.dev/blog/gamifying-learning-badges-heatmap/</link><pubDate>Wed, 20 May 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/blog/gamifying-learning-badges-heatmap/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Part of a series on building
. Previously:
.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;All abbreviations are fully explained in the appendix at the bottom of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I taught secondary-school ICT for eight years before pivoting to full-stack development, and the most reliable lesson from that period was uncomfortably simple: &lt;strong&gt;students who showed up consistently learned. Students who didn&amp;rsquo;t, didn&amp;rsquo;t.&lt;/strong&gt; Talent, prior knowledge, even motivation on any given day — all of it was downstream of attendance.&lt;/p&gt;
&lt;p&gt;CogniVault&amp;rsquo;s &amp;ldquo;Dashboard&amp;rdquo; tab is a small attempt to engineer for that. It&amp;rsquo;s not a Duolingo streak panic machine. It&amp;rsquo;s three things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hero stats&lt;/strong&gt; — total study time, total sessions, current streak.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;25 achievement badges&lt;/strong&gt; — auto-tracked across chat, quizzes, workshops, flashcards, mindmaps.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A 90-day activity heatmap&lt;/strong&gt; — GitHub-style, five purple intensity levels.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The whole thing is a small SQLite table-set and a few React components. The interesting part isn&amp;rsquo;t the code, though — it&amp;rsquo;s the design calls.&lt;/p&gt;
&lt;h2 id="idle-gap-sessions"&gt;Idle-gap sessions&lt;/h2&gt;
&lt;p&gt;The hardest question turned out to be the simplest-sounding: &lt;strong&gt;what counts as one study session?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The naive answer is &amp;ldquo;anything bookended by open/close of the app.&amp;rdquo; That&amp;rsquo;s wrong. People leave tabs open. People bounce away for an hour and come back. People open the app at 9am, do nothing, and check in at 2pm.&lt;/p&gt;
&lt;p&gt;The answer I landed on: a session ends when you&amp;rsquo;ve been &lt;strong&gt;idle for 15 minutes&lt;/strong&gt;. Ask a question, idle for 16 minutes — that&amp;rsquo;s one session. Come back, ask another — that starts a new one. The threshold is configurable via &lt;code&gt;STUDY_SESSION_IDLE_GAP_SECONDS=900&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The clock keys off &lt;strong&gt;chat messages&lt;/strong&gt; — the conversational core of studying in CogniVault. Every message either extends the open session (bumping its &lt;code&gt;ended_at&lt;/code&gt; timestamp and message count) or, if the gap since the last activity exceeds the threshold, closes it implicitly and opens a new one:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Simplified from backend/services/progress_tracker.py&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;record_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idle_gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;most_recent_session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ended_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;idle_gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ended_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# same session continues&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;open_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;started_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ended_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# new session begins&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Two writes per message. A session&amp;rsquo;s duration is &lt;code&gt;ended_at - started_at&lt;/code&gt;, which means &amp;ldquo;total time&amp;rdquo; reflects &lt;em&gt;engaged&lt;/em&gt; time, not &amp;ldquo;had a tab open.&amp;rdquo; Which is the only number that actually means anything. (Study Hub actions — quiz attempts, card flips, mindmap exports — are recorded as their own events and feed the badge metrics below; the session clock itself stays message-driven and honest.)&lt;/p&gt;
&lt;h2 id="25-badges-not-250"&gt;25 badges, not 250&lt;/h2&gt;
&lt;p&gt;Most gamified apps absolutely flood you with achievements. There&amp;rsquo;s a reason: more badges, more dopamine, more daily active users. The cost is that each badge means less — eventually the whole layer becomes wallpaper.&lt;/p&gt;
&lt;p&gt;I capped CogniVault at &lt;strong&gt;25&lt;/strong&gt;, split across the five activity surfaces:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;10 for &lt;strong&gt;chat &amp;amp; study habits&lt;/strong&gt; (first question, 10 messages in a day, 100 total, an hour of total study, 3- and 7-day streaks, a 30-minute deep-dive session, night-owl and early-bird sessions, first use of the scope filter)&lt;/li&gt;
&lt;li&gt;4 for &lt;strong&gt;quizzes&lt;/strong&gt; (first quiz, a perfect score, passing on advanced difficulty, 10 quizzes)&lt;/li&gt;
&lt;li&gt;4 for &lt;strong&gt;workshops&lt;/strong&gt; (first outline, first completed lesson, first completed workshop, 5 completed)&lt;/li&gt;
&lt;li&gt;4 for &lt;strong&gt;flashcards&lt;/strong&gt; (first deck, 50 card flips, fully mastering a deck, 5 decks)&lt;/li&gt;
&lt;li&gt;3 for &lt;strong&gt;mindmaps&lt;/strong&gt; (first mindmap, first export, 5 mindmaps)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each badge has a one-line unlock criterion that&amp;rsquo;s auto-evaluated on relevant events. Nothing manual, nothing the user has to &amp;ldquo;claim.&amp;rdquo; They just appear.&lt;/p&gt;
&lt;p&gt;And the definitions aren&amp;rsquo;t code at all — they&amp;rsquo;re &lt;strong&gt;data&lt;/strong&gt;. All 25 live in a single JSON file, each entry naming the metric it watches and the target to hit:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;code&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;card_reviewer&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;name&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Card Reviewer&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;icon&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;🃏&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;metric&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;total_card_flips&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;target&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A single evaluator reads the current stats, compares every definition against its metric, diffs against already-earned badges, and inserts new unlocks into &lt;code&gt;progress.db&lt;/code&gt;. Adding badge number 26 means adding a JSON entry, not writing new logic. Several badges form ladders — each knows which badge is its &amp;ldquo;next level,&amp;rdquo; which powers the detail view&amp;rsquo;s nudge toward the next goal.&lt;/p&gt;
&lt;h2 id="the-heatmap"&gt;The heatmap&lt;/h2&gt;
&lt;p&gt;The 90-day heatmap is the part I&amp;rsquo;m proudest of, and also the simplest. It&amp;rsquo;s a 13×7 grid of cells, one per day, coloured by total study time that day.&lt;/p&gt;
&lt;p&gt;Five intensity levels:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;level 0 — no activity
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;level 1 — under 15 minutes (a quick check-in)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;level 2 — 15-60 minutes (a focused session)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;level 3 — 1-3 hours (substantial study)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;level 4 — 3+ hours (a marathon)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The data is conceptually one aggregation over the sessions table:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;started_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ended_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;started_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;study_sessions&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;started_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;now&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;-90 days&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The backend zero-fills the missing days so the frontend always receives exactly 90 entries, and a small client-side function bins each day&amp;rsquo;s total into the five levels. Click any cell and a &lt;code&gt;DayDetailModal&lt;/code&gt; opens with that day&amp;rsquo;s numbers — study time, sessions, messages — plus any badges earned that day.&lt;/p&gt;
&lt;p&gt;The reason I love this component: it makes the &lt;em&gt;texture&lt;/em&gt; of a study habit visible. Streaks are great, but a streak is one number. A heatmap shows you that you study harder on weekends, or that you&amp;rsquo;ve been on a slow drift downward all month, or that the gap between your last &amp;ldquo;level 4 day&amp;rdquo; and today is longer than you thought. It reflects something the user can act on.&lt;/p&gt;
&lt;h2 id="what-i-deliberately-left-out"&gt;What I deliberately left out&lt;/h2&gt;
&lt;p&gt;Three things you&amp;rsquo;d find in most gamified apps, intentionally absent from CogniVault:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Streak panic.&lt;/strong&gt; No &amp;ldquo;your streak is in danger!&amp;rdquo; pop-up. No streak freeze rules. No yellow exclamation marks. The streak is shown — that&amp;rsquo;s the entirety of the feedback loop. If a user breaks their streak, they break their streak. Adults don&amp;rsquo;t need shaming UX.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Leaderboards.&lt;/strong&gt; This is a single-user, fully local app. There&amp;rsquo;s no global comparison. (And there shouldn&amp;rsquo;t be — leaderboards optimise the wrong thing for studying.)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Confetti, fanfare, push notifications.&lt;/strong&gt; A newly earned badge shows up in the quiz results screen and on the dashboard grid. That&amp;rsquo;s the entire celebration. Anything bigger is theft of the user&amp;rsquo;s attention for the app&amp;rsquo;s benefit, not theirs.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The general principle: &lt;strong&gt;measure what matters, surface it without nagging.&lt;/strong&gt; Notice that you came back. Reflect that back to you. Don&amp;rsquo;t pretend you care more than you do.&lt;/p&gt;
&lt;h2 id="what-the-dashboard-doesn-try-to-optimise"&gt;What the dashboard &lt;em&gt;doesn&amp;rsquo;t&lt;/em&gt; try to optimise&lt;/h2&gt;
&lt;p&gt;A common trap with these dashboards is reverse-causation: the user starts gaming the metric instead of doing the underlying thing. A daily-question count, for instance, gets you users who ask one filler question per day to keep their streak alive.&lt;/p&gt;
&lt;p&gt;So the bar is deliberately low in exactly one place and high everywhere else. There is &lt;em&gt;one&lt;/em&gt; zero-effort badge — &amp;ldquo;First Question,&amp;rdquo; earned on your very first message — because every game needs an on-ramp that proves the system works. After that, the metrics get hard to game without doing the actual work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Total study time&lt;/strong&gt; — only accrues during active engagement, with idle-gap cutoffs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sessions&lt;/strong&gt; — adding more requires actually starting separate work periods.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Badges&lt;/strong&gt; — almost all require depth (100 messages, ace a quiz, master a deck, complete 5 workshops), not just touch.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Heatmap intensity&lt;/strong&gt; — needs sustained engagement on a given day.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="implementation-small-on-purpose"&gt;Implementation: small on purpose&lt;/h2&gt;
&lt;p&gt;The gamification core is three SQLite tables —&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;study_sessions&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;started_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ended_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;message_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;message_events&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;sent_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;had_scope_filter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;had_attachments&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;achievements_earned&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;earned_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;— plus the JSON badge definitions, one evaluator module, and a handful of React components (&lt;code&gt;SummaryCards&lt;/code&gt;, &lt;code&gt;AchievementGrid&lt;/code&gt;, &lt;code&gt;ActivityHeatmap&lt;/code&gt;, &lt;code&gt;DayDetailModal&lt;/code&gt;). The same &lt;code&gt;progress.db&lt;/code&gt; file has since grown more tables for the Study Hub&amp;rsquo;s saved quizzes, workshops, decks, and mindmaps — but the badge-and-session machinery itself remains a couple hundred lines.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s nothing fancy in any of it. The dashboard works because the &lt;em&gt;design calls&lt;/em&gt; are right, not because the implementation is clever.&lt;/p&gt;
&lt;h2 id="takeaway"&gt;Takeaway&lt;/h2&gt;
&lt;p&gt;If you&amp;rsquo;re building a learning tool — or any tool that lives on user habit — gamify &lt;em&gt;consciously&lt;/em&gt;. Pick the metrics that reflect what you actually want to encourage. Cap your achievement surface. Skip the streak-panic UX. Make the texture of usage visible without making the app desperate.&lt;/p&gt;
&lt;p&gt;Or, more bluntly: don&amp;rsquo;t build Duolingo. Build a dashboard the user occasionally glances at and then closes, feeling slightly more inclined to keep going. That&amp;rsquo;s the whole job.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="appendix-abbreviations-in-this-post"&gt;Appendix: Abbreviations in this post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;User Experience&lt;/td&gt;
&lt;td&gt;How the product feels — the thing streak-panic mechanics sacrifice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ICT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Information and Communications Technology&lt;/td&gt;
&lt;td&gt;The subject I taught for eight years before going full-stack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQLite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;(SQL = Structured Query Language)&lt;/td&gt;
&lt;td&gt;A complete relational database living in one file, &lt;code&gt;progress.db&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;The data format the 25 badge definitions live in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;User Interface&lt;/td&gt;
&lt;td&gt;The dashboard surface: stats, grid, heatmap&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt;
.&lt;/p&gt;</description></item><item><title>Part 6 · The Mindmap Renderer: What Hand-Rolling SVG Taught Me (and Why v2 Uses React Flow)</title><link>https://aretascodes.dev/blog/svg-mindmap-from-scratch/</link><pubDate>Fri, 15 May 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/blog/svg-mindmap-from-scratch/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Part of a series on building
. Previously:
.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;All abbreviations are fully explained in the appendix at the bottom of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;CogniVault&amp;rsquo;s Study Hub has four modes. Three of them — Quiz, Workshop, Flashcards — are list-shaped. The fourth, &lt;strong&gt;Mindmap&lt;/strong&gt;, isn&amp;rsquo;t. It&amp;rsquo;s a tree of concepts radiating from a central topic, and I wanted it to be:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Visually clean enough that the user actually wants to look at it.&lt;/li&gt;
&lt;li&gt;Interactive: pan, zoom, explore.&lt;/li&gt;
&lt;li&gt;Exportable to PNG and PDF with high fidelity.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This post is the honest version of how that renderer got built — &lt;strong&gt;twice&lt;/strong&gt;. Version one was hand-rolled SVG with no graph library, and it shipped. Version two, the one in the codebase today, is built on &lt;code&gt;@xyflow/react&lt;/code&gt; (React Flow) and a dagre auto-layout. I think both decisions were correct &lt;em&gt;at the time they were made&lt;/em&gt;, and the journey between them taught me more about build-vs-buy than either version alone.&lt;/p&gt;
&lt;h2 id="round-one-hand-rolling-it"&gt;Round one: hand-rolling it&lt;/h2&gt;
&lt;p&gt;My first instinct, like everyone else&amp;rsquo;s, was to reach for a library on day one. I resisted, for reasons that were sound: the default styling would need full customisation anyway, the layout I wanted was simple, export would need extra dependencies regardless, and the bundle cost wasn&amp;rsquo;t nothing. For the small trees Gemma generates, SVG alone looked sufficient — it pans and zooms with the &lt;code&gt;viewBox&lt;/code&gt; attribute, draws arbitrary shapes, serialises to a string, and rasterises cleanly.&lt;/p&gt;
&lt;p&gt;So v1 was pure SVG. And the core of it really was small.&lt;/p&gt;
&lt;h3 id="radial-layout-in-40-lines"&gt;Radial layout in 40 lines&lt;/h3&gt;
&lt;p&gt;A radial layout places the root at the centre and arranges children in concentric rings:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt;: &lt;span class="kt"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Placed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Node&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;angle&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;: &lt;span class="kt"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;radiusStep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Placed&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;placed&lt;/span&gt;: &lt;span class="kt"&gt;Placed&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;node&lt;/span&gt;: &lt;span class="kt"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;depth&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;fromAngle&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;toAngle&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;placed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;: &lt;span class="kt"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;: &lt;span class="kt"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;angle&lt;/span&gt;: &lt;span class="kt"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;angle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fromAngle&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;toAngle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;placed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;x&lt;/span&gt;: &lt;span class="kt"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;radiusStep&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;y&lt;/span&gt;: &lt;span class="kt"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;radiusStep&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toAngle&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;fromAngle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;fromAngle&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;fromAngle&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;placed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each level inherits an angular slice from its parent and subdivides it among its children. Pan and zoom were pure &lt;code&gt;viewBox&lt;/code&gt; arithmetic — no transform matrices, no event library, just numbers. Edges were quadratic Bézier curves pulled toward the centre. It looked good, it was fast, and the whole renderer fit comfortably in one component.&lt;/p&gt;
&lt;h3 id="the-export-trick-thats-still-worth-knowing"&gt;The export trick that&amp;rsquo;s still worth knowing&lt;/h3&gt;
&lt;p&gt;To export an SVG to PNG with zero dependencies, the browser does all the work:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;SVG DOM ─► XMLSerializer ─► string ─► &amp;lt;img&amp;gt; ─► &amp;lt;canvas&amp;gt; ─► PNG blob
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Serialise the SVG to a string, load it into an &lt;code&gt;Image&lt;/code&gt;, draw that image onto a scaled &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt;, and ask the canvas for a PNG. Fonts, anti-aliasing, &lt;code&gt;currentColor&lt;/code&gt; — the browser resolves all of it natively. If your visual &lt;em&gt;is&lt;/em&gt; an SVG element, this is still the cleanest export pipeline there is, and I&amp;rsquo;d use it again without hesitation.&lt;/p&gt;
&lt;p&gt;One v1 detail survived to the present day completely unchanged: the save flow. Instead of the classic &amp;ldquo;straight to the Downloads folder&amp;rdquo; experience, exports go through the &lt;strong&gt;File System Access API&lt;/strong&gt; (&lt;code&gt;showSaveFilePicker&lt;/code&gt;) where the browser supports it, with an anchor-tag download fallback for Firefox and Safari. A real &amp;ldquo;Save As…&amp;rdquo; dialog, no Electron required. That helper (&lt;code&gt;lib/saveBlob.ts&lt;/code&gt;) now serves the quiz and workshop exports too.&lt;/p&gt;
&lt;h2 id="the-requirements-that-broke-v1"&gt;The requirements that broke v1&lt;/h2&gt;
&lt;p&gt;Then the feature met its users (well — met me, using it seriously while studying), and three requirements emerged that the elegant hand-rolled version handled badly:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&amp;ldquo;Let me move that node.&amp;rdquo;&lt;/strong&gt; A generated layout is a starting point; a &lt;em&gt;useful&lt;/em&gt; mindmap is one you rearrange to match how you think. v1&amp;rsquo;s nodes were fixed in their computed positions. Adding drag meant building hit-testing, drag state, and position persistence from scratch — exactly the unglamorous interaction machinery that graph libraries exist to provide.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Text wanted to be HTML.&lt;/strong&gt; SVG &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; doesn&amp;rsquo;t wrap. Long concept labels needed manual line-breaking, measuring, and truncation — a constant fight. HTML nodes (real &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;s with CSS) wrap, ellipsize, and theme for free.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Radial wasn&amp;rsquo;t the best reading layout after all.&lt;/strong&gt; For the wide-and-shallow trees Gemma actually generates, a left-to-right or top-down tree (the kind a layout engine like &lt;strong&gt;dagre&lt;/strong&gt; computes) reads better than rings. And once layouts became switchable, &amp;ldquo;auto-layout plus remembered manual tweaks&amp;rdquo; became the natural model.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I could have built all of that on the SVG foundation. But look at the list: viewport management, node dragging, HTML nodes inside a graph canvas, pluggable layouts. That is &lt;em&gt;precisely&lt;/em&gt; React Flow&amp;rsquo;s feature set. In v1, the library would have been a wrapper around things I didn&amp;rsquo;t need. By v2, my requirements had grown into exactly the things it does well.&lt;/p&gt;
&lt;p&gt;So I changed my mind.&lt;/p&gt;
&lt;h2 id="round-two-react-flow--dagre"&gt;Round two: React Flow + dagre&lt;/h2&gt;
&lt;p&gt;Today&amp;rsquo;s renderer (&lt;code&gt;frontend/src/components/study/mindmaps/&lt;/code&gt;):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;@xyflow/react&lt;/code&gt; (React Flow)&lt;/strong&gt; provides the canvas: native pan/zoom, draggable nodes, a minimap-style controls cluster, and dark-mode support via &lt;code&gt;colorMode&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;dagre&lt;/strong&gt; computes the automatic layout, with a user-facing toggle between left-to-right and top-down. The tree-to-graph conversion is a small pure function.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Custom HTML nodes&lt;/strong&gt; carry the design system: the root gets a gradient, themes get a tint, leaves stay subtle — and text wraps like text should.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dragged positions persist.&lt;/strong&gt; Moving a node fires a save; reopening the map restores your arrangement. A &amp;ldquo;Reset layout&amp;rdquo; button clears the saved positions and returns to the dagre auto-layout. The layout choice and positions live with the mindmap in SQLite.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Export adapted to the new reality.&lt;/strong&gt; The nodes are HTML now, so the v1 SVG-serialisation trick no longer applies. PNG export uses &lt;code&gt;html-to-image&lt;/code&gt; over the React Flow viewport, framed to the node bounds regardless of current zoom; PDF embeds that PNG via a lazy-loaded &lt;code&gt;jsPDF&lt;/code&gt;; Markdown export is a zero-dependency recursive walk of the tree. Yes — v2 uses the exact library (&lt;code&gt;html-to-image&lt;/code&gt;) I was proud of avoiding in v1. The requirements changed; the trade-off changed with them.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="what-the-journey-actually-taught-me"&gt;What the journey actually taught me&lt;/h2&gt;
&lt;p&gt;I went back and forth on how to write this post, because v1&amp;rsquo;s story (&amp;ldquo;look how little code you need!&amp;rdquo;) is more flattering. But the two-version truth is the more useful lesson:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Hand-rolling first was still right.&lt;/strong&gt; v1 shipped in a weekend, taught me the problem&amp;rsquo;s real shape (layout, viewport, export are separate concerns), and cost nothing to throw away because it was small. If I&amp;rsquo;d started with React Flow, I&amp;rsquo;d have configured a library before understanding the problem.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Libraries earn their place when your requirements converge on their feature set — not before.&lt;/strong&gt; The moment &amp;ldquo;drag nodes and remember where I put them&amp;rdquo; became a requirement, the build-vs-buy maths flipped completely.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Some pieces outlive the rewrite.&lt;/strong&gt; The save-dialog helper, the Markdown walk, the instinct to frame exports to content bounds — all carried over. Rewrites are rarely total.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The browser&amp;rsquo;s native pipelines are worth knowing even when you end up not using them.&lt;/strong&gt; SVG → canvas → PNG is still the best zero-dependency export trick in frontend development. It just stops applying the day your nodes become HTML.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="takeaway"&gt;Takeaway&lt;/h2&gt;
&lt;p&gt;&amp;ldquo;Build or buy&amp;rdquo; is a function of requirements — and requirements move. Build while the problem is small and you&amp;rsquo;re still learning its shape. Buy when your feature list starts reading like the library&amp;rsquo;s README. And when you switch, write down why, so the next person (or the next you) knows it wasn&amp;rsquo;t indecision. It was the plan growing up.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="appendix-abbreviations-in-this-post"&gt;Appendix: Abbreviations in this post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SVG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scalable Vector Graphics&lt;/td&gt;
&lt;td&gt;The browser&amp;rsquo;s built-in vector drawing format — v1&amp;rsquo;s entire foundation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PNG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Network Graphics&lt;/td&gt;
&lt;td&gt;The raster image format exports produce&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Document Format&lt;/td&gt;
&lt;td&gt;The print-ready export, built by embedding the PNG&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DOM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Document Object Model&lt;/td&gt;
&lt;td&gt;The browser&amp;rsquo;s live representation of the page — what &lt;code&gt;html-to-image&lt;/code&gt; rasterises in v2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTML / CSS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HyperText Markup Language / Cascading Style Sheets&lt;/td&gt;
&lt;td&gt;What v2&amp;rsquo;s nodes are made of — and why their text wraps for free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;As in the File System Access API, which provides real &amp;ldquo;Save As…&amp;rdquo; dialogs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UI / UX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;User Interface / User Experience&lt;/td&gt;
&lt;td&gt;The drag-a-node requirement that triggered the rewrite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;The tree structure Gemma generates for each mindmap (see the previous post)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQLite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;(SQL = Structured Query Language)&lt;/td&gt;
&lt;td&gt;The single-file database where layout choices and node positions persist&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt;
.&lt;/p&gt;</description></item><item><title>Part 5 · Getting Reliable JSON Out of a Local LLM</title><link>https://aretascodes.dev/blog/reliable-json-local-llm/</link><pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/blog/reliable-json-local-llm/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Part of a series on building
. Previously:
.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;All abbreviations are fully explained in the appendix at the bottom of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;CogniVault&amp;rsquo;s Study Hub generates four kinds of structured artefacts from your documents: quizzes, multi-lesson workshops, flashcard decks, and mindmaps. All four need the model to return structured JSON, not prose. All four ride on Gemma 4 running locally via Ollama. And all four would fail far too often if I trusted the model to &amp;ldquo;just return JSON.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s the defensive pattern that brings that failure rate close to zero — and what to do about the cases that still get through.&lt;/p&gt;
&lt;h2 id="the-pattern"&gt;The pattern&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript3" data-lang="gdscript3"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="mf"&gt;1.&lt;/span&gt; &lt;span class="n"&gt;Retrieve&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;hybrid&lt;/span&gt; &lt;span class="n"&gt;search&lt;/span&gt; &lt;span class="n"&gt;restricted&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;selected&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="mf"&gt;2.&lt;/span&gt; &lt;span class="n"&gt;Prompt&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;strict&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;explicit&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;shape&lt;/span&gt; &lt;span class="n"&gt;rules&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="mf"&gt;3.&lt;/span&gt; &lt;span class="n"&gt;Generate&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;ollama&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;json&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;grammar&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;constrained&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="mf"&gt;4.&lt;/span&gt; &lt;span class="n"&gt;Parse&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tolerant&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;object&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;array&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;fenced&lt;/span&gt; &lt;span class="n"&gt;shapes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;trailing&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;comma&lt;/span&gt; &lt;span class="n"&gt;repair&lt;/span&gt; &lt;span class="k"&gt;pass&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="mf"&gt;5.&lt;/span&gt; &lt;span class="n"&gt;Validate&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;drop&lt;/span&gt; &lt;span class="n"&gt;malformed&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="n"&gt;rather&lt;/span&gt; &lt;span class="n"&gt;than&lt;/span&gt; &lt;span class="n"&gt;fail&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;whole&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="mf"&gt;6.&lt;/span&gt; &lt;span class="n"&gt;Retry&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;workshop&lt;/span&gt; &lt;span class="n"&gt;outline&lt;/span&gt; &lt;span class="n"&gt;retries&lt;/span&gt; &lt;span class="n"&gt;once&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;stronger&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="mf"&gt;7.&lt;/span&gt; &lt;span class="n"&gt;Persist&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;SQLite&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;so&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="n"&gt;can&lt;/span&gt; &lt;span class="n"&gt;come&lt;/span&gt; &lt;span class="n"&gt;back&lt;/span&gt; &lt;span class="n"&gt;later&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Every generator in CogniVault follows it. The interesting moves are 2, 4, and 5.&lt;/p&gt;
&lt;h2 id="step-3-formatjson-does-real-work"&gt;Step 3: &lt;code&gt;format=&amp;quot;json&amp;quot;&lt;/code&gt; does real work&lt;/h2&gt;
&lt;p&gt;Ollama exposes a &lt;code&gt;format=&amp;quot;json&amp;quot;&lt;/code&gt; option that puts the model under a &lt;strong&gt;grammar constraint&lt;/strong&gt; during sampling. The decoder won&amp;rsquo;t emit tokens that would make the output invalid JSON. It&amp;rsquo;s not perfect — schemas are bigger than &amp;ldquo;valid JSON,&amp;rdquo; and the model can still produce well-formed garbage — but it eliminates the entire class of &amp;ldquo;the model started writing prose before the closing brace&amp;rdquo; failures.&lt;/p&gt;
&lt;p&gt;If your local-LLM stack supports a grammar option (Ollama, llama.cpp, vLLM, etc.), turn it on. It&amp;rsquo;s not free (sampling is slightly slower) but the failure-mode improvement is enormous. Without it, you&amp;rsquo;ll spend most of your error budget on truncated objects.&lt;/p&gt;
&lt;h2 id="step-2-schema-in-prompt-that-the-model-can-actually-obey"&gt;Step 2: schema-in-prompt that the model can actually obey&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;format=&amp;quot;json&amp;quot;&lt;/code&gt; guarantees the &lt;em&gt;shape&lt;/em&gt; of the output is JSON. It says nothing about whether the JSON matches your domain schema. That&amp;rsquo;s the prompt&amp;rsquo;s job.&lt;/p&gt;
&lt;p&gt;The pattern that works for me: instead of dumping a formal JSON Schema and saying &amp;ldquo;obey this,&amp;rdquo; include a &lt;strong&gt;filled-in example&lt;/strong&gt; that shows the model the exact shape, plus explicit counts. Here&amp;rsquo;s the heart of CogniVault&amp;rsquo;s real quiz template (it lives as an editable Markdown file in &lt;code&gt;backend/prompts/quiz.md&lt;/code&gt;):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Output ONLY a single JSON object — no prose, no markdown fences,
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;no text outside the JSON.
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;NUMBER OF QUESTIONS: EXACTLY $num_questions. This is a hard requirement.
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;OUTPUT SCHEMA:
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;{
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;questions&amp;#34;: [
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;type&amp;#34;: one of [$types_csv],
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;question&amp;#34;: the question text (string, no leading numbering),
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;options&amp;#34;: array of strings (length 4 for mcq, length 2 for true_false),
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;correct_index&amp;#34;: integer index into options (0-based),
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;explanation&amp;#34;: 1-2 sentence explanation of the correct answer
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; },
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ... exactly $num_questions entries
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A few choices that matter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Show the shape, don&amp;rsquo;t describe it.&lt;/strong&gt; &amp;ldquo;Each item has a &lt;code&gt;type&lt;/code&gt; field&amp;rdquo; gets ignored more often than the literal example.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pin the count.&lt;/strong&gt; &amp;ldquo;EXACTLY 10&amp;rdquo; — repeated, in capitals, as a hard requirement — is much more reliable than &amp;ldquo;around 10.&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Index, don&amp;rsquo;t repeat.&lt;/strong&gt; The correct answer is &lt;code&gt;correct_index&lt;/code&gt;, an integer pointing into &lt;code&gt;options&lt;/code&gt; — not the answer text again. Repeated text invites paraphrase drift (&amp;ldquo;Paris&amp;rdquo; vs &amp;ldquo;Paris, France&amp;rdquo;), and then your grading comparison breaks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One artefact per call.&lt;/strong&gt; I tried generating a full workshop (outline + every lesson) in one call. The model&amp;rsquo;s quality degrades sharply as the response grows. Splitting into outline-first, lesson-on-demand is the two-pass strategy below.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="step-4-parse-tolerantly"&gt;Step 4: parse, tolerantly&lt;/h2&gt;
&lt;p&gt;Even with &lt;code&gt;format=&amp;quot;json&amp;quot;&lt;/code&gt;, two parsing problems survive in practice.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The shape surprise.&lt;/strong&gt; This one bit me in production: I&amp;rsquo;d assumed the model would return a bare JSON array of questions. With &lt;code&gt;format=&amp;quot;json&amp;quot;&lt;/code&gt;, Gemma consistently returns an &lt;strong&gt;object&lt;/strong&gt; — &lt;code&gt;{&amp;quot;questions&amp;quot;: [...]}&lt;/code&gt; — and for a while the parser only accepted the array. Result: a 502 on every quiz generation until I found it. The fix is a parser that meets the model where it is:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Simplified from backend/services/quiz_generator.py&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extract_json_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;extract_json_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;load_json_lenient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="c1"&gt;# bare array&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;questions&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# the expected object shape&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;Lexical glitches.&lt;/strong&gt; Occasionally a trailing comma slips through. The repair is deliberately narrow — one regex pass, then give up:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load_json_lenient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;repaired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;,(\s*[\]}])&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;\1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# strip trailing commas&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repaired&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I don&amp;rsquo;t try to balance brackets, complete truncated strings, or guess at missing fields. Either the output is fixable with a trailing-comma pass and some substring extraction, or it isn&amp;rsquo;t, and we move to step 5.&lt;/p&gt;
&lt;h2 id="step-5-drop-malformed-items-dont-fail-the-batch"&gt;Step 5: drop malformed items, don&amp;rsquo;t fail the batch&lt;/h2&gt;
&lt;p&gt;This is the call that took me a while to make peace with.&lt;/p&gt;
&lt;p&gt;When the model returns 10 quiz questions but #7 is missing its &lt;code&gt;options&lt;/code&gt; field, the temptation is to error out and regenerate the whole batch. &lt;em&gt;Don&amp;rsquo;t&lt;/em&gt;. Validate each item independently and drop the ones that fail.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# CogniVault does this with explicit field checks into a dataclass;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# pydantic works just as well.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;questions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;raw_item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;parsed_items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;validate_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allowed_types&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# returns None if malformed&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The user gets 9 questions instead of 10. They don&amp;rsquo;t notice. Re-running the whole generation to fix question #7 takes 30 seconds and might introduce &lt;em&gt;new&lt;/em&gt; failures in questions 1-6. The dropped-item approach is strictly better UX. (The model also sometimes overshoots the count — the validated list is simply trimmed back to what was asked for.)&lt;/p&gt;
&lt;h2 id="step-6-the-outline-retries-once"&gt;Step 6: the outline retries once&lt;/h2&gt;
&lt;p&gt;Workshops are the exception that proves the rule. A workshop is a structured outline (title, summary, lesson list) plus each lesson&amp;rsquo;s content. The outline &lt;em&gt;must&lt;/em&gt; parse — there&amp;rsquo;s no partial success for a table of contents — so a parse failure there triggers exactly &lt;strong&gt;one&lt;/strong&gt; retry, with the prompt re-sent plus a stern reminder: &amp;ldquo;Your previous response was unparseable. Output ONLY a single valid JSON object.&amp;rdquo; If the second attempt fails too, the user gets a clear error suggesting a narrower scope.&lt;/p&gt;
&lt;p&gt;One retry, not three. Three retries when the model is consistently confused is just wasted seconds and watts.&lt;/p&gt;
&lt;p&gt;The lessons themselves, interestingly, are &lt;strong&gt;not JSON at all&lt;/strong&gt;. A lesson body is prose — forcing it into a JSON string would buy nothing and cost escaping headaches. Lessons are generated as plain Markdown, then run through a small cleanup pass that strips chat-isms the model sometimes adds despite instructions (&amp;ldquo;I hope this helps!&amp;rdquo;, &amp;ldquo;Let me know if…&amp;rdquo;). Different output, different contract.&lt;/p&gt;
&lt;h2 id="two-pass-outline-first-lessons-on-demand"&gt;Two-pass: outline first, lessons on demand&lt;/h2&gt;
&lt;p&gt;Workshops use a two-pass generation pattern:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Pass 1 — generate outline: {&amp;#34;title&amp;#34;: ..., &amp;#34;lessons&amp;#34;: [{&amp;#34;title&amp;#34;: ...}, ...]} (cheap, JSON)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Pass 2 — for each lesson: a full Markdown lesson body (on demand)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The outline is fast and lets the user see the shape of the workshop immediately. Each lesson is generated when the user opens it — meaning the user is &lt;em&gt;reading&lt;/em&gt; lesson 1 while deciding whether they even want lesson 5. The total wall-clock time to &amp;ldquo;first useful content&amp;rdquo; is small even for a 10-lesson workshop.&lt;/p&gt;
&lt;p&gt;This is the same architectural move the chat side makes with
: split a slow operation into a tiny fast part and a larger slow part, hand the user the fast part immediately.&lt;/p&gt;
&lt;h2 id="what-i-learned-so-far-putting-those-generators-together"&gt;What I learned so far putting those generators together&lt;/h2&gt;
&lt;p&gt;A few principles distilled from the four generators:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Use the grammar option in your inference stack.&lt;/strong&gt; Don&amp;rsquo;t try to coax JSON out of a free-form decoder.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pin every quantifier in the prompt.&lt;/strong&gt; &amp;ldquo;Exactly 10,&amp;rdquo; &amp;ldquo;exactly 4 options,&amp;rdquo; &amp;ldquo;one or two sentences.&amp;rdquo; Vague counts = inconsistent output.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don&amp;rsquo;t assume the top-level shape.&lt;/strong&gt; Grammar-constrained Gemma likes objects; your code might expect arrays. Accept both — the parser is cheaper than relying on the model to return the expected shape.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Drop, don&amp;rsquo;t fail.&lt;/strong&gt; Lossy success beats brittle perfection.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One retry, never more.&lt;/strong&gt; If two tries can&amp;rsquo;t produce valid output, the prompt is wrong, not the model.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Split large generations.&lt;/strong&gt; Outline + lessons. Skeleton + body. Two small calls beat one big one almost every time. And if a part of the output is naturally prose, let it &lt;em&gt;be&lt;/em&gt; prose.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Local LLMs in 2026 are good enough that structured generation is genuinely usable for production-shaped features. They are not so good that you can skip the defensive scaffolding. The scaffolding above is maybe 80 lines of code total across all four generators, and it&amp;rsquo;s the difference between &amp;ldquo;demo-quality&amp;rdquo; and &amp;ldquo;I trust this enough to ship.&amp;rdquo;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="appendix-abbreviations-in-this-post"&gt;Appendix: Abbreviations in this post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;The structured text format the generators must produce&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large Language Model&lt;/td&gt;
&lt;td&gt;A neural network trained on huge amounts of text that can read and generate language&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Artificial Intelligence&lt;/td&gt;
&lt;td&gt;Software performing tasks that normally need human intelligence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MCQ&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-Choice Question&lt;/td&gt;
&lt;td&gt;One of the two quiz question types (the other is true/false)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;User Experience&lt;/td&gt;
&lt;td&gt;Why 9 valid questions beat a regeneration error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQLite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;(SQL = Structured Query Language)&lt;/td&gt;
&lt;td&gt;The single-file database where generated artefacts persist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System&lt;/td&gt;
&lt;td&gt;The durable-workflow library from the previous post&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTTP 502&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Bad Gateway (HyperText Transfer Protocol status code)&lt;/td&gt;
&lt;td&gt;The error my array-only parser produced until I accepted Gemma&amp;rsquo;s object shape&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt;
— what hand-rolling an SVG radial layout taught me, and why version two uses React Flow anyway.&lt;/p&gt;</description></item><item><title>Part 4 · Crash-Resumable Ingestion: DBOS, SHA-256, and Surviving a kill -9</title><link>https://aretascodes.dev/blog/crash-resumable-ingestion-dbos/</link><pubDate>Tue, 05 May 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/blog/crash-resumable-ingestion-dbos/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Part of a series on building
. Previously:
.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;All abbreviations are fully explained in the appendix at the bottom of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;There are two things you absolutely don&amp;rsquo;t want your RAG ingestion pipeline to do:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Re-embed a 200-page PDF because you fixed a typo on page 12.&lt;/li&gt;
&lt;li&gt;Lose its progress if you close the laptop lid halfway through.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The first wastes time and compute resources. The second leads to distrust in the system. Both have the same root: ingestion is treated like a fire-and-forget function, when it&amp;rsquo;s actually a long-running pipeline with intermediate state worth preserving.&lt;/p&gt;
&lt;p&gt;CogniVault treats ingestion as a &lt;strong&gt;durable workflow&lt;/strong&gt;. Specifically, a
workflow checkpointed in Postgres, with content hashing for incremental work. This post walks through both pieces.&lt;/p&gt;
&lt;h2 id="the-pipeline"&gt;The pipeline&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;1. Scan docs/ → SHA-256 hash per file
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── New file → queue for embedding
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── Changed file → soft-delete old chunks, re-embed
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └── Unchanged → skip (idempotent)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2. Extract text → per-format extractor (PDF/OCR, DOCX, PPTX, XLSX, MD, CSV, TXT, HTML)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;3. Chunk → RecursiveCharacterTextSplitter (1000 chars, 100 overlap)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;4. Embed → embeddinggemma via Ollama, batches of 5
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;5. Save → append to FAISS IndexFlatIP + JSON metadata on disk
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The heavy stages run as DBOS steps inside one parent workflow, each one checkpointed: if the process dies between steps, the next start picks up at the last completed one.&lt;/p&gt;
&lt;h2 id="sha-256-as-the-source-of-truth"&gt;SHA-256 as the source of truth&lt;/h2&gt;
&lt;p&gt;The naive approach is to track ingestion by filename. That breaks the first time someone edits a file in place. Filename is the same; content isn&amp;rsquo;t. The vector store quietly carries stale chunks.&lt;/p&gt;
&lt;p&gt;The fix is content-addressed: hash the file bytes, store the hash alongside the chunks. Every ingestion run:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;current_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;stored_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chunk_metadata_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;file_hash&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;stored_hash&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;schedule_ingest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# new file&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;stored_hash&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;current_hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# unchanged&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;soft_delete_chunks_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# changed&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;schedule_ingest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This gives ingestion an &lt;strong&gt;idempotent&lt;/strong&gt; property that&amp;rsquo;s worth its weight in gold: running the pipeline twice in a row does almost nothing the second time. That&amp;rsquo;s not just an optimisation — it&amp;rsquo;s what makes the next section possible.&lt;/p&gt;
&lt;h2 id="dbos-workflows"&gt;DBOS workflows&lt;/h2&gt;
&lt;p&gt;
is a Python library that turns regular functions into checkpointed workflows backed by Postgres. The model is dead simple: decorate a function with &lt;code&gt;@DBOS.workflow()&lt;/code&gt;, mark each long-running call inside it as a &lt;code&gt;@DBOS.step()&lt;/code&gt;, and DBOS records each step&amp;rsquo;s input, output, and status in Postgres as it runs.&lt;/p&gt;
&lt;p&gt;If the workflow crashes — process killed, OS reboot, Postgres connection drop — the next start sees there&amp;rsquo;s an unfinished workflow with the same ID, replays the &lt;em&gt;recorded&lt;/em&gt; step outputs from Postgres (without re-running them), and resumes from the first incomplete step.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s the actual step structure (slightly simplified from &lt;code&gt;backend/services/ingest.py&lt;/code&gt;):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@DBOS.workflow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ingest_workflow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;filenames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list_document_files&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;# @DBOS.step — scan + hash check&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;filenames&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;process_single_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# @DBOS.step — extract text, one file each&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# plain Python — fast, re-runs freely&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;batches_of_5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;embed_batch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# @DBOS.step — the slow one, retried on failure&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;save_vector_store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# @DBOS.step — append to FAISS + metadata&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The granularity of &lt;code&gt;@DBOS.step&lt;/code&gt; is the granularity of crash recovery, and it&amp;rsquo;s chosen deliberately. Extraction is one step &lt;strong&gt;per file&lt;/strong&gt;, so a crash during file 9 of 10 doesn&amp;rsquo;t re-read the first eight. Embedding is one step &lt;strong&gt;per batch of five chunks&lt;/strong&gt;, for one specific reason: &lt;strong&gt;&lt;code&gt;embed_batch&lt;/code&gt; is the slow one.&lt;/strong&gt; If the laptop dies during embeddings, we resume the embedding loop at the failed batch, not at PDF extraction.&lt;/p&gt;
&lt;p&gt;Notice what &lt;em&gt;isn&amp;rsquo;t&lt;/em&gt; a step: chunking. Splitting text is fast pure-Python work — checkpointing it would cost more ledger bookkeeping than simply redoing it on a resume.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a related sizing trick hiding in the batch number. DBOS records each step&amp;rsquo;s output in Postgres, and &lt;code&gt;embed_batch&lt;/code&gt; returns its vectors — so each ledger entry contains five embeddings&amp;rsquo; worth of floats. Small batches keep each checkpoint record small and each retry cheap. One giant &amp;ldquo;embed everything&amp;rdquo; step would mean one giant ledger row and zero resume granularity.&lt;/p&gt;
&lt;h2 id="the-format-extractors"&gt;The format extractors&lt;/h2&gt;
&lt;p&gt;Step 2 (&lt;code&gt;process_single_document&lt;/code&gt;) is a dispatch on file extension. Each extractor is small and obvious; the interesting choices are in the chunking strategy each one feeds downstream.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;Chunking note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pypdf&lt;/code&gt; page-by-page; &lt;code&gt;pytesseract&lt;/code&gt; OCR fallback for image-only pages&lt;/td&gt;
&lt;td&gt;Recursive splitter, 1000/100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DOCX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;python-docx&lt;/code&gt; (paragraphs + table rows joined as text)&lt;/td&gt;
&lt;td&gt;Recursive splitter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PPTX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;python-pptx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One chunk per slide (title + body text)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;XLSX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;openpyxl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Header + 20-row batches, per sheet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MD&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MarkdownHeaderTextSplitter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One chunk per H1/H2/H3 section, breadcrumb prepended&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CSV&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;manual reader&lt;/td&gt;
&lt;td&gt;Header row + 20-row batches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TXT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;raw UTF-8 read&lt;/td&gt;
&lt;td&gt;Recursive splitter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTML&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;trafilatura&lt;/code&gt; clean text&lt;/td&gt;
&lt;td&gt;Recursive splitter&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The OCR fallback is the one worth pausing on. PDFs come in two flavours: ones with a real text layer, and ones that are basically scanned images wearing a PDF costume. &lt;code&gt;pypdf&lt;/code&gt; returns &lt;em&gt;nothing useful&lt;/em&gt; for the second kind, but it doesn&amp;rsquo;t raise — it just hands back empty strings. Without a fallback, your &amp;ldquo;ingestion succeeded&amp;rdquo; log is lying to you.&lt;/p&gt;
&lt;p&gt;The detector is a heuristic: if &lt;code&gt;pypdf&lt;/code&gt; returns fewer than 50 characters for a page, route the page through &lt;code&gt;pymupdf&lt;/code&gt; → &lt;code&gt;Pillow&lt;/code&gt; → &lt;code&gt;pytesseract&lt;/code&gt; OCR. Slower, but at least produces text. The threshold is tuned to be sensitive enough to catch scanned pages while not punishing legitimately short pages (a chapter cover, a colophon).&lt;/p&gt;
&lt;h2 id="soft-delete-not-hard-delete"&gt;Soft delete, not hard delete&lt;/h2&gt;
&lt;p&gt;When a file changes and we re-ingest, the old chunks need to go. The temptation is to physically remove them from the FAISS index, but FAISS &lt;code&gt;IndexFlatIP&lt;/code&gt; doesn&amp;rsquo;t support efficient delete — you&amp;rsquo;d have to rebuild.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Soft delete&lt;/strong&gt; instead: changed files get their old chunks marked with a &lt;code&gt;deleted: true&lt;/code&gt; flag in the metadata; new chunks are appended without it. Search filters on the flag at query time, so stale vectors sit harmlessly in the index. If enough dead weight ever accumulates, the escape valve is obvious — rebuild the index from active chunks only — but in practice I haven&amp;rsquo;t needed it.&lt;/p&gt;
&lt;p&gt;This is the same pattern most append-only systems use. It pairs naturally with content hashing — flag-and-append is much cheaper than remove-and-rebuild. One subtlety: the keyword index has to follow suit. CogniVault&amp;rsquo;s &lt;code&gt;VectorDB.delete_by_source()&lt;/code&gt; flips the flags &lt;strong&gt;and rebuilds BM25&lt;/strong&gt; over the remaining active chunks, so the two retrievers never disagree about what exists.&lt;/p&gt;
&lt;h2 id="what-the-user-sees"&gt;What the user sees&lt;/h2&gt;
&lt;p&gt;Starting an ingestion (&lt;code&gt;POST /ingest&lt;/code&gt;) returns a &lt;code&gt;workflow_id&lt;/code&gt;, and the frontend polls &lt;code&gt;GET /ingest/status/{workflow_id}&lt;/code&gt; to draw a live timeline of the workflow&amp;rsquo;s steps — scanning, per-file extraction (&amp;ldquo;Reading pages… 3 of 21&amp;rdquo;), embedding (&amp;ldquo;Calibrating batch 4 of 12&amp;rdquo;), saving. If the user closes the tab mid-ingest, comes back five minutes later, and reopens — the workflow finished in the background regardless. The next call to &lt;code&gt;GET /api/vault/stats&lt;/code&gt; reflects the new chunk count. No &amp;ldquo;click to resume&amp;rdquo; button, no manual recovery dance.&lt;/p&gt;
&lt;p&gt;The first time I closed the lid mid-embedding and watched the workflow pick itself up from the next step on resume, I&amp;rsquo;ll admit I was a little smug. That&amp;rsquo;s exactly the property I wanted, with surprisingly little code.&lt;/p&gt;
&lt;h2 id="pitfalls-and-edges"&gt;Pitfalls and edges&lt;/h2&gt;
&lt;p&gt;A few things I had to learn the hard way:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Don&amp;rsquo;t make &lt;code&gt;embed_batch&lt;/code&gt; too big.&lt;/strong&gt; Ollama isn&amp;rsquo;t great at backpressure. Batches of 5 are a sweet spot for &lt;code&gt;embeddinggemma&lt;/code&gt; on a 16 GB machine — bigger batches stall on memory, smaller ones waste round-trip overhead. (And as noted above, the batch size doubles as your checkpoint-record size.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Be careful with file deletion.&lt;/strong&gt; Soft-deleted chunks must also disappear from BM25&amp;rsquo;s corpus, or keyword search will keep returning text that dense search no longer sees. Rebuilding BM25 inside &lt;code&gt;delete_by_source()&lt;/code&gt; keeps the two in lockstep.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OCR is slow.&lt;/strong&gt; A 50-page scan can take a minute or more. Surface that latency to the user; otherwise they think it&amp;rsquo;s hanging.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="takeaway"&gt;Takeaway&lt;/h2&gt;
&lt;p&gt;Durable workflows aren&amp;rsquo;t only for distributed systems. A single-user local app benefits from them in &lt;em&gt;exactly the same ways&lt;/em&gt;: incremental work, crash recovery, idempotent retries. DBOS makes the cost of opting in trivially low — decorate your function, run Postgres locally, and you get a pipeline that survives lid-closes, OS updates, and your own &lt;code&gt;Ctrl-C&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Combined with content-addressed hashing, ingestion stops being a thing you avoid touching for fear of having to wait 20 minutes. It becomes a thing you re-run whenever you feel like it — because re-running is free when nothing has changed.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="appendix-abbreviations-in-this-post"&gt;Appendix: Abbreviations in this post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System&lt;/td&gt;
&lt;td&gt;A library that checkpoints workflow steps in Postgres so crashed jobs resume instead of restarting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SHA-256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Secure Hash Algorithm, 256-bit&lt;/td&gt;
&lt;td&gt;A content fingerprint: change one byte of a file and the hash changes completely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Retrieval-Augmented Generation&lt;/td&gt;
&lt;td&gt;Retrieve relevant passages from your own documents first; let the model answer from them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OCR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Optical Character Recognition&lt;/td&gt;
&lt;td&gt;Turning pictures of text (scanned pages) into machine-readable text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;The vector index the embeddings are appended to&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IP&lt;/strong&gt; (in &lt;code&gt;IndexFlatIP&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Inner Product&lt;/td&gt;
&lt;td&gt;FAISS&amp;rsquo;s similarity measure; equals cosine similarity on normalised vectors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BM25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Best Match 25&lt;/td&gt;
&lt;td&gt;The keyword index that must stay in lockstep with FAISS on deletes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF / DOCX / PPTX / XLSX / MD / CSV / TXT / HTML&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Document Format / Word / PowerPoint / Excel / Markdown / Comma-Separated Values / plain text / HyperText Markup Language&lt;/td&gt;
&lt;td&gt;The formats the per-extension extractors handle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;The format of the chunk-metadata file next to the FAISS index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UTF-8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Unicode Transformation Format, 8-bit&lt;/td&gt;
&lt;td&gt;The text encoding used when reading plain-text files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Operating System&lt;/td&gt;
&lt;td&gt;What reboots underneath you mid-ingest&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt;
— what happens after Gemma 4 enthusiastically returns &lt;code&gt;{&amp;quot;questions&amp;quot;: [{&amp;quot;text&amp;quot;: &amp;quot;...&amp;quot;},}]&lt;/code&gt;.&lt;/p&gt;</description></item><item><title>Part 3 · Two-Phase Streaming: Showing the Model Think Before It Acts</title><link>https://aretascodes.dev/blog/two-phase-streaming-strands-agents/</link><pubDate>Thu, 30 Apr 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/blog/two-phase-streaming-strands-agents/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Part of a series on building
. Previously:
.
All abbreviations are fully explained in the appendix at the bottom of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;When I first wired up Gemma 4 with
inside CogniVault, the chat felt slow. Not laggy — slow in a way that&amp;rsquo;s worse than laggy. The user types a question. The cursor sits there. Then, eventually, an answer drops out of the void.&lt;/p&gt;
&lt;p&gt;The model wasn&amp;rsquo;t idle. It was &lt;em&gt;thinking&lt;/em&gt;. Gemma 4 has a chain-of-thought mode that produces a (sometimes long) reasoning trace before its final reply. With a single-phase agent stream, all of that thinking is happening &lt;em&gt;inside the agent loop&lt;/em&gt; — silently — before any tool calls run, before any tokens get emitted to the UI.&lt;/p&gt;
&lt;p&gt;So I split the call into two phases.&lt;/p&gt;
&lt;h2 id="the-shape"&gt;The shape&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;POST /rag
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── Phase 1 — Direct Ollama call, thinking enabled
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │ stream: {&amp;#34;type&amp;#34;:&amp;#34;thinking&amp;#34;,&amp;#34;data&amp;#34;:&amp;#34;...&amp;#34;} (reasoning tokens)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └── Phase 2 — Strands Agent (thinking disabled)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; stream: {&amp;#34;type&amp;#34;:&amp;#34;metadata&amp;#34;,&amp;#34;data&amp;#34;:{...}} (citations, as soon as search runs)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; stream: {&amp;#34;type&amp;#34;:&amp;#34;text&amp;#34;,&amp;#34;data&amp;#34;:&amp;#34;...&amp;#34;} (answer tokens)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; stream: {&amp;#34;type&amp;#34;:&amp;#34;memory&amp;#34;,&amp;#34;data&amp;#34;:{...}} (end-of-stream: session memory usage)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The endpoint streams &lt;strong&gt;newline-delimited JSON&lt;/strong&gt; (NDJSON): each line of the response body is one self-contained JSON envelope with a &lt;code&gt;type&lt;/code&gt; and a &lt;code&gt;data&lt;/code&gt;. The frontend dispatches on &lt;code&gt;type&lt;/code&gt; and renders accordingly: a &lt;strong&gt;collapsible reasoning panel&lt;/strong&gt; for the thinking tokens, the main message bubble for the text tokens, a sidebar card per citation.&lt;/p&gt;
&lt;p&gt;The user sees the model start thinking &lt;em&gt;immediately&lt;/em&gt;. Latency to first byte drops from &amp;ldquo;long enough to wonder if it crashed&amp;rdquo; to &amp;ldquo;instant.&amp;rdquo; Total time to final answer doesn&amp;rsquo;t change. Perceived speed does.&lt;/p&gt;
&lt;h2 id="phase-1--thinking-only"&gt;Phase 1 — Thinking only&lt;/h2&gt;
&lt;p&gt;Phase 1 is a single direct call to Ollama with thinking enabled. It gets exactly what Phase 2 will see — the same system prompt, the current question, and any attached images — so the reasoning reflects reality. Only the &lt;em&gt;reasoning&lt;/em&gt; tokens are consumed; whatever answer text Phase 1 starts to produce is discarded, because we don&amp;rsquo;t want a half-formed answer competing with the real one.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Simplified from backend/services/rag_agent.py&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ollama&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ollama_host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llm_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;role&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;system&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;content&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;role&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;user&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;content&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;images&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;images&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;thinking&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thinking&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;thinking&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thinking&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Phase 1 is deliberately &lt;strong&gt;best-effort&lt;/strong&gt;: any failure here is swallowed and logged, and the stream moves straight on to Phase 2. A broken reasoning panel should never cost the user their answer.&lt;/p&gt;
&lt;h2 id="phase-2--agent-with-tools"&gt;Phase 2 — Agent with tools&lt;/h2&gt;
&lt;p&gt;Phase 2 builds a &lt;strong&gt;fresh Strands &lt;code&gt;Agent&lt;/code&gt; per request&lt;/strong&gt; — no shared mutable state between concurrent chats — restores the session&amp;rsquo;s conversation history into it, and runs the tool loop with six tools registered:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;search_knowledge_base(query)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Hybrid FAISS + BM25 search, top-7, RRF fusion. Scope-filter-aware.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list_documents()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Inventory of every indexed file with type and chunk count.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;analyze_document(filename)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Inner Gemma call → structured summary (topics, entities, key facts).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;compare_documents(doc_a, doc_b, question)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Inner Gemma call answering across two documents.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;calculator(expression)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Safe AST evaluator — no &lt;code&gt;eval()&lt;/code&gt;, no arbitrary code.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;current_time()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Timestamp for time-aware queries.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The agent decides which tools to call and in what order. There&amp;rsquo;s no hard-coded router; the system prompt explains what&amp;rsquo;s available and Strands handles the loop. For most document questions the path is: &lt;code&gt;search_knowledge_base&lt;/code&gt; → answer. For comparisons: &lt;code&gt;compare_documents&lt;/code&gt; → answer. For &amp;ldquo;what files do I have?&amp;rdquo;: &lt;code&gt;list_documents&lt;/code&gt; → answer. For greetings and arithmetic, the system prompt tells the agent it may skip search entirely. The model picks.&lt;/p&gt;
&lt;p&gt;Two details that took debugging to get right:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Phase 2 runs with thinking explicitly disabled.&lt;/strong&gt; Without that flag, Gemma&amp;rsquo;s default behaviour can leak &lt;code&gt;&amp;lt;think&amp;gt;…&amp;lt;/think&amp;gt;&lt;/code&gt; tags into the visible answer, and everything before the closing tag gets swallowed by the Markdown renderer. One model option — &lt;code&gt;options={&amp;quot;thinking&amp;quot;: False}&lt;/code&gt; — fixed a &amp;ldquo;truncated responses&amp;rdquo; bug that looked much scarier than it was.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Citations are flushed before the first answer token.&lt;/strong&gt; Tools run before text deltas arrive, so by the time the first visible token streams, every source the search found is already in the sidebar. The accumulator is a request-local &lt;code&gt;ContextVar&lt;/code&gt; the search tool appends to.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Simplified — the real loop reads Strands&amp;#39; raw event dicts&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stream_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_input&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;event&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;contentBlockDelta&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;delta&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;text&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;new_citations&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="c1"&gt;# drain the ContextVar accumulator&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;metadata&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;text&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="why-this-matters-more-than-it-sounds"&gt;Why this matters more than it sounds&lt;/h2&gt;
&lt;p&gt;You could implement similar behaviour with one agent call that interleaves &lt;code&gt;thinking&lt;/code&gt; events with &lt;code&gt;text&lt;/code&gt; events. The reasons I split it anyway:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The thinking model and the tool model can be different.&lt;/strong&gt; Right now they&amp;rsquo;re both &lt;code&gt;gemma4:e4b&lt;/code&gt;, but the architecture lets me swap a smaller, faster model in for Phase 1 reasoning and keep the big one for Phase 2 tool use. I&amp;rsquo;m not doing that yet — but I want the option.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Phase 1 always streams immediately.&lt;/strong&gt; A pure agent loop only starts producing tokens after the model has decided what to say. Two-phase guarantees the user sees activity almost as soon as they press Enter, regardless of how complex the Phase 2 tool work gets.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Failures isolate.&lt;/strong&gt; If Phase 2 falls over (Ollama timeout, tool error), Phase 1&amp;rsquo;s reasoning is still visible — the user can see &lt;em&gt;what the model was trying to do&lt;/em&gt;, which makes the error far less frustrating than a blank &amp;ldquo;something went wrong.&amp;rdquo;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="contextvar-isolation-again"&gt;ContextVar isolation, again&lt;/h2&gt;
&lt;p&gt;The same &lt;code&gt;ContextVar&lt;/code&gt; trick that scopes retrieval in
carries here. At the start of each &lt;code&gt;/rag&lt;/code&gt; stream, the handler sets two request-local variables: the &lt;strong&gt;document-scope filter&lt;/strong&gt; and the &lt;strong&gt;citation accumulator&lt;/strong&gt;. The agent&amp;rsquo;s tools read and write them implicitly. Conversation history itself lives in a per-session store guarded by per-session &lt;code&gt;asyncio&lt;/code&gt; locks, so two concurrent requests in the same chat can&amp;rsquo;t corrupt each other either.&lt;/p&gt;
&lt;p&gt;Tested with two browser tabs open on the same backend, scoped to different document categories, sending overlapping queries simultaneously. Zero cross-contamination. The test suite covers this explicitly in &lt;code&gt;test_thinking.py&lt;/code&gt; and &lt;code&gt;test_doc_scope_filter.py&lt;/code&gt; — see
for the broader story.&lt;/p&gt;
&lt;h2 id="the-frontend-side-of-the-contract"&gt;The frontend side of the contract&lt;/h2&gt;
&lt;p&gt;A detail that tripped me up: this is a &lt;code&gt;POST&lt;/code&gt; endpoint, so the browser&amp;rsquo;s &lt;code&gt;EventSource&lt;/code&gt; API (which only does GET) is out. The frontend uses &lt;code&gt;fetch&lt;/code&gt; and reads the response body incrementally, splitting on newlines and parsing each line as JSON:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-tsx" data-lang="tsx"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Simplified from useRagStream.ts
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;/rag&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;POST&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;body&lt;/span&gt;: &lt;span class="kt"&gt;JSON.stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getReader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;: &lt;span class="kt"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;\n&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// keep the trailing partial line
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kr"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;thinking&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;appendThinking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;text&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;appendText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;metadata&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;addCitation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;memory&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;updateMemoryMeter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The reasoning panel starts &lt;strong&gt;collapsed&lt;/strong&gt;, with a small pulsing indicator while thinking tokens are still streaming — enough to signal &amp;ldquo;the model is working&amp;rdquo; without shoving a wall of chain-of-thought at the user. One click expands the full trace, during or after the stream.&lt;/p&gt;
&lt;h2 id="what-id-revisit"&gt;What I&amp;rsquo;d revisit&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Phase 1 reasons toward a full answer, and we throw the answer part away.&lt;/strong&gt; A dedicated &amp;ldquo;plan your approach, don&amp;rsquo;t answer yet&amp;rdquo; prompt for Phase 1 would make the reasoning trace tighter and cheaper. Today it shares the main system prompt — simpler, but the trace can ramble.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No interrupt yet.&lt;/strong&gt; Once Phase 1 starts, it runs to completion. If the user types a follow-up mid-stream we let it finish. A real cancel button would mean wiring an abort signal through Ollama&amp;rsquo;s HTTP client — feasible, not yet done.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phase 1 occasionally over-thinks.&lt;/strong&gt; Greetings and trivial questions still produce a paragraph of reasoning. A &amp;ldquo;should I think?&amp;rdquo; gate (probably a tiny classifier or even a heuristic on query length) would skip Phase 1 entirely for those cases.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="takeaway"&gt;Takeaway&lt;/h2&gt;
&lt;p&gt;Streaming is &lt;em&gt;not&lt;/em&gt; just an optimisation. It&amp;rsquo;s a UX primitive. Two-phase streaming buys you a free property: the &lt;em&gt;visible&lt;/em&gt; part of the interaction starts before the &lt;em&gt;slow&lt;/em&gt; part does. The user gets to watch the model think, which is — genuinely — more interesting than watching a spinner.&lt;/p&gt;
&lt;p&gt;If your agent app feels slow even though the answers are fast, look at &lt;em&gt;when&lt;/em&gt; tokens start flowing. The fix often isn&amp;rsquo;t a faster model.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="appendix-abbreviations-in-this-post"&gt;Appendix: Abbreviations in this post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NDJSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Newline-Delimited JSON&lt;/td&gt;
&lt;td&gt;A stream where each line is its own complete JSON object — what &lt;code&gt;/rag&lt;/code&gt; emits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;The universal text format for structured data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;User Experience&lt;/td&gt;
&lt;td&gt;How the product feels to use — the real beneficiary of two-phase streaming&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;User Interface&lt;/td&gt;
&lt;td&gt;The visible surface the stream renders into&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;The dense half of hybrid retrieval (previous post)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BM25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Best Match 25&lt;/td&gt;
&lt;td&gt;The keyword half of hybrid retrieval (previous post)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reciprocal Rank Fusion&lt;/td&gt;
&lt;td&gt;The rank-only formula that merges the two result lists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AST&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Abstract Syntax Tree&lt;/td&gt;
&lt;td&gt;The parsed form of an expression — how the calculator evaluates maths without &lt;code&gt;eval()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTTP&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HyperText Transfer Protocol&lt;/td&gt;
&lt;td&gt;The protocol carrying the stream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SSE&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Server-Sent Events&lt;/td&gt;
&lt;td&gt;The browser&amp;rsquo;s built-in GET-only streaming format — notably &lt;em&gt;not&lt;/em&gt; usable here, because &lt;code&gt;/rag&lt;/code&gt; is a POST&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;The boundary the frontend calls&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt;
— how CogniVault re-ingests edited PDFs without re-embedding everything, and survives a kill -9 mid-pipeline.&lt;/p&gt;</description></item><item><title>Part 2 · Hybrid Retrieval in Practice: FAISS + BM25, Fused with RRF</title><link>https://aretascodes.dev/blog/hybrid-retrieval-faiss-bm25-rrf/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/blog/hybrid-retrieval-faiss-bm25-rrf/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Part of a series on building
, a fully local AI study companion. Previous:
.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;All abbreviations are fully explained in the appendix at the bottom of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The first version of CogniVault used pure dense retrieval — embed the query with &lt;code&gt;embeddinggemma&lt;/code&gt;, search a FAISS index, pass the top-7 chunks to the model. It worked. It worked &lt;em&gt;beautifully&lt;/em&gt; — until a user uploaded a PDF containing some German legal text and asked for &amp;ldquo;§3 Absatz 2.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;The model couldn&amp;rsquo;t find it.&lt;/p&gt;
&lt;p&gt;The chunk was &lt;em&gt;right there&lt;/em&gt;. The PDF was indexed. But &amp;ldquo;§3 Absatz 2&amp;rdquo; doesn&amp;rsquo;t embed into anything semantically meaningful — it&amp;rsquo;s a token-level identifier, not a concept. The dense vector for the query landed nowhere near the dense vector for the chunk, even though the chunk literally contains the string the user asked for.&lt;/p&gt;
&lt;p&gt;That bug killed pure dense retrieval for me. This post is about what replaced it.&lt;/p&gt;
&lt;h2 id="two-kinds-of-similar"&gt;Two kinds of &amp;ldquo;similar&amp;rdquo;&lt;/h2&gt;
&lt;p&gt;You already use both kinds of search every day. When Spotify builds a &amp;ldquo;song radio&amp;rdquo; from a track you like, it&amp;rsquo;s matching &lt;em&gt;feel&lt;/em&gt; — tempo, mood, genre — and it will happily play you a song whose title shares no words with the original. But when you type &lt;code&gt;Bohemian Rhapsody remastered 2011&lt;/code&gt; into the search box, you don&amp;rsquo;t want &lt;em&gt;feel&lt;/em&gt;. You want that exact string, and &amp;ldquo;a similar operatic rock epic&amp;rdquo; is a wrong answer.&lt;/p&gt;
&lt;p&gt;Search systems formalise that split into two notions of similarity:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Lexical similarity&lt;/strong&gt; — &amp;ldquo;do these strings share rare words?&amp;rdquo; This is what TF-IDF and BM25 model. They thrive on identifiers, names, code, technical terminology, and direct quotes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Semantic similarity&lt;/strong&gt; — &amp;ldquo;do these passages talk about the same idea, even with different words?&amp;rdquo; This is what embeddings model. They thrive on paraphrase, conceptual queries, and natural-language questions.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Neither subsumes the other. A user asking &lt;em&gt;&amp;ldquo;how is the practical exam structured?&amp;rdquo;&lt;/em&gt; needs &lt;strong&gt;semantic&lt;/strong&gt; search — the document doesn&amp;rsquo;t say &amp;ldquo;structure of practical exam.&amp;rdquo; A user asking &lt;em&gt;&amp;quot;§3 Absatz 2&amp;quot;&lt;/em&gt; needs &lt;strong&gt;lexical&lt;/strong&gt; search — there&amp;rsquo;s no concept to embed, just a literal string.&lt;/p&gt;
&lt;p&gt;Production RAG has to do both. CogniVault does both, and then fuses the result lists with &lt;strong&gt;Reciprocal Rank Fusion (RRF)&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 id="the-stack"&gt;The stack&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Query
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── embed via embeddinggemma ──► FAISS IndexFlatIP ──► top-K dense
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └── tokenize + lowercase ──► BM25Okapi ──► top-K sparse
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Reciprocal Rank Fusion ◄──┘
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; top-7 fused chunks
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Both indexes live &lt;strong&gt;in memory&lt;/strong&gt;, fronted by a &lt;code&gt;VectorDB&lt;/code&gt; singleton. FAISS does inner-product search over normalised embeddings (so dot product = cosine). BM25 is &lt;code&gt;rank_bm25&lt;/code&gt;&amp;rsquo;s &lt;code&gt;BM25Okapi&lt;/code&gt;, fed the same chunks tokenised by a simple lowercase-and-split tokenizer.&lt;/p&gt;
&lt;p&gt;The corpora are kept in lockstep: soft-deleting a file&amp;rsquo;s chunks triggers a BM25 rebuild over the remaining active chunks, and the singleton reloads both indexes from &lt;code&gt;vector_store.faiss&lt;/code&gt; + &lt;code&gt;vector_store.json&lt;/code&gt; (chunk metadata + raw text) after every ingestion run and on app start.&lt;/p&gt;
&lt;h2 id="why-faiss-indexflatip-not-hnsw-or-ivf"&gt;Why FAISS &lt;code&gt;IndexFlatIP&lt;/code&gt;, not HNSW or IVF?&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;IndexFlatIP&lt;/code&gt; is brute-force exact search. It scans every vector, every query. For tens of thousands of chunks that&amp;rsquo;s fine — sub-millisecond on a laptop. CogniVault is a &lt;strong&gt;single-user, local-first&lt;/strong&gt; app; the index is never going to be billions of vectors. Trading recall for speed via HNSW or IVF would buy nothing here and lose the &amp;ldquo;exact&amp;rdquo; guarantee. Boring, correct, fast enough.&lt;/p&gt;
&lt;p&gt;When the corpus grows large enough that brute-force gets sticky, switching is a one-line change. Until then, the simplest index wins.&lt;/p&gt;
&lt;h2 id="reciprocal-rank-fusion"&gt;Reciprocal Rank Fusion&lt;/h2&gt;
&lt;p&gt;The naive way to combine two ranked lists is to score them and add. That sounds reasonable until you remember FAISS returns inner-product scores in some bounded range and BM25 returns scores in an unbounded one — they aren&amp;rsquo;t comparable without normalisation, and any normalisation you pick is somewhat arbitrary.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;RRF sidesteps the problem entirely.&lt;/strong&gt; It only looks at &lt;em&gt;ranks&lt;/em&gt;, not scores. For each result list, an item at rank &lt;code&gt;r&lt;/code&gt; contributes &lt;code&gt;1 / (k + r)&lt;/code&gt; to its final score (with &lt;code&gt;k = 60&lt;/code&gt; by convention — large enough to flatten the tail, small enough that the top items still dominate). Items that appear in both lists get summed.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Simplified — the real implementation also de-duplicates chunks&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# by (source, chunk_id, page) before scoring.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reciprocal_rank_fusion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result_lists&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result_lists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;chunk_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That&amp;rsquo;s the whole algorithm. No tuning, no calibration, no per-corpus weights. A chunk that&amp;rsquo;s #1 in BM25 and #4 in FAISS easily beats a chunk that&amp;rsquo;s #2 in only one of them. A chunk that &lt;em&gt;both&lt;/em&gt; indexes agree on rises to the top deterministically.&lt;/p&gt;
&lt;p&gt;The result for the &amp;ldquo;§3 Absatz 2&amp;rdquo; query: BM25 finds the literal match and lands it at rank 1. FAISS finds nothing useful (its top hits are about exam regulations in general). RRF surfaces the BM25 hit at the top of the fused list. Problem solved.&lt;/p&gt;
&lt;h2 id="scope-filtering-with-contextvar-isolation"&gt;Scope filtering with ContextVar isolation&lt;/h2&gt;
&lt;p&gt;One detail that&amp;rsquo;s easy to get wrong: the retriever has to be &lt;em&gt;scope-aware&lt;/em&gt;. CogniVault lets users limit a question to a single category or specific files. The scope is set by the request, but the search is called from deep inside the Strands agent loop, which is called from a streaming FastAPI handler, possibly with multiple concurrent requests in flight per worker.&lt;/p&gt;
&lt;p&gt;Threading the scope through every function call would be ugly. A global is unsafe. The right primitive is Python&amp;rsquo;s
, which gives you per-task isolated state that asyncio and threads both respect.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;contextvars&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ContextVar&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;_doc_scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ContextVar&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DocScope&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ContextVar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;doc_scope&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;set_doc_scope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DocScope&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;_doc_scope&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;current_doc_scope&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DocScope&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_doc_scope&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;/rag&lt;/code&gt; request handler sets the scope at the very start of each streaming response; the search tool reads it; because the value is task-local, it dies with the request. No globals, no parameter drilling, no race conditions across concurrent users.&lt;/p&gt;
&lt;p&gt;This is one of those design choices that looks like over-engineering until you have two browser tabs open and realise that without it, tab A&amp;rsquo;s scope filter would leak into tab B&amp;rsquo;s question.&lt;/p&gt;
&lt;h2 id="chunking-choices-that-pay-off-downstream"&gt;Chunking choices that pay off downstream&lt;/h2&gt;
&lt;p&gt;Hybrid retrieval is only as good as the chunks. CogniVault uses a &lt;code&gt;RecursiveCharacterTextSplitter&lt;/code&gt; with &lt;strong&gt;1,000 characters, 100 overlap&lt;/strong&gt; for unstructured text — small enough to keep retrieval precise, large enough to carry context for the model.&lt;/p&gt;
&lt;p&gt;For structured formats it switches strategy:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Markdown&lt;/strong&gt; → &lt;code&gt;MarkdownHeaderTextSplitter&lt;/code&gt; emits one chunk per H1/H2/H3 section with the heading hierarchy prepended as a breadcrumb (&amp;ldquo;Privacy &amp;gt; Vault Audit &amp;gt; Indicators&amp;rdquo;). BM25 loves breadcrumbs — they make heading-keyword queries match cleanly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CSV&lt;/strong&gt; → header row + 20-row batches per chunk, so a query for a column name lands on the right block.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PPTX&lt;/strong&gt; → one chunk per slide, title and body text together.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;XLSX&lt;/strong&gt; → header + row batches, per sheet, with a &lt;code&gt;[Sheet: name]&lt;/code&gt; prefix.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Tiny fragments get filtered: unstructured text needs at least &lt;strong&gt;100 characters&lt;/strong&gt; to become a chunk, while the structured formats drop the bar to &lt;strong&gt;20&lt;/strong&gt; — a two-line Markdown section or a header-only sheet is short but still meaningful. The recursive splitter is well-trodden territory, but the per-format strategies matter much more than people give them credit for.&lt;/p&gt;
&lt;h2 id="what-id-do-differently"&gt;What I&amp;rsquo;d do differently&lt;/h2&gt;
&lt;p&gt;A few things I&amp;rsquo;d revisit if I were starting over:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Stop tokenising for BM25 with &lt;code&gt;str.split()&lt;/code&gt;.&lt;/strong&gt; It&amp;rsquo;s fine, but a real tokenizer that handles punctuation and German compounds would meaningfully improve recall on the legal docs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add a small reranker.&lt;/strong&gt; RRF gets the right &lt;em&gt;set&lt;/em&gt;, but a cross-encoder rerank on the top 20 would polish the &lt;em&gt;order&lt;/em&gt;. Locally-served, of course — there are good small ones now.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Query expansion for thin queries.&lt;/strong&gt; Two-word questions like &amp;ldquo;§3 exam&amp;rdquo; could be expanded via a quick &lt;code&gt;gemma4&lt;/code&gt; call before retrieval. Latency cost, recall gain.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of those are in the box yet. RRF over FAISS + BM25 is already so much better than either alone that I haven&amp;rsquo;t felt the pull to optimise further.&lt;/p&gt;
&lt;h2 id="the-takeaway"&gt;The takeaway&lt;/h2&gt;
&lt;p&gt;If your retrieval is &amp;ldquo;embed + cosine + top-k,&amp;rdquo; it will fail in exactly the way mine did — on the queries that contain literal identifiers your model has no embedding for. The fix isn&amp;rsquo;t a better embedding model. It&amp;rsquo;s a second retriever that doesn&amp;rsquo;t pretend everything is a concept.&lt;/p&gt;
&lt;p&gt;FAISS for ideas. BM25 for strings. RRF to decide which one was right today.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="appendix-abbreviations-in-this-post"&gt;Appendix: Abbreviations in this post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Retrieval-Augmented Generation&lt;/td&gt;
&lt;td&gt;Retrieve relevant passages from your own documents first; let the model answer from them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Meta&amp;rsquo;s library for storing vectors and finding the most similar ones fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BM25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Best Match 25&lt;/td&gt;
&lt;td&gt;A keyword-ranking formula — the 25th ranking function developed in the Okapi information-retrieval system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reciprocal Rank Fusion&lt;/td&gt;
&lt;td&gt;Merges ranked lists using only ranks: each item scores &lt;code&gt;Σ 1/(k + rank)&lt;/code&gt; across lists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TF-IDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Term Frequency–Inverse Document Frequency&lt;/td&gt;
&lt;td&gt;BM25&amp;rsquo;s ancestor: score words by how often they appear here vs how rare they are everywhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IP&lt;/strong&gt; (in &lt;code&gt;IndexFlatIP&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Inner Product&lt;/td&gt;
&lt;td&gt;The similarity measure FAISS computes; on normalised vectors it equals cosine similarity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HNSW&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hierarchical Navigable Small World&lt;/td&gt;
&lt;td&gt;A popular &lt;em&gt;approximate&lt;/em&gt; vector-index structure — deliberately not used here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IVF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Inverted File Index&lt;/td&gt;
&lt;td&gt;Another approximate FAISS index type — also deliberately not used&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AEVO&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ausbildereignungsverordnung&lt;/td&gt;
&lt;td&gt;The German trainer-aptitude regulation whose &amp;ldquo;§3 Absatz 2&amp;rdquo; query broke pure dense retrieval&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CSV / PPTX / XLSX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Comma-Separated Values / PowerPoint / Excel (Office Open XML)&lt;/td&gt;
&lt;td&gt;Structured formats with their own chunking strategies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;H1/H2/H3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Heading levels 1–3&lt;/td&gt;
&lt;td&gt;The Markdown heading tiers used to split sections&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt;
— how CogniVault&amp;rsquo;s &lt;code&gt;/rag&lt;/code&gt; endpoint streams Gemma 4&amp;rsquo;s &lt;em&gt;thinking&lt;/em&gt; before any tool calls run.&lt;/p&gt;</description></item><item><title>Part 1 · Why I Built a Local-First RAG</title><link>https://aretascodes.dev/blog/why-local-first-rag/</link><pubDate>Mon, 20 Apr 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/blog/why-local-first-rag/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;All abbreviations are fully explained in the appendix at the bottom of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I&amp;rsquo;ve spent the last few years in front of virtual classrooms full of career-changers in Germany, walking them through programming basics, web development, and introductory AI courses. Most of the information we deal with is fine to paste into cloud-based AI tools. Some of it really isn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;Exam materials under confidentiality. A trainee&amp;rsquo;s portfolio with personal details. Other private documents that should never end up training someone else&amp;rsquo;s model.&lt;/p&gt;
&lt;p&gt;So I built
— a fully local AI study and productivity tool. No cloud. No telemetry. No &amp;ldquo;we may use this data to improve our service.&amp;rdquo; Just Gemma 4 running on Ollama, on my laptop, talking to my files.&lt;/p&gt;
&lt;h2 id="the-leaky-abstraction"&gt;The leaky abstraction&lt;/h2&gt;
&lt;p&gt;The pitch for cloud AI is great: a giant model, available instantly, billed by the token. The fine print is where it gets uncomfortable:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Where does the data physically live during inference?&lt;/li&gt;
&lt;li&gt;Whose jurisdiction governs that hardware this afternoon?&lt;/li&gt;
&lt;li&gt;Does the &lt;em&gt;audit trail&lt;/em&gt; stop at the API boundary, or can you actually trace what happened to your bytes?&lt;/li&gt;
&lt;li&gt;When you tick &amp;ldquo;do not train on my data,&amp;rdquo; are you trusting a control, a contract, or both?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For most consumer use cases, those questions are fine to wave away. For &lt;strong&gt;education, healthcare, finance, legal, public administration&lt;/strong&gt; — the answer &amp;ldquo;trust us&amp;rdquo; isn&amp;rsquo;t an answer.&lt;/p&gt;
&lt;h2 id="what-local-first-actually-means-here"&gt;What &amp;ldquo;local-first&amp;rdquo; actually means here&lt;/h2&gt;
&lt;p&gt;Lots of products say &amp;ldquo;private.&amp;rdquo; I wanted three concrete properties:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The model lives on your machine.&lt;/strong&gt; Gemma 4 (&lt;code&gt;gemma4:e4b&lt;/code&gt;) and &lt;code&gt;embeddinggemma&lt;/code&gt; are pulled via Ollama. Inference is a localhost HTTP call.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Your documents never leave.&lt;/strong&gt; Vectors, chunks, chat history, study sessions, achievements — all on disk on your computer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You can &lt;em&gt;verify&lt;/em&gt; it.&lt;/strong&gt; Gemma CogniVault ships a &lt;strong&gt;Privacy Audit Panel&lt;/strong&gt; that shows a live &amp;ldquo;zero external connections&amp;rdquo; indicator alongside document counts and the Ollama host. It&amp;rsquo;s not a promise — it&amp;rsquo;s a status light.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If a future build of Gemma CogniVault ever made an outbound call, that panel would be the first thing to scream.&lt;/p&gt;
&lt;h2 id="what-you-get-back"&gt;What you get back&lt;/h2&gt;
&lt;p&gt;Going local sounds like a trade-off — surely you lose the magic of the giant frontier models? In practice, with &lt;strong&gt;Gemma 4&lt;/strong&gt; you get more than enough:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Thinking mode&lt;/strong&gt; — Gemma 4&amp;rsquo;s chain-of-thought streams into a collapsible panel before the answer. Watching the model reason about your documents is genuinely useful as a teaching tool.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tool use&lt;/strong&gt; — through the
, the model decides when to search the knowledge base, summarise a document, compare two files, or check the time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vision&lt;/strong&gt; — attach images and PDFs straight into a chat turn.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Generation that&amp;rsquo;s actually structured&lt;/strong&gt; — quizzes, multi-lesson workshops, flashcard decks, and interactive mindmaps, generated with &lt;code&gt;format=&amp;quot;json&amp;quot;&lt;/code&gt; so the output parses reliably.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Cognivault doesn&amp;rsquo;t try to be a giant ecosystem. It&amp;rsquo;s a single-purpose tool that does one thing well: use your own documents with a capable local model in a private environment. I must admit that it was inspired to a great extent by
, which I&amp;rsquo;ve found incredibly useful but not private enough for my needs.&lt;/p&gt;
&lt;h2 id="the-shape-of-the-app"&gt;The shape of the app&lt;/h2&gt;
&lt;p&gt;CogniVault is split into four sections that map to how I actually work with information on cloud-based AI tools:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Section&lt;/th&gt;
&lt;th&gt;What it&amp;rsquo;s for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Chat&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ask anything about your documents. Cited answers, scope filter, voice in.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Knowledge Base&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Upload, categorise, manage. SHA-256 detects edits on re-upload.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Study Hub&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Quiz · Workshop · Flashcards · Mindmaps — four ways to drill into the source.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dashboard&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Total study time, streak, 25 badges, GitHub-style 90-day heatmap.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Everything reachable from a sidebar that remembers where you left off, on a stack that fits in your &lt;code&gt;~/Documents&lt;/code&gt; folder.&lt;/p&gt;
&lt;h2 id="what-comes-next"&gt;What comes next&lt;/h2&gt;
&lt;p&gt;This is the first in a short series. Over the next few posts I&amp;rsquo;ll dig into the parts I&amp;rsquo;m most proud of — and a few I&amp;rsquo;d build differently next time:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hybrid retrieval&lt;/strong&gt; — why FAISS &lt;em&gt;and&lt;/em&gt; BM25, fused with Reciprocal Rank Fusion&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Two-phase streaming&lt;/strong&gt; with Gemma 4 and Strands Agents&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Crash-resumable ingestion&lt;/strong&gt; with DBOS, hash-aware re-ingest, OCR fallback&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Getting reliable JSON&lt;/strong&gt; out of a local LLM (and what to do when it fails)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The mindmap renderer&lt;/strong&gt; — what hand-rolling SVG taught me, and why v2 uses React Flow&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gamifying learning&lt;/strong&gt; — 25 badges, idle-gap sessions, 90-day heatmap&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Testing a local-AI app&lt;/strong&gt; with 350+ tests and zero infrastructure&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you want to skip ahead, the code is open source at
, and there&amp;rsquo;s a
.&lt;/p&gt;
&lt;p&gt;Your data. Your hardware. Your AI. Your vault.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="appendix-abbreviations-in-this-post"&gt;Appendix: Abbreviations in this post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Retrieval-Augmented Generation&lt;/td&gt;
&lt;td&gt;Retrieve relevant passages from your own documents first; let the model answer from them instead of from training memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Artificial Intelligence&lt;/td&gt;
&lt;td&gt;Software performing tasks that normally need human intelligence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large Language Model&lt;/td&gt;
&lt;td&gt;A neural network trained on huge amounts of text that can read and generate language&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTTP&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HyperText Transfer Protocol&lt;/td&gt;
&lt;td&gt;The protocol browsers and APIs use to exchange requests and responses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;The boundary where you call someone else&amp;rsquo;s software — and where cloud audit trails stop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IHK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Industrie- und Handelskammer&lt;/td&gt;
&lt;td&gt;The German Chamber of Commerce and Industry, which administers trainer certification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AEVO&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ausbildereignungsverordnung&lt;/td&gt;
&lt;td&gt;The German trainer-aptitude regulation — the exam material that motivated this project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Meta&amp;rsquo;s vector-search library (covered in the next post)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BM25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Best Match 25&lt;/td&gt;
&lt;td&gt;A classic keyword-ranking formula (also next post)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SDK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Software Development Kit&lt;/td&gt;
&lt;td&gt;A library of building blocks — here, Strands, which provides the agent loop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;The universal text format for structured data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Document Format&lt;/td&gt;
&lt;td&gt;One of the eight-plus file types CogniVault ingests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SHA-256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Secure Hash Algorithm, 256-bit&lt;/td&gt;
&lt;td&gt;A content fingerprint used to detect edited files on re-upload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OCR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Optical Character Recognition&lt;/td&gt;
&lt;td&gt;Turning pictures of text (scans) into machine-readable text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System&lt;/td&gt;
&lt;td&gt;The durable-workflow library behind crash-resumable ingestion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SVG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scalable Vector Graphics&lt;/td&gt;
&lt;td&gt;The browser&amp;rsquo;s built-in vector drawing format&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;</description></item><item><title>Uses</title><link>https://aretascodes.dev/uses/</link><pubDate>Tue, 24 Oct 2023 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/uses/</guid><description>&lt;p&gt;This page is a living document of the tools, technologies, and setup I use daily as a developer and trainer.&lt;/p&gt;
&lt;h2 id="languages--frameworks"&gt;Languages &amp;amp; Frameworks&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;HTML, CSS, Vanilla JavaScript&lt;/li&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;ReactJS, NextJS, AngularJS&lt;/li&gt;
&lt;li&gt;React Native&lt;/li&gt;
&lt;li&gt;NodeJS, ExpressJS&lt;/li&gt;
&lt;li&gt;C#, .NET&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="databases"&gt;Databases&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;MongoDB, Firebase&lt;/li&gt;
&lt;li&gt;PostgreSQL, MySQL&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="devops--tooling"&gt;DevOps &amp;amp; Tooling&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Git, GitHub, GitHub Actions&lt;/li&gt;
&lt;li&gt;Docker&lt;/li&gt;
&lt;li&gt;AWS (S3, DynamoDB, Amplify)&lt;/li&gt;
&lt;li&gt;CI/CD pipelines&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="styling"&gt;Styling&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;CSS, SCSS, TailwindCSS&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="editor--terminal"&gt;Editor + Terminal&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
— my editor of choice&lt;/li&gt;
&lt;li&gt;Chrome — main browser&lt;/li&gt;
&lt;li&gt;Integrated terminal in VS Code&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="website"&gt;Website&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Built with
and deployed on
&lt;/li&gt;
&lt;/ul&gt;</description></item></channel></rss>