You don't need a vector database to search your own docs. SQLite ships with FTS5, a full-text search engine that runs inside the same file as your data — no server, no embeddings, no API bill. For a personal knowledge base or a small project's documentation, it beats most "semantic search" setups I've seen on latency and cost, and the whole thing fits in one Python file.
I built this for my notes after getting tired of grep. It ranks by relevance instead of matching substrings, and it handles typos the way people actually search — by words, not exact strings.
What FTS5 gives you
- BM25 ranking out of the box — results come back ordered by relevance, not by file path.
- Word-level matching:
docker networkingmatches documents containing both words, in any order. - No external service: it's a table in the same SQLite file as everything else, so it backups with your data.
What it doesn't give you is semantic understanding — searching "container port mapping" won't find a doc that only says "expose ports with -p". If you need that, you need embeddings. For most personal docs, you don't.
The script
Two parts: an indexer that loads your markdown files, and a query function.
import sqlite3, pathlib, re
DB = "docs.db"
def index_docs(root: str) -> int:
conn = sqlite3.connect(DB)
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS docs USING fts5(path, title, body)")
conn.execute("DELETE FROM docs")
n = 0
for p in pathlib.Path(root).rglob("*.md"):
text = p.read_text(encoding="utf-8", errors="ignore")
title = text.splitlines()[0].lstrip("# ").strip() if text else p.stem
body = re.sub(r"[#*`>\[\]()]", " ", text) # strip markdown syntax
conn.execute("INSERT INTO docs VALUES (?, ?, ?)", (str(p), title, body))
n += 1
conn.commit()
conn.close()
return n
def search(query: str, limit: int = 5) -> list:
conn = sqlite3.connect(DB)
rows = conn.execute(
"SELECT path, title, snippet(docs, 2, '<b>', '</b>', '…', 12) "
"FROM docs WHERE docs MATCH ? ORDER BY rank LIMIT ?",
(query, limit),
).fetchall()
conn.close()
return rows
if __name__ == "__main__":
n = index_docs("./notes")
print(f"indexed {n} files")
for path, title, snip in search("docker networking"):
print(f"\n{title} ({path})\n {snip}")
Run it once to build the index, then import search() from your bot, a REPL, or a small UI. ORDER BY rank is the BM25 score — lower is better.
When local search isn't enough
FTS5 covers the case where the answer already exists in your own files. The gap is everything outside them: release notes, changelogs, competitor pages, anything on the web. Two options there, and they're not exclusive:
- Keep a web fallback. When local search returns nothing, hit a SERP API — search requests on SerpBase cost 1 credit each and standard credits don't expire, so a fallback path costs almost nothing on a low-traffic bot. Field definitions live in SerpBase's search endpoint documentation.
- Hybrid ranking. Try local first; if the top BM25 score is weak or empty, merge in web results. Most "which library does X" questions resolve locally; "what happened with X last week" needs the network.
The MCP route works too if your client supports it — the same key can drive a server that exposes search to Claude or Cursor, which is convenient for interactive use. For a scheduled bot, direct HTTP is simpler.
Cost and limits
SQLite FTS5 has no per-query cost and no network hop — indexing a thousand markdown files takes under a second on a laptop. The practical limit is your disk, not the engine. If your docs are behind auth or scattered across services, local indexing gets awkward fast, and that's when a hosted search API earns its keep.
FAQ
Do I need to re-index every run? Only when files change. For a bot, watch the directory's mtime and re-index on change, or index on startup — with a thousand files it's fast enough not to matter.
How is this different from grep? grep returns lines; FTS5 returns documents ranked by relevance, with highlighted snippets. It also handles multi-word queries as "contains all these words" rather than exact phrase.
Can I search PDFs or HTML too? Yes, after extraction — convert to text, store the text. FTS5 only sees the body column you insert.
What about embeddings for semantic search? Worth it when users phrase queries differently from the docs' wording. Start with FTS5; if logs show people searching "how do I restart the thing" when the doc says "service restart procedure", add embeddings then.
Index your notes tonight and ask the bot a question you'd normally open three browser tabs for. If it answers, you've saved yourself the tabs.
Top comments (0)