knowledge-index
A private, self-hosted RAG service over MCP that enables document ingestion, hybrid retrieval (BM25 + dense vectors fused with RRF), and notebook management through 14 tools, keeping documents on your own hardware.
README
knowledge-index (ki)
A private, self-hosted RAG service exposed over MCP (Model Context Protocol), built as a replacement for NotebookLM in a personal AI-tooling ecosystem: documents stay on hardware I control, retrieval is hybrid (BM25 + dense vectors fused with RRF), and every capability is reachable as a tool from a Claude Code session instead of a web UI.
Project status — read this first. The ingestion, chunking, retrieval and embedding layers are implemented and covered by 130 passing unit tests under
ruff+mypy --strict. The storage layer (PostgreSQL schema/migrations and the OpenSearch index + RRF pipeline) is written and its integration tests exist, but its live acceptance criteria are still unverified: the host that runs OpenSearch and Postgres has been offline, so the end-to-end pipeline has not been exercised against real services. This is not a deployed product — it is a working codebase with a deliberate architecture, and I would rather say so than imply otherwise.
Why it exists
Hosted notebook tools are convenient but impose three costs I did not want to pay: documents leave
my machine, the tool cannot be driven programmatically, and scraping around those limits violates
the terms of service. ki trades that convenience for control — the corpus lives on my own host,
and the interface is MCP, so an agent can ingest, query and manage notebooks as ordinary tool calls.
What it does
- Ingests documents (PDF, Markdown, TXT today; URL, YouTube, Google Docs, DOCX planned) into isolated notebooks.
- Retrieves with a hybrid strategy: BM25 lexical search and dense k-NN vector search, fused by Reciprocal Rank Fusion, then optionally re-ranked with a cross-encoder.
- Exposes 14 MCP tools over SSE, consumed directly from agent sessions.
- Abstracts the embedding provider behind one interface — Cohere (default), Voyage, OpenAI, Gemini — so the model is a configuration decision, not an architectural one.
Architecture
SOURCE PDF · Markdown · TXT
|
v
ADAPTERS pypdf primary, pdfplumber fallback when extraction yields <100 chars/page
de-hyphenation · whitespace normalization · encoding cascade (utf-8-sig → cp1252 → latin-1)
page_map preserved (page, char_start, char_end) for citations
|
v
CHUNKING structure-aware splitting · content_hash for deduplication
|
v
EMBEDDINGS provider registry behind one ABC · 1024-dim vectors
notebook freezes its provider+model at creation time
|
v
STORAGE PostgreSQL → notebooks, sources, chunks, jobs, costs (5 tables, 8 indices,
NOTIFY + updated_at triggers, Alembic migrations)
OpenSearch → ki_chunks index, HNSW (lucene, cosine, 1024) + BM25
|
v
RETRIEVAL hybrid query → RRF fusion → optional cross-encoder rerank
|
v
INTERFACE 14 MCP tools over SSE
Stack: Python 3.11 · Pydantic · asyncio · OpenSearch · PostgreSQL + Alembic · Cohere ·
pypdf/pdfplumber · pytest · ruff · mypy (strict).
Design decisions and trade-offs
The interesting part of this project is not the code, it is what got ruled out and why.
Vector store: reuse OpenSearch instead of adding Qdrant or pgvector
Chosen: the OpenSearch cluster already running on my host, with a dedicated index.
- Qdrant has better ergonomics and native payload filtering, but it means standing up another service at roughly 500 MB of RAM on hardware that is already tight.
- pgvector adds zero infrastructure, but HNSW performance degrades past ~100k chunks, lexical
search via
pg_trgmis materially weaker than BM25, and there is no native RRF.
OpenSearch gives BM25 and k-NN in one engine with RRF available in the search pipeline, at no
additional memory cost. The price paid: the index mapping fixes vector dimensionality at 1024,
which constrains which embedding models are usable, and the cluster is shared — so isolation is by
index name with number_of_shards=1.
Embeddings: multi-provider abstraction, Cohere as default
Self-hosting bge-m3 was the theoretically better answer (no runtime cost, nothing leaves the host)
and was measured and rejected: the available hardware has 8 GB of RAM and no usable GPU, and the
model's working memory would starve the OpenSearch instance sharing the box. Gemini's embeddings are
768-dimensional and would not fit the index without a full rebuild.
So the provider sits behind an ABC with a registry, and each notebook freezes its provider and model at creation time — mixing embedding spaces inside one index silently destroys retrieval quality, and freezing makes that failure impossible rather than merely discouraged. Migration is handled explicitly by a re-index operation.
Query returns chunks, not a synthesized answer
ki_query returns ranked chunks; synthesis is opt-in through a separate tool. The consumer is
already an LLM session, so synthesizing server-side would mean paying for a second model call to
produce something the caller can do for free — and it would force notebooks marked sensitive
through a cloud provider they are specifically configured to avoid.
Privacy as an enforced constraint, not a convention
Notebooks can be flagged sensitive. The provider layer rejects any provider whose is_cloud
flag is true for those notebooks — enforced in the abstraction, with parametric tests covering it,
rather than left to the caller to remember.
Measured numbers
| Metric | Value |
|---|---|
| Unit tests | 130, passing |
| Type checking | mypy --strict, clean |
| Lint | ruff, clean |
Embedding latency (Cohere embed-multilingual-v3.0) |
~1150 ms p95 (target was <2 s) |
| Embedding dimensionality | 1024, confirmed against the live API |
| Embedding providers implemented | 4 (1 fully live, 3 behind the same interface) |
| PostgreSQL schema | 5 tables · 8 indices · 2 triggers |
The rerank path is deliberately disabled by default in development: the Cohere trial allows 10 rerank calls per month against 1000 embedding calls, which is trivially exhausted during active retrieval work. Finding that in the response headers before it caused a mid-development outage is exactly the kind of cost detail that separates a demo from something operable.
Running it
pip install -e ".[dev]"
cp .env.example .env.local # fill in COHERE_API_KEY and KI_PG_DSN
# Unit tests — no network, no external services required
pytest tests/unit -v
# Live integration tests — require reachable OpenSearch + PostgreSQL and real credentials
KI_TEST_LIVE=1 pytest tests/integration -v
Bootstrapping the storage layer (both idempotent, safe to re-run):
alembic upgrade head # PostgreSQL: schema, tables, indices, triggers
python scripts/init_opensearch.py # OpenSearch: ki_chunks index + RRF search pipeline
Configuration
| Variable | Purpose |
|---|---|
COHERE_API_KEY |
Embedding provider credential |
KI_PG_DSN |
libpq DSN; rewritten internally to +asyncpg / +psycopg |
KI_PG_SCHEMA |
Defaults to ki |
KI_OPENSEARCH_URL |
e.g. http://localhost:9200 |
KI_EMBEDDING_PROVIDER |
cohere (default), voyage, openai, gemini |
KI_TEST_LIVE |
Set to 1 to run integration tests against real services |
Secrets are typed as Pydantic SecretStr so they are never emitted through logs or reprs.
What I would do next
- Verify the live acceptance criteria for the storage layer once the host is back, and run the end-to-end smoke over a real corpus.
- Add retrieval quality evaluation — a ground-truth query set with recall@k, so changes to chunking or fusion can be judged by measurement instead of impression.
- Add tracing over the ingestion and query paths; per-notebook cost accounting is already modelled in the schema but not yet surfaced.
License
Not currently licensed for reuse. Published as a portfolio artifact.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.
E2B
Using MCP to run code via e2b.