<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>RAG |</title><link>https://aretascodes.dev/tags/rag/</link><atom:link href="https://aretascodes.dev/tags/rag/index.xml" rel="self" type="application/rss+xml"/><description>RAG</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>RAG</title><link>https://aretascodes.dev/tags/rag/</link></image><item><title>CogniVault Backend Explained, Part 1 · Meet the Backend: Three Processes, Four Layers</title><link>https://aretascodes.dev/blog/backend-explained-meet-the-backend/</link><pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/blog/backend-explained-meet-the-backend/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;All abbreviations are fully explained in the appendix at the bottom of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;When people first open the CogniVault repository, the question I hear most is some version of: &lt;em&gt;&amp;ldquo;Where do I even start?&amp;rdquo;&lt;/em&gt; There&amp;rsquo;s a RAG agent, a FAISS index, a DBOS workflow, an Ollama host — and if you&amp;rsquo;re transitioning into tech, every one of those words is a closed door.&lt;/p&gt;
&lt;p&gt;This series opens the doors one at a time. No prior RAG knowledge assumed, every abbreviation spelled out, and every claim checkable against the
. If you&amp;rsquo;ve already read my
, think of this series as the guided tour that should have come first.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s map this out.&lt;/p&gt;
&lt;h2 id="the-whole-app-is-three-processes"&gt;The whole app is three processes&lt;/h2&gt;
&lt;p&gt;CogniVault lets you chat with your own documents and turn them into quizzes, workshops, flashcards, and mindmaps — and nothing ever leaves your machine. (The &lt;em&gt;why&lt;/em&gt; behind that constraint is its own story:
.)&lt;/p&gt;
&lt;p&gt;You might expect an app like that to be a sprawl of microservices. It&amp;rsquo;s three processes:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Process&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The Python backend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One FastAPI app on port 8000 — it also serves the compiled React frontend as static files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ollama&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The local model server on port 11434, running the AI models&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PostgreSQL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One Docker container, used &lt;em&gt;only&lt;/em&gt; for workflow checkpoints — never for your documents&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Everything else — your files, the search index, your chat history, your quiz scores — is a plain file on disk. That&amp;rsquo;s not laziness; it&amp;rsquo;s the privacy argument made physical. You can open every byte the app stores with a text editor and a SQLite browser.&lt;/p&gt;
&lt;h2 id="the-four-layers"&gt;The four layers&lt;/h2&gt;
&lt;p&gt;Before we name technologies, here&amp;rsquo;s the mental model I want you to keep for the whole series. The backend is four layers, top to bottom:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Layer 1 — the web layer.&lt;/strong&gt; A FastAPI application receives every HTTP request and routes it to one of six routers: chat (&lt;code&gt;/rag&lt;/code&gt;), knowledge management (&lt;code&gt;/upload&lt;/code&gt;, &lt;code&gt;/ingest&lt;/code&gt;), study tools (&lt;code&gt;/api/study/*&lt;/code&gt;), progress (&lt;code&gt;/api/progress/*&lt;/code&gt;), voice (&lt;code&gt;/api/transcribe&lt;/code&gt;), and chat history (&lt;code&gt;/api/history&lt;/code&gt;). FastAPI (a modern Python web framework) also auto-generates interactive API documentation at &lt;code&gt;/api/docs&lt;/code&gt;, which is the best way to explore the backend without reading a line of code.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Layer 2 — the intelligence layer.&lt;/strong&gt; Two AI models with two different jobs. &lt;code&gt;gemma4:e4b&lt;/code&gt; &lt;em&gt;generates&lt;/em&gt;: chat answers, reasoning, image analysis, and tool calls. &lt;code&gt;embeddinggemma&lt;/code&gt; &lt;em&gt;embeds&lt;/em&gt;: it turns text into vectors (lists of numbers that capture meaning) so similar ideas can be found mathematically. Both run inside Ollama — think of Ollama as Docker, but for AI models.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Layer 3 — the retrieval layer.&lt;/strong&gt; A search engine over your documents that combines &lt;em&gt;semantic&lt;/em&gt; search (find things that mean the same) with &lt;em&gt;keyword&lt;/em&gt; search (find the exact string). Part 3 of this series is entirely about this layer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Layer 4 — the persistence layer.&lt;/strong&gt; Four storage systems, each picked for one job: a FAISS index plus a JSON file for searchable knowledge, SQLite for study data, PostgreSQL for workflow checkpoints, and plain JSON files for chat history.&lt;/p&gt;
&lt;h2 id="one-diagram-every-major-piece"&gt;One diagram, every major piece&lt;/h2&gt;
&lt;div class="mermaid"&gt;flowchart TB
subgraph CLIENT["Browser"]
UI["React Frontend&lt;br/&gt;(compiled, served by FastAPI)"]
end
subgraph SERVER["FastAPI Backend — port 8000"]
ROUTERS["6 Routers&lt;br/&gt;rag · knowledge · study ·&lt;br/&gt;progress · audio · history"]
AGENT["RAG Agent&lt;br/&gt;(Strands SDK, 6 tools)"]
VDB["VectorDB&lt;br/&gt;FAISS + BM25 + RRF"]
INGEST["Ingestion&lt;br/&gt;(DBOS durable workflow)"]
GEN["Study generators&lt;br/&gt;quiz · workshop · cards · mindmap"]
PROG["Progress tracker&lt;br/&gt;+ 25 achievements"]
end
subgraph OLLAMA["Ollama — port 11434"]
GEMMA["gemma4:e4b&lt;br/&gt;chat · thinking · vision · tools"]
EMBED["embeddinggemma&lt;br/&gt;text to vectors"]
end
subgraph STORAGE["Local storage"]
FAISSF["vector_store.faiss + .json"]
SQLITE["progress.db (SQLite)"]
PG["PostgreSQL&lt;br/&gt;workflow state only"]
DOCS["docs/ folder + chat_history.json"]
end
UI --&gt; ROUTERS
ROUTERS --&gt; AGENT --&gt; VDB
AGENT --&gt; GEMMA
VDB --&gt; EMBED
ROUTERS --&gt; INGEST --&gt; EMBED
INGEST --&gt; PG
INGEST --&gt; FAISSF
VDB --- FAISSF
ROUTERS --&gt; GEN --&gt; GEMMA
GEN --&gt; SQLITE
ROUTERS --&gt; PROG --&gt; SQLITE
ROUTERS --&gt; DOCS
&lt;/div&gt;
&lt;p&gt;Keep this picture handy — Parts 2, 3, and 4 each zoom into one region of it.&lt;/p&gt;
&lt;h2 id="the-tech-stack-and-why-each-piece-earned-its-place"&gt;The tech stack, and why each piece earned its place&lt;/h2&gt;
&lt;p&gt;The full dependency list lives in &lt;code&gt;requirements.txt&lt;/code&gt;. Here&amp;rsquo;s what matters, grouped by job:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Serving requests.&lt;/strong&gt; FastAPI defines the endpoints and validates every request and response with Pydantic (a data-validation library — think of it as a strict customs officer for JSON). Uvicorn is the ASGI server (Asynchronous Server Gateway Interface — the Python standard that lets one process juggle many simultaneous requests) that actually runs it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thinking.&lt;/strong&gt; Ollama serves &lt;code&gt;gemma4:e4b&lt;/code&gt; — the &lt;code&gt;e4b&lt;/code&gt; tag is the roughly four-billion effective-parameter variant, about a 9.6 GB download — and &lt;code&gt;embeddinggemma&lt;/code&gt; (about 622 MB). The agent behaviour is built with the Strands Agents SDK, which wraps the model in a loop where it can call tools, read the results, and only then answer. (Where I run Ollama relative to Docker is a deliberate choice with a story behind it:
.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Finding things.&lt;/strong&gt; FAISS (Facebook AI Similarity Search — Meta&amp;rsquo;s vector search library) handles semantic lookups; &lt;code&gt;rank-bm25&lt;/code&gt; handles keyword lookups; a formula called Reciprocal Rank Fusion merges the two. Part 3 unpacks all of this.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reading documents.&lt;/strong&gt; &lt;code&gt;pypdf&lt;/code&gt; for PDFs, with an OCR fallback (Optical Character Recognition — turning pictures of text into actual text) for scanned pages via &lt;code&gt;pymupdf&lt;/code&gt; and Tesseract. Word, PowerPoint, and Excel each get their own extractor. &lt;code&gt;trafilatura&lt;/code&gt; pulls clean article text out of web pages.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Not losing work.&lt;/strong&gt; DBOS makes the ingestion pipeline durable — every step is checkpointed in PostgreSQL so a crash resumes instead of restarting. Part 2 shows this in action.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Remembering.&lt;/strong&gt; SQLite — a complete database engine that lives in a single file, &lt;code&gt;progress.db&lt;/code&gt; — holds your study sessions, achievements, quizzes, workshops, flashcard decks, and mindmaps.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="appendix-abbreviations-in-this-post"&gt;Appendix: Abbreviations in this post&lt;/h2&gt;
&lt;p&gt;This series&amp;rsquo; promise is &amp;ldquo;no unexplained abbreviations,&amp;rdquo; so here is the table I wish every technical tutorial shipped with.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Plain-English meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large Language Model&lt;/td&gt;
&lt;td&gt;A neural network trained on huge amounts of text that can read and generate language&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Retrieval-Augmented Generation&lt;/td&gt;
&lt;td&gt;Fetch relevant passages from &lt;em&gt;your&lt;/em&gt; documents first, then let the model answer from them — instead of from its training memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;The set of URLs the frontend calls to talk to the backend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ASGI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Asynchronous Server Gateway Interface&lt;/td&gt;
&lt;td&gt;The Python standard that lets the server handle many requests concurrently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;The universal text format for structured data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NDJSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Newline-Delimited JSON&lt;/td&gt;
&lt;td&gt;A stream where each line is its own JSON object — ideal for streaming AI answers chunk by chunk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Meta&amp;rsquo;s library for storing vectors and finding the most similar ones fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BM25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Best Match 25&lt;/td&gt;
&lt;td&gt;A classic keyword-ranking formula — the 25th ranking function developed in the Okapi information-retrieval system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reciprocal Rank Fusion&lt;/td&gt;
&lt;td&gt;A formula for merging multiple ranked result lists using only the ranks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ANN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Approximate Nearest Neighbour&lt;/td&gt;
&lt;td&gt;A speed shortcut many vector databases take. CogniVault deliberately uses an &lt;em&gt;exact&lt;/em&gt; index instead — precise, and plenty fast at personal-library scale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System (the research project it grew from)&lt;/td&gt;
&lt;td&gt;A library that checkpoints workflow steps in a database so crashed jobs resume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQL / SQLite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Structured Query Language / SQLite&lt;/td&gt;
&lt;td&gt;The language of relational databases / a tiny database that lives in one file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OCR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Optical Character Recognition&lt;/td&gt;
&lt;td&gt;Turning pictures of text (scans) into machine-readable text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SHA-256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Secure Hash Algorithm, 256-bit&lt;/td&gt;
&lt;td&gt;A fingerprint function — any file maps to a unique hash, used to detect changed files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CORS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cross-Origin Resource Sharing&lt;/td&gt;
&lt;td&gt;Browser rules controlling which websites may call the API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SSRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Server-Side Request Forgery&lt;/td&gt;
&lt;td&gt;An attack where a server is tricked into fetching internal URLs — the URL-import endpoint guards against it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MCQ&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-Choice Question&lt;/td&gt;
&lt;td&gt;One of the two quiz question types&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;KB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Knowledge Base&lt;/td&gt;
&lt;td&gt;All your ingested, searchable documents&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;(Every claim in this series can be checked directly against the
— the relevant file is named whenever it matters, and the repository README maps the full architecture.)&lt;/p&gt;
&lt;h2 id="the-takeaway"&gt;The takeaway&lt;/h2&gt;
&lt;p&gt;Strip away the abbreviations and CogniVault is a small system: one web server, one model runtime, one durability database, and a handful of files. The sophistication isn&amp;rsquo;t in the part count — it&amp;rsquo;s in how a few well-chosen pieces cooperate. That cooperation is what the next three parts are about.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt;
— how a 1,000-page scanned PDF becomes something the AI can search in seconds, and why the pipeline survives a crash at page 800.&lt;/p&gt;</description></item><item><title>CogniVault Backend Explained, Part 2 · From File to Searchable Knowledge</title><link>https://aretascodes.dev/blog/backend-explained-ingestion/</link><pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/blog/backend-explained-ingestion/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;All abbreviations are fully explained in the appendix at the bottom of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;An LLM cannot &amp;ldquo;open&amp;rdquo; your PDF. That sentence surprises a lot of newcomers, so let&amp;rsquo;s sit with it for a second: when you chat with your documents in CogniVault, the model never touches the original files. Something has to happen &lt;em&gt;between&lt;/em&gt; &amp;ldquo;I dropped a file into the browser&amp;rdquo; and &amp;ldquo;the AI just quoted page 47 back at me.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;That something is &lt;strong&gt;ingestion&lt;/strong&gt;, and it&amp;rsquo;s the subject of this part. In
we drew the whole map; today we zoom into one region — the conveyor belt that turns files into searchable knowledge.&lt;/p&gt;
&lt;h2 id="the-conveyor-belt"&gt;The conveyor belt&lt;/h2&gt;
&lt;p&gt;Think of ingestion as a four-station assembly line:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Extract&lt;/strong&gt; the text out of each file — even scanned ones.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Chunk&lt;/strong&gt; it into pieces small enough to fit into a prompt.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Embed&lt;/strong&gt; each chunk — turn it into a vector (a list of numbers that captures its meaning) so similar ideas land near each other in vector space.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Store&lt;/strong&gt; vectors and metadata so they can be searched later.&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="mermaid"&gt;flowchart TD
A["Upload&lt;br/&gt;POST /upload&lt;br/&gt;saved to docs/"] --&gt; B
subgraph WF["DBOS durable workflow"]
B["Step 1&lt;br/&gt;Which files changed?&lt;br/&gt;SHA-256 fingerprints"] --&gt; C["Step 2&lt;br/&gt;Extract text&lt;br/&gt;per-format + OCR fallback"]
C --&gt; D["Chunk&lt;br/&gt;1000 chars, 100 overlap"]
D --&gt; E["Step 3&lt;br/&gt;Embed&lt;br/&gt;embeddinggemma, batches of 5"]
E --&gt; F["Step 4&lt;br/&gt;Save&lt;br/&gt;FAISS index + metadata JSON"]
end
F --&gt; G["Reload in-memory index&lt;br/&gt;instantly searchable"]
&lt;/div&gt;
&lt;p&gt;Simple enough. The interesting engineering is in the failure cases — so let&amp;rsquo;s start there.&lt;/p&gt;
&lt;h2 id="the-factory-ledger-why-the-pipeline-cant-lose-work"&gt;The factory ledger: why the pipeline can&amp;rsquo;t lose work&lt;/h2&gt;
&lt;p&gt;Embedding a large library takes minutes. What happens when your laptop goes to sleep at page 800 of a 1,000-page manual? With a plain Python script: everything restarts from page 1.&lt;/p&gt;
&lt;p&gt;CogniVault instead writes the pipeline as a &lt;strong&gt;DBOS durable workflow&lt;/strong&gt;. Picture a factory where every station stamps a permanent ledger the moment it finishes a box. If the power cuts out, nobody rebuilds finished boxes — the workers read the ledger and resume at the first unstamped entry.&lt;/p&gt;
&lt;p&gt;DBOS is that ledger, and PostgreSQL is the book it&amp;rsquo;s written in. Each pipeline station is a checkpointed step; on restart, completed steps return their recorded results instantly and execution continues from the first unfinished one. A failed embedding batch is simply retried.&lt;/p&gt;
&lt;p&gt;This is also what powers the live progress timeline in the UI: starting an ingestion returns a &lt;code&gt;workflow_id&lt;/code&gt;, and the frontend polls a status endpoint that reports which steps have completed, which are running, and which are still waiting.&lt;/p&gt;
&lt;p&gt;I wrote a whole deep dive on this mechanism — including what happens when you &lt;code&gt;kill -9&lt;/code&gt; the process mid-ingest — in
.&lt;/p&gt;
&lt;h2 id="fingerprints-not-faith-sha-256-change-detection"&gt;Fingerprints, not faith: SHA-256 change detection&lt;/h2&gt;
&lt;p&gt;Re-embedding your whole library every time you add one file would be wasteful. So before any work happens, the pipeline computes each file&amp;rsquo;s &lt;strong&gt;SHA-256 hash&lt;/strong&gt; (a content fingerprint — change one character in the file and the fingerprint changes completely) and compares it to the fingerprint stored with the file&amp;rsquo;s existing chunks:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Never seen before&lt;/strong&gt; → ingest it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fingerprint changed&lt;/strong&gt; → the old chunks are &lt;em&gt;soft-deleted&lt;/em&gt; and the file is re-ingested.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fingerprint identical&lt;/strong&gt; → skip it entirely.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Why &amp;ldquo;soft&amp;rdquo;-deleted? Because the FAISS index type CogniVault uses cannot remove individual vectors. Stale chunks are just marked &lt;code&gt;deleted: true&lt;/code&gt; in the metadata; their vectors stay in the index but every search filters them out. It&amp;rsquo;s an honest, boring solution — and it never corrupts the index.&lt;/p&gt;
&lt;h2 id="every-format-gets-its-own-treatment"&gt;Every format gets its own treatment&lt;/h2&gt;
&lt;p&gt;Here&amp;rsquo;s a detail that separates a demo from a product. A naive pipeline extracts &amp;ldquo;all the text&amp;rdquo; and calls it a day. CogniVault gives each format an extractor that preserves the &lt;em&gt;structure&lt;/em&gt; that retrieval will need later:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Page by page, keeping page numbers (those become citations later). Any page yielding fewer than 50 characters is presumed scanned and sent to OCR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scanned page&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The page is rendered to an image at roughly 144 dpi, then Tesseract OCR (Optical Character Recognition — reading text out of images) extracts the words&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Markdown&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Split on headings; each section chunk gets a breadcrumb prefix like &lt;code&gt;[Section: Intro &amp;gt; Setup]&lt;/code&gt; so its embedding carries the document hierarchy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CSV&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Rows grouped 20 per chunk — and &lt;em&gt;every&lt;/em&gt; chunk is prefixed with the header row, so the model always knows the column names&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Excel&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Same row-group idea per sheet, prefixed &lt;code&gt;[Sheet: name]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PowerPoint&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One chunk per slide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Word&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Paragraphs plus table cells&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Web pages&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fetched on request and stripped to clean article text — behind an SSRF guard (Server-Side Request Forgery protection: the server refuses to fetch private or internal addresses)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Ask yourself why the CSV detail matters. If chunk 14 of a spreadsheet is just twenty naked rows of numbers, no search will ever connect it to the question &amp;ldquo;what was the Q3 budget?&amp;rdquo; Prefix it with the header row, and the chunk &lt;em&gt;knows&lt;/em&gt; it contains budget columns. Structure is retrieval fuel.&lt;/p&gt;
&lt;h2 id="chunking-1000-characters-with-a-100-character-safety-overlap"&gt;Chunking: 1,000 characters with a 100-character safety overlap&lt;/h2&gt;
&lt;p&gt;Long text is split into pieces of about 1,000 characters, with neighbouring pieces overlapping by 100. The overlap is insurance: a sentence sliced at a chunk boundary still appears whole in one of the two neighbours, so no idea falls into the gap between chunks.&lt;/p&gt;
&lt;h2 id="embedding-and-saving"&gt;Embedding and saving&lt;/h2&gt;
&lt;p&gt;Chunks are embedded by &lt;code&gt;embeddinggemma&lt;/code&gt; (via Ollama) in batches of five — each chunk becomes one vector. The vectors are normalised and appended to a FAISS index; alongside it, a JSON file records each chunk&amp;rsquo;s source filename, page number, category, fingerprint, and the text itself. The index holds the numbers; the JSON holds the meaning.&lt;/p&gt;
&lt;p&gt;One choice worth highlighting for beginners: this is an &lt;strong&gt;exact&lt;/strong&gt; index, not an approximate one. Many vector databases use ANN (Approximate Nearest Neighbour) shortcuts that trade a little accuracy for speed at massive scale. At personal-library scale you don&amp;rsquo;t need the trade — CogniVault checks every vector on every search and is still fast.&lt;/p&gt;
&lt;h2 id="the-whole-journey-end-to-end"&gt;The whole journey, end to end&lt;/h2&gt;
&lt;div class="mermaid"&gt;%%{init: {'sequence': {'actorFontSize': 28, 'messageFontSize': 24, 'loopTextFontSize': 22, 'noteFontSize': 22}}}%%
sequenceDiagram
actor U as You
participant F as Frontend
participant B as FastAPI
participant W as DBOS Workflow
participant O as Ollama (embeddinggemma)
participant V as FAISS + metadata
U-&gt;&gt;F: Drag and drop a file, pick a category
F-&gt;&gt;B: POST /upload
B-&gt;&gt;B: Validate type and size, save to docs/
F-&gt;&gt;B: POST /ingest
B-&gt;&gt;W: Start durable workflow
B--&gt;&gt;F: workflow_id
loop Poll status
F-&gt;&gt;B: GET /ingest/status/{workflow_id}
B--&gt;&gt;F: Step list (drives the progress timeline)
end
W-&gt;&gt;W: SHA-256 change detection
W-&gt;&gt;W: Extract text (per format, OCR if scanned)
W-&gt;&gt;W: Chunk (1000 chars / 100 overlap)
W-&gt;&gt;O: Embed in batches of 5
O--&gt;&gt;W: Vectors
W-&gt;&gt;V: Append vectors + metadata
B--&gt;&gt;F: SUCCESS — index reloaded
F--&gt;&gt;U: "Knowledge Sync Complete"
&lt;/div&gt;
&lt;h2 id="the-takeaway"&gt;The takeaway&lt;/h2&gt;
&lt;p&gt;Ingestion is where most RAG quality is actually won or lost — long before any clever prompting. Page numbers preserved, headers carried into every spreadsheet chunk, scans rescued by OCR, and a ledger that makes the whole thing crash-proof: none of it is glamorous, all of it shows up later as answers that cite the right page.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id="appendix-abbreviations-in-this-post"&gt;Appendix: Abbreviations in this post&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large Language Model&lt;/td&gt;
&lt;td&gt;A neural network trained on huge amounts of text that can read and generate language&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System&lt;/td&gt;
&lt;td&gt;The library that checkpoints workflow steps in PostgreSQL so crashed jobs resume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SHA-256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Secure Hash Algorithm, 256-bit&lt;/td&gt;
&lt;td&gt;A content fingerprint — change one byte of a file and the hash changes completely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OCR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Optical Character Recognition&lt;/td&gt;
&lt;td&gt;Reading text out of images — the rescue path for scanned PDF pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SSRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Server-Side Request Forgery&lt;/td&gt;
&lt;td&gt;An attack where a server is tricked into fetching internal URLs; the URL importer blocks it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;The vector index the embeddings are appended to&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ANN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Approximate Nearest Neighbour&lt;/td&gt;
&lt;td&gt;The accuracy-for-speed shortcut CogniVault deliberately does &lt;em&gt;not&lt;/em&gt; take&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;dpi&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Dots Per Inch&lt;/td&gt;
&lt;td&gt;Image resolution — scanned pages are rendered at ~144 dpi before OCR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;The format of the chunk-metadata file beside the FAISS index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF / CSV&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Document Format / Comma-Separated Values&lt;/td&gt;
&lt;td&gt;Two of the eight-plus supported file formats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;The endpoints (&lt;code&gt;/upload&lt;/code&gt;, &lt;code&gt;/ingest&lt;/code&gt;, &lt;code&gt;/ingest/status/…&lt;/code&gt;) driving the flow&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt;
— hybrid retrieval, the six-tool agent, and the two-phase stream that shows the model think before it answers.&lt;/p&gt;</description></item><item><title>CogniVault Backend Explained, Part 3 · How a Question Becomes a Cited Answer</title><link>https://aretascodes.dev/blog/backend-explained-rag-agent/</link><pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/blog/backend-explained-rag-agent/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;All abbreviations are fully explained in the appendix at the bottom of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;You type a question. A few seconds later you get an answer with footnotes — the exact documents and pages it came from. This part walks through everything that happens in between.&lt;/p&gt;
&lt;p&gt;In
we built the knowledge base: every document chunked, embedded, and indexed. Now we get to &lt;em&gt;use&lt;/em&gt; it — and this is where CogniVault stops being a pipeline and starts being interesting.&lt;/p&gt;
&lt;h2 id="two-librarians-because-one-keeps-failing-you"&gt;Two librarians, because one keeps failing you&lt;/h2&gt;
&lt;p&gt;Imagine a library with one librarian who organises everything by &lt;em&gt;vibe&lt;/em&gt;. Ask her about &amp;ldquo;server downtime procedures&amp;rdquo; and she&amp;rsquo;s brilliant — she understands what you mean and finds documents that discuss the concept, whatever words they use. But ask her for &amp;ldquo;Error Code 404B&amp;rdquo; and she shrugs, handing you general networking guides. She doesn&amp;rsquo;t do exact strings.&lt;/p&gt;
&lt;p&gt;Down the hall is a second librarian with a card catalogue. He finds the exact string &amp;ldquo;404B&amp;rdquo; instantly — but ask him a conceptual question phrased differently from the source text, and he finds nothing at all.&lt;/p&gt;
&lt;p&gt;These are the two halves of search:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Semantic search (FAISS)&lt;/strong&gt; — your question is embedded into a vector, and the index finds chunks whose vectors point the same way (technically: cosine similarity — how closely two arrows align). Great for meaning, blind to exact identifiers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keyword search (BM25)&lt;/strong&gt; — a scoring formula that rewards chunks containing your &lt;em&gt;exact&lt;/em&gt; words, weighted by how distinctive those words are. Great for identifiers, blind to synonyms.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;CogniVault asks &lt;strong&gt;both librarians every time&lt;/strong&gt;, then merges their answers with &lt;strong&gt;Reciprocal Rank Fusion (RRF)&lt;/strong&gt; — a formula that combines ranked lists using only the &lt;em&gt;positions&lt;/em&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;score(chunk) = sum over both lists of 1 / (60 + rank)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A chunk ranked highly by either librarian scores well; a chunk both of them liked floats to the top. The elegance is what&amp;rsquo;s &lt;em&gt;missing&lt;/em&gt;: you never have to reconcile FAISS&amp;rsquo;s similarity scores with BM25&amp;rsquo;s completely different scale, because ranks are the only input. The constant 60 comes straight from the original 2009 research paper, and yes, it&amp;rsquo;s cited in the code.&lt;/p&gt;
&lt;p&gt;A few implementation details worth knowing: both searches deliberately over-fetch (at least 20 candidates each) so the fusion has material to work with; very weak semantic matches are dropped, but a keyword-perfect chunk can still be rescued through fusion; and the final answer uses the top 7 chunks. I benchmarked this whole setup against pure vector search in
if you want the war stories.&lt;/p&gt;
&lt;h2 id="the-agent-a-model-that-decides-for-itself"&gt;The agent: a model that decides for itself&lt;/h2&gt;
&lt;p&gt;Here&amp;rsquo;s the second idea that trips up beginners: CogniVault&amp;rsquo;s chat is not &amp;ldquo;paste chunks into a prompt, get an answer.&amp;rdquo; It&amp;rsquo;s an &lt;strong&gt;agent&lt;/strong&gt; — a model running in a loop where it can &lt;em&gt;choose&lt;/em&gt; to call tools, read their results, and only then answer.&lt;/p&gt;
&lt;p&gt;Built with the Strands Agents SDK, the agent gets six tools:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;search_knowledge_base&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The core RAG tool — runs the hybrid search above, returns chunks with source and page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list_documents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;See what&amp;rsquo;s in the vault&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;analyze_document&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Structured analysis of one document: topics, entities, facts, summary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;compare_documents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Answer a question by comparing two documents side by side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;calculator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Safe maths — the expression is parsed into a syntax tree and only whitelisted operators run. No &lt;code&gt;eval()&lt;/code&gt;, ever&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;current_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The date and time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;There is no hard-coded routing. The &lt;em&gt;model&lt;/em&gt; reads your question and decides which tools to call, guided by its system prompt. Ask &amp;ldquo;compare the two contracts on termination clauses&amp;rdquo; and it reaches for &lt;code&gt;compare_documents&lt;/code&gt;; ask &amp;ldquo;what&amp;rsquo;s 15% of 2,340&amp;rdquo; and it uses the calculator instead of hallucinating arithmetic.&lt;/p&gt;
&lt;p&gt;Two safety details I want beginners to notice, because they&amp;rsquo;re the difference between a toy and a product: a &lt;strong&gt;fresh agent is constructed for every request&lt;/strong&gt; (no shared state bleeding between concurrent chats), and the document-analysis tools call the model &lt;em&gt;directly&lt;/em&gt; rather than through the agent — otherwise an agent calling a tool that calls the agent could recurse forever.&lt;/p&gt;
&lt;h2 id="watching-the-model-think"&gt;Watching the model think&lt;/h2&gt;
&lt;p&gt;When you send a message, the response streams back as &lt;strong&gt;NDJSON&lt;/strong&gt; (Newline-Delimited JSON — each line of the stream is its own small JSON object). And it arrives in two phases:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 1 — thinking.&lt;/strong&gt; Gemma&amp;rsquo;s reasoning chain streams first, rendered in the collapsible panel above the answer. It&amp;rsquo;s deliberately best-effort: if it fails for any reason, the answer still comes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 2 — the agent answer.&lt;/strong&gt; Tools run, citations appear in the Sources panel the moment the search completes — &lt;em&gt;before&lt;/em&gt; the answer finishes writing — and the answer text streams in.&lt;/p&gt;
&lt;div class="mermaid"&gt;flowchart TB
Q["Your question&lt;br/&gt;(plus optional images, files, scope)"] --&gt; P1
subgraph STREAM["POST /rag — one NDJSON stream"]
P1["Phase 1: Thinking&lt;br/&gt;reasoning chunks stream first"]
P1 --&gt; P2["Phase 2: Agent&lt;br/&gt;fresh per request, history restored"]
P2 --&gt;|"decides to call"| T["search_knowledge_base"]
T --&gt; D["FAISS&lt;br/&gt;semantic"]
T --&gt; S["BM25&lt;br/&gt;keywords"]
D --&gt; RRF["RRF fusion — top 7 chunks"]
S --&gt; RRF
RRF --&gt;|"chunks + citations"| P2
P2 --&gt; OUT["citations, then answer text,&lt;br/&gt;then a memory-usage report"]
end
&lt;/div&gt;
&lt;p&gt;Each line in the stream is typed: &lt;code&gt;thinking&lt;/code&gt;, &lt;code&gt;metadata&lt;/code&gt; (a citation), &lt;code&gt;text&lt;/code&gt; (answer), &lt;code&gt;memory&lt;/code&gt; (how full the conversation budget is), or &lt;code&gt;error&lt;/code&gt;. The frontend just reads lines and routes them to the right panel. I dissected this design — and why thinking comes &lt;em&gt;before&lt;/em&gt; the tool calls — in
.&lt;/p&gt;
&lt;h2 id="a-memory-budget-not-a-bottomless-pit"&gt;A memory budget, not a bottomless pit&lt;/h2&gt;
&lt;p&gt;Gemma&amp;rsquo;s context window (the amount of text the model can consider at once) is 128K tokens, but CogniVault doesn&amp;rsquo;t let conversation history sprawl across all of it. Each chat session gets a budget of 48,000 characters — roughly 12,000 tokens. Exceed it, and the &lt;em&gt;oldest&lt;/em&gt; question-answer pair quietly drops out first, keeping the bulk of the window free for what matters: your current question and the retrieved chunks.&lt;/p&gt;
&lt;p&gt;Two resilience touches worth stealing for your own projects:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Restart survival.&lt;/strong&gt; In-memory history dies with the process. So the first message in a session after a backend restart rebuilds its history from the chat log the frontend persists. Multi-turn memory survives reboots.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Edit and regenerate.&lt;/strong&gt; Editing an earlier message rewinds the stored history to that point before re-asking — the model genuinely forgets the timeline that no longer exists.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="scope-pinning-the-ai-to-specific-documents"&gt;Scope: pinning the AI to specific documents&lt;/h2&gt;
&lt;p&gt;One last feature, and a lesson about small local models. You can pin a chat to specific files or a category. The filter travels with the request &lt;em&gt;and&lt;/em&gt; a mandatory-search instruction is injected into both the system prompt and the user message itself.&lt;/p&gt;
&lt;p&gt;Why both? Because small models sometimes skip instructions that live only in the system prompt — but they can&amp;rsquo;t ignore what&amp;rsquo;s inside the question. Belt and braces. When you work with 4-billion-parameter models instead of frontier ones, you learn to make instructions impossible to miss rather than hoping they&amp;rsquo;re followed.&lt;/p&gt;
&lt;h2 id="the-takeaway"&gt;The takeaway&lt;/h2&gt;
&lt;p&gt;A cited answer is four systems cooperating: two retrievers covering each other&amp;rsquo;s blind spots, a fusion formula that needs nothing but ranks, an agent that picks its own tools, and a stream that shows its work. None of the four is exotic on its own — the product is the cooperation.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="appendix-abbreviations-in-this-post"&gt;Appendix: Abbreviations in this post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Retrieval-Augmented Generation&lt;/td&gt;
&lt;td&gt;Retrieve relevant passages from your own documents first; let the model answer from them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;The semantic (meaning-based) half of hybrid search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BM25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Best Match 25&lt;/td&gt;
&lt;td&gt;The keyword half — a classic ranking formula from the Okapi information-retrieval system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reciprocal Rank Fusion&lt;/td&gt;
&lt;td&gt;Merges the two ranked lists using only each chunk&amp;rsquo;s rank: &lt;code&gt;score = Σ 1/(60 + rank)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NDJSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Newline-Delimited JSON&lt;/td&gt;
&lt;td&gt;A stream where each line is its own complete JSON object — the chat response format&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;The universal text format for structured data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AST&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Abstract Syntax Tree&lt;/td&gt;
&lt;td&gt;The parsed form of an expression — how the calculator does maths without &lt;code&gt;eval()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large Language Model&lt;/td&gt;
&lt;td&gt;A neural network trained on huge amounts of text that can read and generate language&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SDK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Software Development Kit&lt;/td&gt;
&lt;td&gt;A library of building blocks — here, Strands, which provides the agent loop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;K&lt;/strong&gt; (in 128K)&lt;/td&gt;
&lt;td&gt;Kilo (thousand)&lt;/td&gt;
&lt;td&gt;128K tokens ≈ 128,000 tokens — Gemma&amp;rsquo;s context window&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt;
— the same machinery pointed at generating quizzes, workshops, flashcards, and mindmaps, plus a table of every byte the app stores and exactly where it lives.&lt;/p&gt;</description></item><item><title>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 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><item><title>Part 1 · Why I Built a Local-First RAG</title><link>https://aretascodes.dev/blog/why-local-first-rag/</link><pubDate>Mon, 20 Apr 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/blog/why-local-first-rag/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;All abbreviations are fully explained in the appendix at the bottom of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I&amp;rsquo;ve spent the last few years in front of virtual classrooms full of career-changers in Germany, walking them through programming basics, web development, and introductory AI courses. Most of the information we deal with is fine to paste into cloud-based AI tools. Some of it really isn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;Exam materials under confidentiality. A trainee&amp;rsquo;s portfolio with personal details. Other private documents that should never end up training someone else&amp;rsquo;s model.&lt;/p&gt;
&lt;p&gt;So I built
— a fully local AI study and productivity tool. No cloud. No telemetry. No &amp;ldquo;we may use this data to improve our service.&amp;rdquo; Just Gemma 4 running on Ollama, on my laptop, talking to my files.&lt;/p&gt;
&lt;h2 id="the-leaky-abstraction"&gt;The leaky abstraction&lt;/h2&gt;
&lt;p&gt;The pitch for cloud AI is great: a giant model, available instantly, billed by the token. The fine print is where it gets uncomfortable:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Where does the data physically live during inference?&lt;/li&gt;
&lt;li&gt;Whose jurisdiction governs that hardware this afternoon?&lt;/li&gt;
&lt;li&gt;Does the &lt;em&gt;audit trail&lt;/em&gt; stop at the API boundary, or can you actually trace what happened to your bytes?&lt;/li&gt;
&lt;li&gt;When you tick &amp;ldquo;do not train on my data,&amp;rdquo; are you trusting a control, a contract, or both?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For most consumer use cases, those questions are fine to wave away. For &lt;strong&gt;education, healthcare, finance, legal, public administration&lt;/strong&gt; — the answer &amp;ldquo;trust us&amp;rdquo; isn&amp;rsquo;t an answer.&lt;/p&gt;
&lt;h2 id="what-local-first-actually-means-here"&gt;What &amp;ldquo;local-first&amp;rdquo; actually means here&lt;/h2&gt;
&lt;p&gt;Lots of products say &amp;ldquo;private.&amp;rdquo; I wanted three concrete properties:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The model lives on your machine.&lt;/strong&gt; Gemma 4 (&lt;code&gt;gemma4:e4b&lt;/code&gt;) and &lt;code&gt;embeddinggemma&lt;/code&gt; are pulled via Ollama. Inference is a localhost HTTP call.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Your documents never leave.&lt;/strong&gt; Vectors, chunks, chat history, study sessions, achievements — all on disk on your computer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You can &lt;em&gt;verify&lt;/em&gt; it.&lt;/strong&gt; Gemma CogniVault ships a &lt;strong&gt;Privacy Audit Panel&lt;/strong&gt; that shows a live &amp;ldquo;zero external connections&amp;rdquo; indicator alongside document counts and the Ollama host. It&amp;rsquo;s not a promise — it&amp;rsquo;s a status light.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If a future build of Gemma CogniVault ever made an outbound call, that panel would be the first thing to scream.&lt;/p&gt;
&lt;h2 id="what-you-get-back"&gt;What you get back&lt;/h2&gt;
&lt;p&gt;Going local sounds like a trade-off — surely you lose the magic of the giant frontier models? In practice, with &lt;strong&gt;Gemma 4&lt;/strong&gt; you get more than enough:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Thinking mode&lt;/strong&gt; — Gemma 4&amp;rsquo;s chain-of-thought streams into a collapsible panel before the answer. Watching the model reason about your documents is genuinely useful as a teaching tool.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tool use&lt;/strong&gt; — through the
, the model decides when to search the knowledge base, summarise a document, compare two files, or check the time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vision&lt;/strong&gt; — attach images and PDFs straight into a chat turn.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Generation that&amp;rsquo;s actually structured&lt;/strong&gt; — quizzes, multi-lesson workshops, flashcard decks, and interactive mindmaps, generated with &lt;code&gt;format=&amp;quot;json&amp;quot;&lt;/code&gt; so the output parses reliably.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Cognivault doesn&amp;rsquo;t try to be a giant ecosystem. It&amp;rsquo;s a single-purpose tool that does one thing well: use your own documents with a capable local model in a private environment. I must admit that it was inspired to a great extent by
, which I&amp;rsquo;ve found incredibly useful but not private enough for my needs.&lt;/p&gt;
&lt;h2 id="the-shape-of-the-app"&gt;The shape of the app&lt;/h2&gt;
&lt;p&gt;CogniVault is split into four sections that map to how I actually work with information on cloud-based AI tools:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Section&lt;/th&gt;
&lt;th&gt;What it&amp;rsquo;s for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Chat&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ask anything about your documents. Cited answers, scope filter, voice in.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Knowledge Base&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Upload, categorise, manage. SHA-256 detects edits on re-upload.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Study Hub&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Quiz · Workshop · Flashcards · Mindmaps — four ways to drill into the source.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dashboard&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Total study time, streak, 25 badges, GitHub-style 90-day heatmap.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Everything reachable from a sidebar that remembers where you left off, on a stack that fits in your &lt;code&gt;~/Documents&lt;/code&gt; folder.&lt;/p&gt;
&lt;h2 id="what-comes-next"&gt;What comes next&lt;/h2&gt;
&lt;p&gt;This is the first in a short series. Over the next few posts I&amp;rsquo;ll dig into the parts I&amp;rsquo;m most proud of — and a few I&amp;rsquo;d build differently next time:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hybrid retrieval&lt;/strong&gt; — why FAISS &lt;em&gt;and&lt;/em&gt; BM25, fused with Reciprocal Rank Fusion&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Two-phase streaming&lt;/strong&gt; with Gemma 4 and Strands Agents&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Crash-resumable ingestion&lt;/strong&gt; with DBOS, hash-aware re-ingest, OCR fallback&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Getting reliable JSON&lt;/strong&gt; out of a local LLM (and what to do when it fails)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The mindmap renderer&lt;/strong&gt; — what hand-rolling SVG taught me, and why v2 uses React Flow&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gamifying learning&lt;/strong&gt; — 25 badges, idle-gap sessions, 90-day heatmap&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Testing a local-AI app&lt;/strong&gt; with 350+ tests and zero infrastructure&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you want to skip ahead, the code is open source at
, and there&amp;rsquo;s a
.&lt;/p&gt;
&lt;p&gt;Your data. Your hardware. Your AI. Your vault.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="appendix-abbreviations-in-this-post"&gt;Appendix: Abbreviations in this post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Retrieval-Augmented Generation&lt;/td&gt;
&lt;td&gt;Retrieve relevant passages from your own documents first; let the model answer from them instead of from training memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Artificial Intelligence&lt;/td&gt;
&lt;td&gt;Software performing tasks that normally need human intelligence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large Language Model&lt;/td&gt;
&lt;td&gt;A neural network trained on huge amounts of text that can read and generate language&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTTP&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HyperText Transfer Protocol&lt;/td&gt;
&lt;td&gt;The protocol browsers and APIs use to exchange requests and responses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;The boundary where you call someone else&amp;rsquo;s software — and where cloud audit trails stop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IHK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Industrie- und Handelskammer&lt;/td&gt;
&lt;td&gt;The German Chamber of Commerce and Industry, which administers trainer certification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AEVO&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ausbildereignungsverordnung&lt;/td&gt;
&lt;td&gt;The German trainer-aptitude regulation — the exam material that motivated this project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Meta&amp;rsquo;s vector-search library (covered in the next post)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BM25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Best Match 25&lt;/td&gt;
&lt;td&gt;A classic keyword-ranking formula (also next post)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SDK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Software Development Kit&lt;/td&gt;
&lt;td&gt;A library of building blocks — here, Strands, which provides the agent loop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;The universal text format for structured data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Document Format&lt;/td&gt;
&lt;td&gt;One of the eight-plus file types CogniVault ingests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SHA-256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Secure Hash Algorithm, 256-bit&lt;/td&gt;
&lt;td&gt;A content fingerprint used to detect edited files on re-upload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OCR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Optical Character Recognition&lt;/td&gt;
&lt;td&gt;Turning pictures of text (scans) into machine-readable text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System&lt;/td&gt;
&lt;td&gt;The durable-workflow library behind crash-resumable ingestion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SVG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scalable Vector Graphics&lt;/td&gt;
&lt;td&gt;The browser&amp;rsquo;s built-in vector drawing format&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;</description></item></channel></rss>