
A local-first, private search engine for your Markdown knowledge base
When 17 documents became a 374MB database
以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。
A Personal Markdown Search Engine
October 21, 2026 · 15 min read
Contents
Why PMS?
How It Works
Python API
Deepdives and lessons learned
BM25 with SQLite
Chunking
Vector search & embeddings
When 17 documents became a 374MB database
Reciprocal Rank Fusion
Why no reranking or expanded queries
Conclusion
PMS
PMS is a lightweight, local-first hybrid search engine and web clipper for personal Markdown knowledge bases, built in Python on top of SQLite (FTS5 + sqlite-vec) and ONNX (fastembed).
It is designed as a fast, minimal, CPU-friendly alternative to qmd, trading multi-gigabyte local GGUF LLMs for lightweight ONNX embeddings, structure-aware Markdown chunking, and a hackable Python codebase. Repo here: https://github.com/benjaminetienne/PMS
I needed a local-first knowledge base & web clipper for Markdown notes (trafilatura → local .md files → hybrid search). It should work offline, and be able to read all my knowledge stored as markdown, so i can look up concepts, code snippets, etc. The advantage of it being local is that you can directly access your documents and keep sensitive data on your laptop. No LLM api here, no ollama, just lightweight models that can run in milliseconds on my laptop and still do the job.
I came across qmd, and though a lot of good ideas were in it, i decided to go my own way for several reasons:
PMS is not meant to be a production grade SaaS. It’s a local-first, lightweight and hackable code that anyone can adjust and tweak. Having a CLI and a UI also means its use can be versatile and offer many possibilities. It can also easily be ported to MCP with its friendly API.
The tool is organized around collections which store documents. Documents are markdown files only. They can be fetched from urls with trafilatura, or written directly in any editor.
Documents are chunked to preserve the structure as much as possible:
Once chunked, documents are added to a SQLite database that stores:
Embeddings are also computed for each chunk using the fastemb library.
Retrieval follows a classic hybrid retrieval approach: keyword search + semantic search.
A reciprocal rank fusion score is then given to the chunks to rank them.
No reranker is applied, and no summary is generated. But both can easily be added.
Web URL / Local .md files │ ▼ ┌────────────────────────────────────────────────────────┐ │ 1. Ingestion (trafilatura / local disk) │ │ Saves Markdown with YAML frontmatter + SHA-256 hash │ └────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────┐ │ 2. Structure-Aware Chunking (pms.chunk) │ │ • Respects code fences (never splits code blocks) │ │ • Splits on H1–H3 boundaries & paragraph breaks │ │ • Prepends [H1 > H2 > H3] breadcrumb to embed_text │ └────────────────────────────────────────────────────────┘ │ ├─────────────────────────────┐ ▼ ▼ ┌──────────────────────┐ ┌────────────────────────────┐ │ 3a. SQLite FTS5 │ │ 3b. FastEmbed + sqlite-vec │ │ Porter stemmer BM25 │ │ BAAI/bge-small-en-v1.5 │ │ full-text index │ │ 384-d float32 cosine KNN │ └──────────────────────┘ └────────────────────────────┘ │ │ └──────────────┬──────────────┘ ▼ ┌────────────────────────────────────────────────────────┐ │ 4. Reciprocal Rank Fusion (RRF, k=60) │ │ Combines lexical (BM25) + semantic rankings │ └────────────────────────────────────────────────────────┘
Python API
The main entrypoint is KnowledgeBase, which initializes the SQLite database (knowledgebase.db), loads sqlite-vec, and prepares the ONNX embedding model (BAAI/bge-small-en-v1.5).
from pms.kb import KnowledgeBase
1. Initialize the knowledge base (SQLite + FastEmbed ONNX model)
kb = KnowledgeBase()
2. Create or load a collection (a directory of Markdown files)
agents_col = kb.collections.create( path="./knowledge/agents", name="agents", description="Articles and notes on AI agent architectures", )
3. Clip a web article directly into the collection as Markdown
agents_col.add_from_url( "https://developers.googleblog.com/build-long-running-ai-agents-that-pause-resume-and-never-lose-context-with-adk/" )
4. Sync disk files with SQLite (chunks, embeds, and indexes new documents)
kb.sync()
5. Run hybrid search (BM25 + Vector + Reciprocal Rank Fusion)
results = kb.search("what is prompt context pollution", k=5) for hit in results: print(hit.text) print("---")
And that’s all. 5 steps to get your first document indexed and get results. Notice the .sync() method to ensure newly added docs are added to the database.
Collections map a logical name to a local directory containing .md files. You can drop Markdown files into the folder manually or ingest URLs directly:
List all registered collections
for col in kb.collections.list(): print(col.name, col.path, len(col.files))
Fetch an existing collection by name
col = kb.collections.get("agents")
Ingest a URL into the collection (saved as YYYY-MM-DD_<slug>.md with frontmatter)
col.add_from_url("https://www.langchain.com/blog/introducing-ambient-agents")
Inspect all .md files in the collection
print(col.files)
Delete a collection from the database
kb.collections.delete("agents")
To ensure local content and DB content always stays up to date, we define a check_sync_status() that tracks newly added files, edited files and deleted files. To compare content, PMS computes SHA-256 content hashes for all .md files across your collections and compares them against the documents table in SQLite:
Preview pending changes without modifying the database
to_add, to_delete, to_update = kb.check_sync_status()
Prints: Sync status: +1 new, ~0 modified, -0 deleted
Index new documents into chunks, chunks_fts, and chunks_vec
kb.sync()
While kb.search(query, k=10) returns the top fused chunk texts, you can also access kb.retrieval (HybridRetrieval) directly to inspect scores or compare BM25 against vector search:
Get hits
scored_results = kb.retrieval.rrf("how do agents handle idle time?", k=5) for hit in scored_results: print(f"[RRF: {hit.score:.4f}]\n{hit.text}\n")
Each Hit object contains the rrf score, as well as the bm25 and vector search ranks. To get all candidates from both methods, we can inspect candidates via
Compare raw candidate chunk IDs from BM25 vs. Vector search
bm25_ids, vector_ids = kb.retrieval.candidates("what is prompt context pollution", k=10) print("BM25 top IDs: ", bm25_ids) print("Vector top IDs:", vector_ids)
Or run standalone BM25 search (includes highlighted snippets)
bm25_rows = kb.retrieval._bm25_search("state machine checkpoint", k=5) for rowid, heading_path, text, bm25_score, snippet in bm25_rows: print(f"#{rowid} ({heading_path}): {snippet}")
Likewise, for vector search:
Run standalone KNN cosine vector search
vec_rows = kb.retrieval._vector_search("how to survive server restarts", k=5) for rowid, text, distance in vec_rows: print(f"#{rowid} (cosine distance={distance:.4f})")
As the code is modular and hackable, it is also easy to inspect the chunking module and replace it with any custom chunker. The chunker is decoupled from the database and can be used, with or without the URL extractor, independently of the SQLite database:
from pms.extract import extract_from_url from pms.chunk import chunk_markdown
Extract clean Markdown + title from any web page
doc = extract_from_url("https://www.omnara.com/blog/what-is-an-async-agent-really")
Split into structure-aware chunks
chunks = chunk_markdown( markdown=doc.content, title=doc.title or "", target_chars=2000, overlap_lines=10, )
for c in chunks[:3]: print(f"Chunk #{c.seq} | Lines {c.start_line}-{c.end_line} | {c.heading_path}") print(c.embed_text[:200]) print("---")
Deepdives and lessons learned
BM25 with SQLite
Building a BM25 search engine in SQLite is very easy and straightforward, since it is already built-in sqlite. Assuming we create a chunks table in SQLite, we can derive a virtual table used to do keyword search like this
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5( heading_path, text, content='chunks', content_rowid='id', tokenize='porter unicode61');
The tokenizer is important to match “approximate” words. For example this is how it finds singular names even if spelled plural, or vice-versa. At query time, the query to find matches is simply
SELECT rowid as id, heading_path, text, bm25(chunks_fts) as bm25_score, snippet(chunks_fts, 1, '<mark>', '</mark>', '...', 32) as snippet FROM chunks_fts WHERE chunks_fts MATCH ? ORDER BY bm25(chunks_fts)
The last line is the key: MATCH ? will match the query, and ORDER BY bm25 will return the scores in ascending order, which is fine because lower means better for BM25.
In practice, BM25 comes with a few caveats: in SQLite FTS5, passing what is AI uses an implicit AND (“what AND is AND AI”). If no document has these exact 3 words, it returns nothing.
One fix is to split the query in words and join them with the “OR” keyword: “what is AI” becomes “what OR is OR AI”. This way, BM25 will look for any of these 3 words.
However there is still a drawback: the search will match any document that contains any of the three, including “what” and “is”, which are very common. There is no way to exclude stopwords directly in sqlite. Though BM25 relies on TF-IDF under the hood, a chunk that doesn’t mention “AI” at all can still rank #1 in BM25 just by repeating “what” and “is” many times.
Chunking is an art, and though several methods exist, the following decisions are unavoidable:
In PMS, chunking is inspired from qmd’s chunking strategy. The idea is to define a maximum window length, inside which there is room for multiple split choices: heading, line breaks, code fences, lists. Each choice comes with a score, meaning it is for example better to split on heading vs a line break. This hierarchy of split criteria is supposed to yield more coherent outputs. In practice, it still depends a lot of the window length and the overlap. Empirically, it seems that windows around 1000 characters work quite good. But this is clearly a critical choice on which the performance of the search engine depends.
The good news: since we own the markdown, we can control the structure. For documents downloaded from a URL this might be more complicated, but for document i would write myself, writing clear markdown with nice headers, short paragraphs and well defined code fences is easy. Based on your personal preferences and writing style, you can adjust the window length and the overlap parameters in the chunking function
As said previously, chunking is an art, but so is choosing the right embedding model. Luckily, we have several options available when it comes to choosing an embedding model:
Since I wanted a local-first and CPU-friendly, the choice quickly ended up being HuggingFace models. But another question remained: how to balance performance and ressource utilization locally ? This ruled out models of several Gb memory, the sweetspot i was targeting being around several hundreds million parameters. I also went for specialized models instead of small general purpose LLMs like Qwen or SmolLM.
Then came the question of the dependencies. I started with google/embeddinggemma-2 using the sentence-transformers lib from HF, but this meant adding pytorch to the dependencies, lots of Gb to be downloaded, etc. I pivoted to using the ONNX format which does not require pytorch to be installed, saving a lot of time in both libs needed but also in memory when loading the model.
The fastembed python library turned out to provide exactly what i was looking for: small embeddings models with low memory footprint and using the ONNX format. I did not compare embedding models and chose the
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。