A local document agent in one SQLite file (sqlite-vec, FTS5)
January 22, 2026
A business owner had become his own company's search engine: any question about an invoice, a contract or a bank statement meant twenty minutes of digging through folders. I built a document agent that reads every incoming PDF and scan, files it on its own, and answers plain-language questions with the exact page and a link to the source file. The idea that makes it work: AI models do the reading and the filing, but the entire searchable brain lives in one SQLite file on his own machine. No vector database, no cloud service holding his financials, one file you back up by copying it.
You are reading the plain-language version. Switch to Tech for the code and the architecture.
Question
how much did I pay this supplier in 2024
Answer
The amount, the exact line, and the path to the source PDF to check in one click.
Sources
2024-03-11_facture_fournisseur-x.pdfI build private document search you run on your own machine, over years of your own PDFs. You ask “how much did I pay this supplier last year”, and you get the number, the exact page it sits on, and a one-click link to the file so you can check it. The whole searchable brain is one file on your machine, and 0 documents ever leave it.
Why this matters to you
Someone on your side already does this search by hand. Friday night, folder by folder, hunting one invoice they know exists across two years of scans. That person became your company’s search engine, and it costs an evening every time you need an old document in a hurry. The obvious shortcut, uploading the whole archive to a hosted AI, means handing your financials and your private contracts to a third party. Most owners refuse that, and they’re right to.
What it costs you today
Every lookup is a person stopping their real job to dig. Finding one old invoice can burn an evening, and that’s an evening nobody bills. A lease clause quoted from memory because pulling the source takes too long is a decision made on a guess. And the day the one person who knows where everything lives is out sick or leaves, the search engine walks out the door with them. All the knowledge is in the files. The cost is that reaching it depends on one overloaded human.
What actually helps
An answer in under 2 seconds instead of an evening, drawn only from your own documents, with the exact page and a link so you verify in one click. It reads cheaply: most digital PDFs cost nothing to process, so a model only gets paid for the real scans, and the search index lives in one file with no hosted vector database and about $0 of infrastructure a month. When a document is genuinely ambiguous, it waits in a short review queue instead of being filed wrong. You back the whole thing up by copying a file.
What I can do
I build this and set it up on your own hardware, so nothing leaves the building. It runs today, over years of real paperwork, for a business owner who refused to put his files in the cloud. Answers come from his own documents and always show their source, so the machine can’t quietly invent a number. One honest boundary: it’s single-machine by design, right for one owner or a small office with a large archive, and the wrong shape for ten people writing to it at once. If that’s your case, I’ll say so up front.
An accounting practice on years of client files, a law office where the answer is always “somewhere in the file”, a property manager juggling leases, a medical practice bound by confidentiality: any back office where one person quietly became the search engine has this exact problem. If your team keeps redigging through the same folders, this is the shape of the answer.
Want me to look at yours, in writing?
A private document agent reads and files every incoming PDF, then answers plain questions over years of paperwork. The whole searchable brain is one SQLite file on the owner’s own machine. No vector database, no cloud service sitting on his financials.
I built it for a business owner who was the only search engine his company ever had: Friday nights hunting one invoice across two years of scans, folder by folder. Ask the agent a plain question now and it answers with the exact page and a one-click link to the source file. Retrieval in under 2 seconds, against 3 years of paperwork, and 0 documents leave the machine.
By the end you’ll know how the pipeline reads cheaply, why it refuses to file anything it can’t stand behind, and how it searches two ways at once, all inside one file you back up with cp.
Never pay a model for work a local tool does for free
Reading is where cost explodes if you send every page to a model, so the pipeline tries the cheap path first. Any PDF with a real text layer gets lifted by pdftotext (poppler) in milliseconds, at zero cost. Only a genuine scan, an image with no text underneath, falls back to Gemini 2.5 Flash vision.
Most business paperwork is born digital, so most of it rides that free path. Roughly 85% of pages never touch a paid model. Multimodal models are the wrong default for OCR when a deterministic extractor already knows the answer. You reach for the model at the edge cases, where the cheap tool runs out.
The machine flags doubt instead of filing blind
Filing is where trust is won or lost. A classifier (Gemini 2.5 Flash, with Gemini 2.5 Pro as fallback for the hard cases) decides what a document is, who it concerns, where it belongs, and how confident it is in that call.
Below the critique threshold, a second independent pass reviews the first decision. Anything still doubtful lands in an “à vérifier” queue instead of getting forced into a folder. A misfiled contract is worse than an unfiled one: the unfiled one you know to check, while a wrong filing sits unquestioned for years. The queue states its doubt out loud, and that’s what lets the owner stop double-checking the machine.
The “who” and “where” come from a controlled vocabulary rather than free text. Each document gets tagged on fixed axes, plus free-form tags that grow organically:
# Values are per-deployment. Mine are real names and real topics,
# so here is the shape with placeholders.
PERSON_VALUES = (
"owner", "spouse", "parent_1",
"parent_2", "sibling", "other",
)
TOPIC_VALUES = (
"tax_residency", "estate", "shared_assets",
"day_to_day", "rental_property", "medical",
"vehicle", "other",
)A model that returns a value off the list gets folded into autre rather than trusted. Boring plumbing, and it’s what turns “show me every 2024 rental document, but not the succession ones” into a WHERE clause instead of a second model call.
The whole brain is one SQLite file, and backup is a copy command
There’s no managed vector store. sqlite-vec holds the embeddings in a vec0 virtual table, FTS5 handles full-text search, and both live inside one file. Skipping a hosted service drops a server, a monthly bill, and a copy of the archive sitting on someone else’s infrastructure. One SQLite file, no vector DB, about $0 in infra per month. Backup is copying a file.
The connection loads the extension, turns on WAL so readers never block the writer, and creates the vector table sized to the embedding model:
import sqlite_vec
EMBED_DIM = 768 # gemini-embedding-001, output_dimensionality=768
def connect(db_path):
conn = sqlite3.connect(str(db_path), check_same_thread=False)
conn.enable_load_extension(True)
sqlite_vec.load(conn)
conn.enable_load_extension(False)
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
return conn
# the vector index is just another virtual table
conn.execute(
f"CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec "
f"USING vec0(embedding float[{EMBED_DIM}])"
)
# full text, accent-insensitive French tokenizer
CREATE VIRTUAL TABLE chunks_fts USING fts5(
chunk_id UNINDEXED, doc_id UNINDEXED, text, context,
tokenize="unicode61 remove_diacritics 2");Two details in that FTS5 line matter for French paperwork. unicode61 is the tokenizer, and remove_diacritics 2 means a search for “resilie” finds “résilié”. Someone hunting a term shouldn’t have to get the accents right.
Embeddings go in as raw little-endian float bytes, packed once with struct:
def _vec(values):
floats = list(values)
return struct.pack(f"{len(floats)}f", *floats)Two searches cover each other’s blind spots
Search runs two engines at once. The semantic side catches meaning (“what did we agree on late payment”), while full-text catches the exact strings a vector blurs, like a reference number or a proper name. Their ranked lists merge by reciprocal rank fusion, so each one covers the other’s blind spot. Here’s the fusion itself, the piece I call _rrf_combine:
RRF_K = 60 # standard reciprocal rank fusion constant
def _rrf_combine(sem_rows, fts_rows) -> dict:
scores: dict = {}
for rank, r in enumerate(sem_rows):
key = (r["chunk_id"], r["doc_id"])
scores[key] = scores.get(key, 0.0) + 1.0 / (RRF_K + rank + 1)
for rank, r in enumerate(fts_rows):
key = (r["chunk_id"], r["doc_id"])
scores[key] = scores.get(key, 0.0) + 1.0 / (RRF_K + rank + 1)
return scoresRRF only reads rank position, never the raw scores, and that’s the whole point. Cosine distance and FTS5’s rank aren’t on the same scale, so adding them directly is a bug waiting to happen. RRF sidesteps the normalization problem entirely.
In hybrid mode I pull the top k * 5 candidates from each engine (at least 30) and fuse them, then group by document and keep the single best chunk per document, so one long file can’t flood the results.
The vector query itself is where sqlite-vec surprises people. K-nearest-neighbours is a constraint inside the WHERE, right where you’d expect a LIMIT:
SELECT c.id AS chunk_id, c.doc_id AS doc_id, v.distance AS distance
FROM chunks_vec v
JOIN chunks c ON c.id = v.rowid
JOIN documents d ON d.id = c.doc_id
WHERE v.embedding MATCH ? -- the packed query vector
AND k = ? -- KNN count comes from k rather than LIMIT
AND d.status = 'ok'
ORDER BY v.distanceWar story. Filtering vector results by
d.status = 'ok'with aLIMIT 5on the end kept returning fewer than five hits, sometimes zero. sqlite-vec resolveskfirst, hands back exactly those rows, and only then runs the SQLWHERE/LIMITon that fixed set, so ak = 5search whose top 5 vectors were all filtered out returned nothing. The fix: ask for a widerkup front (k * 3tok * 5), then filter and trim in Python.
Match small, return big
Two details earn their keep every day. First, small-to-big: a hit matches on a small chunk, but the system hands back the whole parent page. An invoice total split across a chunk boundary would otherwise come back cut in half.
# small-to-big: match on the chunk, return its full parent page
page_num = row["page_num"]
page_text = db.get_page_text(conn, row["doc_id"], page_num)Second, duplicates. Identical files are caught by content hash (SHA-256). A duplicate never triggers a second Gemini call: the existing document row, its chunks, its vectors, and its FTS entries are cloned straight to a new row inside the database.
# duplicate file detected: copy the vector across, no re-embedding
vec = conn.execute(
"SELECT embedding FROM chunks_vec WHERE rowid = ?", (src["id"],)
).fetchone()
if vec:
conn.execute(
"INSERT INTO chunks_vec (rowid, embedding) VALUES (?, ?)",
(new_chunk_id, vec["embedding"]),
)The same content living at two paths tells you something about the filesystem. It’s no reason to pay for OCR twice.
War story. Early on, a batch of scans came back with empty text and the classifier filed them all as “unknown” with high confidence, because an empty string matched nothing and nothing contradicted it. The fix: an empty or near-empty read now short-circuits straight to the “à vérifier” queue before the classifier ever sees it. Confidence has to be earned against real text.
One question, and the answer arrives with the file path
The owner asks “how much did I pay this supplier in 2024”. The query embeds once, then runs semantic and full-text in parallel. _rrf_combine merges the two ranked lists, the top chunk surfaces its full parent page, and the answer comes back with the amount, the exact line, and the path to 2024-03-11_facture_fournisseur-x.pdf to check in one click. Twenty minutes of digging become one question.
The same brain is reachable two ways: from an AI assistant through a small MCP server, or from the command line through a set of bb-* tools. You get your answer in a chat or a script, whichever is closer to hand.
The honest limit: one machine, one writer
This is single-machine by design, and that’s also its ceiling. WAL mode lets many readers run while one writer works, which is right for one owner with years of paperwork. It won’t handle ten people writing at once.
Concurrent writers would need a real database, along with the server and the monthly bill, plus the off-machine copy of the archive. For this owner, the single-writer limit is the feature that keeps everything on one machine. A team writing concurrently is where I’d change the design.
Who has this problem
The transferable pattern: a pile of unstructured, sensitive documents becomes something you interrogate like a colleague, without surrendering it to anyone. Read cheaply before you read expensively, and let two search methods cover each other’s blind spots. Make the system state its doubt out loud instead of guessing. Then keep the data where it was born, in a form you back up by copying a file.
An accounting practice on years of client files, a law office where the answer is always “in the file, somewhere”, a property manager juggling leases across dozens of units, a medical practice bound by confidentiality, any back office where one person quietly became the search engine: the same fix covers all of them.
Questions I get about this
Can you run a private document AI without a cloud vector database?
Yes. The whole searchable index, embeddings via sqlite-vec plus full-text via FTS5, lives in one SQLite file on the owner's own machine. No server, no monthly bill, and backup is copying a file.
How do you search scanned PDFs cheaply?
Lift the native text layer for free when it exists and only pay a vision model for real scans. Most business paperwork is born digital, so most pages never touch a paid model.
How accurate is search over years of documents?
Hybrid search fuses semantic and full-text results by reciprocal rank fusion, so meaning and exact strings both hit, and every answer links back to the exact source page.
Got this problem? I'll look at yours, in writing.
Book a call