<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Python |</title><link>https://aretascodes.dev/tags/python/</link><atom:link href="https://aretascodes.dev/tags/python/index.xml" rel="self" type="application/rss+xml"/><description>Python</description><generator>HugoBlox Kit (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Fri, 12 Jun 2026 00:00:00 +0000</lastBuildDate><image><url>https://aretascodes.dev/media/icon_hu_2ab4f4763b27c75b.png</url><title>Python</title><link>https://aretascodes.dev/tags/python/</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>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 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 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></channel></rss>