Local RAG Over Audit Reports: Searching Five Years of Vulnerabilities Offline A developer built a local retrieval-augmented generation (RAG) system to search through five years of public audit reports offline, using Ollama for embeddings and SQLite for storage. The key innovation is a chunking strategy that detects finding boundaries instead of using fixed-size windows, preserving the natural atomic unit of audit findings for better search results. The system allows querying vulnerability knowledge without API costs or rate limits. Last month I was reviewing a vault contract and had that itch: I have seen this exact rounding bug before, in some audit report, two or three years ago, something with ERC4626 share math. I spent forty minutes grepping through a folder of PDFs and markdown files and never found it. The knowledge existed on my disk. I just couldn't query it. Public audit reports are one of the most underused resources in this field. Sherlock contest reports, Code4rena findings, Trail of Bits publications, OpenZeppelin audits: thousands of real vulnerabilities, described by the people who found them, with the exact code patterns that caused them. But they're scattered across PDFs, GitHub repos, and judging platforms, and keyword search fails you because the same bug gets described ten different ways. "Rounding direction favors attacker", "share price inflation", "first depositor attack", "donation attack": four phrasings, one family of bugs. That's an embeddings problem. So I built a local RAG over my report collection. Ollama for embeddings, SQLite for storage, everything offline. No API costs, no rate limits, and I can query it on a plane. Here's how, including the one decision that matters more than all the others. Every RAG tutorial tells you to split documents into fixed-size chunks with overlap. For audit reports that's actively harmful. A 500-token window will happily slice a finding in half, gluing the tail of "H-02: Reentrancy in withdraw" to the head of "H-03: Oracle staleness not checked". Your embedding then represents a chimera that matches nothing well. Audit reports have a natural atomic unit: the finding. One title, one severity, one description, one code snippet, one fix. Findings are almost always short enough to embed whole, and they're exactly the granularity you want back as a search result. So the chunker's job is to detect finding boundaries, and audit reports are formulaic enough that this works well: interface Finding { id: string; // "H-01", "M-07", "TOB-XYZ-3" severity: string; // parsed from the id or heading title: string; body: string; // full finding text incl. code blocks source: string; // report file path protocol: string; // from report metadata date: string; } const FINDING HEADING = /^ {1,4}\s \ ? ?:H|M|L|QA|G -\d+|TOB- A-Z0-9- +-\d+|C4-\d+ \ ? :.\s- + .+ $/; export function chunkReport markdown: string, meta: ReportMeta : Finding { const lines = markdown.split "\n" ; const findings: Finding = ; let current: { id: string; title: string; start: number } | null = null; const flush = end: number = { if current return; const body = lines.slice current.start, end .join "\n" .trim ; if body.length < 200 return; // skip stubs and withdrawn findings findings.push { id: current.id, severity: severityFromId current.id , title: current.title, body, source: meta.path, protocol: meta.protocol, date: meta.date, } ; }; lines.forEach line, i = { const m = line.match FINDING HEADING ; if m?. 1 && m 2 { flush i ; current = { id: m 1 , title: m 2 .trim , start: i }; } } ; flush lines.length ; return findings; } That regex covers Code4rena and Sherlock conventions plus Trail of Bits IDs. Real life needs a couple more variants some firms use "Finding 3:", some use severity words as headings , and PDFs need a text-extraction pass first I use a CLI converter and accept the mess . It doesn't have to be perfect. A chunker that correctly isolates 90% of findings beats fixed-size windows by a mile. One more trick: I prepend the metadata into the text that gets embedded, so "protocol: , severity: High" is part of what the vector represents. Queries that mention severity or protocol type then work for free. You do not need a vector database for this. My whole corpus is a few thousand findings, and brute-force cosine similarity over a few thousand vectors takes milliseconds. SQLite holds everything: python import Database from "better-sqlite3"; const db = new Database "audits.db" ; db.exec CREATE TABLE IF NOT EXISTS findings id INTEGER PRIMARY KEY, finding id TEXT, severity TEXT, title TEXT, body TEXT, protocol TEXT, source TEXT, date TEXT, embedding BLOB ; async function embed text: string : Promise