A local-first, private search engine for your Markdown knowledge base A developer released PMS, an open-source local-first hybrid search engine and web clipper for personal Markdown knowledge bases, built in Python on SQLite FTS5 plus sqlite-vec and ONNX fastembed embeddings. PMS is positioned as a CPU-friendly alternative to qmd, replacing multi-gigabyte local GGUF LLMs with lightweight ONNX embeddings, structure-aware Markdown chunking that never splits code fences and prepends H1–H3 breadcrumbs, and reciprocal rank fusion of BM25 keyword and semantic results with no reranker or query expansion. The project runs fully offline with no LLM API or Ollama dependency and ships both a CLI and a local UI. 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 https://github.com/tobi/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 https://github.com/benjaminetienne/PMS Why PMS? Why build 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: - dependencies: qmd pulls a lot of dependencies to make it work. Bun, GGUF models, etc. I dont want to have ollama models, i want snappy search with lightweight models - no UI: the CLI is neat, but no one reads markdown files on a terminal. So yes qmd provides an API and a MCP, but this means having to build the UI. Shame there is no “qmd ui” to launch a local UI. - heavy pipeline: qmd does query expansion, reranking, etc. I am not sure it does bring a lot of value when documents are well written. I am ready to sacrifice a bit of accuracy in favor of lighter pipelines, and careful document crafting. 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. How It Works 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: - headers are aggregated to create “breadcrumb title” that are appended to the chunk - code fences are never interrupted - chunks are optimized to stay within a certain limit. Chunking is done on titles, code fences, or line breaks depending on which is more optimal. Once chunked, documents are added to a SQLite database that stores: - document metadata path, title, collection, hash - document chunks with a FTS5 search BM25 used for keyword search 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 . python 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