hybrid-rag-mcp
Run a local, private hybrid RAG service over your documents via MCP: ingest, search, ask with citations, and monitor.
Ingest & index documents (MD, TXT, PDF, DOCX, CSV/TSV, HTML, JSON) into Qdrant vector + BM25 lexical indexes incrementally.
Hybrid search with RRF fusion, optional reranking, returning snippets and sources.
Ask questions through a multi-step agent that retrieves, generates, re-searches when context is insufficient, and answers with cited sources (using semantic cache and context compression).
Monitor counters and p50/p95 latencies via the
metricstool or/metricsPrometheus endpoint.Integrate over stdio or streamable HTTP (with optional bearer auth), plus a visual web dashboard for inspection.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@hybrid-rag-mcp What is the default port for the server?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
hybrid-rag-mcp
MCP server with hybrid RAG (vector Qdrant + lexical BM25 via RRF), a multi-step agent with offline fallback via Ollama, and stdio or streamable HTTP transport.
Focus: index technical and operational documents (Markdown, TXT, PDF, Word DOCX, CSV/TSV tables, HTML and JSON) and answer questions with cited sources, 100% locally, without sending documents to third parties.
Evidence: recall@1 0.917 · answer judge 1.92/2 · 110 tests · mypy strict · CI with quality gates.
Leia em português.
Contents
Related MCP server: rag-mcp
Highlights
Hybrid search: embeddings (local Qdrant, no Docker) + in-house BM25 (smoothed idf), fused by RRF.
Multi-step agent: if the first search's context is insufficient, the model signals
[MORE_CONTEXT], the agent issues a follow-up search and retries with incremental source memory.Optional re-ranking: local cross-encoder via Ollama (
bge-reranker-v2-m3), neural via Cohere API (rerank-v3.5), or custom HTTP endpoint, with graceful degradation.Resilient fallback: cloud provider (OpenAI-compatible) first, local Ollama as backup when the API is down.
Persistence: chunks live in Qdrant; the BM25 index is restored on demand (first search) with no re-ingestion.
Incremental ingestion: re-running
ingestonly embeds what changed (content-hash idempotent) and prunes orphans — cheap in CI and redeploys.Token optimization: semantic cache (JSONL + cosine, with TTL) returns previously generated answers without re-calling the LLM; a Caveman-style compressor (PT/EN) trims the source context before the prompt.
Traceability: per-step agent
trace+audit.jsonl(question, provider, iterations, latency, sources, cache).Measurable:
recall@k/nDCG@kevaluation pipeline with a quality gate in CI.Two transports: stdio (local RPC) and streamable HTTP (
http://host:port/mcp).
Architecture
flowchart LR
C["MCP Client<br/>stdio or HTTP"] -->|tools: ingest / search / ask / metrics| M["MCP Server<br/>hybrid-rag-mcp"]
M --> AGE["Multi-step agent<br/>loop with [MORE_CONTEXT]"]
M --> I["ingest"]
I --> C1["Chunker<br/>sections + sentences"]
C1 --> E["Embeddings<br/>Ollama bge-m3"]
E --> Q1[("Qdrant<br/>vector search")]
C1 --> K["In-house BM25<br/>lexical search"]
AGE --> RET["Hybrid search"]
RET --> Q1 & K
Q1 & K --> RRF["RRF fusion"]
RRF --> RR["Optional reranker<br/>Ollama /api/rerank"]
RR --> LLM["FallbackLLM<br/>cloud -> Ollama"]
LLM --> AUD["audit.jsonl<br/>trace + iterations + sources"]Metrics (quality gate in CI)
Evaluation pipeline over 36 queries — 12 hand-curated in eval/dataset.jsonl + 24 auto-generated from real documents (eval/dataset.real.jsonl, see Corpus).
CI trigger: fails if recall@1 < 0.8.
k | recall@k | nDCG@k |
1 | 0.917 | 0.917 |
3 | 1.000 | 0.865 |
5 | 1.000 | 0.945 |
Honest numbers over real text: the right source ranks top-1 in 91.7% of cases and always in the top-3. tools/grid_search.py sweeps RRF weights/top_k to reach this result (lexical weight 1.5) — history in eval/grid_results.json. Run locally with python -m hybrid_rag_mcp.eval.
Answer quality (llm-as-judge)
Retrieval proves the right chunk surfaces; this proves the final answer is correct:
12 questions over the corpus (eval/answers.jsonl), each with an expected
answer, scored 0/1/2 by Ollama itself — run with
python -m hybrid_rag_mcp.judge (needs Ollama up).
metric | value |
average | 1.92 (0..2) |
score 2 | 11/12 |
score 1 | 1/12 (ans-06: missed "telemetry doesn't go to PostgreSQL") |
score 0 / no verdict | 0 |
Compression validated: with CONTEXT_COMPRESSION=1 the average holds at 1.92;
with 2, 2.00 (12×2) — pruning function words does not degrade answers
(cache off in all 3 runs; n=12, a 1-item swing is noise —
the conclusion is "doesn't hurt", not "helps").
Scale (retrieval, embedded mode)
Methodology (tools/scale_bench.py): 11 real docs replicated with unique salt
up to ~25k chunks — recall stays on the curated eval; here only latency,
throughput and disk (36 real queries, no LLM). Isolated index, embedded
Qdrant on a 4GB RAM box.
metric | 32,760 indexed chunks |
ingest | 566s (58 chunks/s), 337MB index on disk |
1-thread search | p50 191ms · p95 621ms · mean 373ms |
8-thread search | p50 640ms · p95 4308ms |
p99 single ≈5s | one-time warmup cost (first search restores the BM25 index; varies across runs) |
Re-measured with vectorized BM25 (before: p50 269/p95 663 single, p50 1313/p95 2334 concurrent). p50 fell ~30% single-threaded and ~50% concurrent — numpy releases the GIL on the vectorized path; tails (p95/p99) swing ±2x between runs on this box, noise not signal.
Honest readings (including our own correction): the initial guess was that
pure-Python BM25 was the bottleneck — measured, it wasn't: at 32k docs
score_all cost ~17ms of ~400ms per search (the bulk is Ollama embedding +
local HNSW). We vectorized anyway (~10x: 17.5→1.7ms on 30k synthetic docs,
parity < 1e-9): the win compounds at 10x scale, where the pure loop would
dominate. Contention under concurrency (~5x with 8 threads — GIL + local
Qdrant + embedding on Ollama) stands. Known ceiling: at 128k chunks the box
OOMed — above ~100k chunks or on little RAM, use server mode
(QDRANT_URL, see Deploy).
Context optimization (cache and compression)
Semantic cache — before generating, ask consults data/cache.jsonl in two layers:
exact normalization (identical questions, ignoring case/whitespace) and
similarity — the question is embedded (same bge-m3 as search) and compared by cosine against the entries; above
CACHE_SIM_THRESHOLD(0.92) the saved answer is returned (TTLCACHE_TTL_SEC,CACHE_MAX_ENTRIEScap). A hit skips generation entirely — the biggest token saving. Hits are marked· cachein the answer and recorded in the audit ("cache": true).
Caveman-style context compression — CONTEXT_COMPRESSION (0/1/2) removes
predictable function words (connectives/fillers at level 1; + articles and
auxiliaries at level 2) only from the copy that goes into the LLM prompt:
search, re-ranking and displayed sources keep the original text, and numbers,
proper nouns and negations are never removed. Deterministic, multilingual
(PT/EN), zero dependencies — principle inspired by
Caveman ("strip grammar, keep
facts"), reimplemented in-house.
python tools/optimizers_report.py # dashboard: cache + compression + evaluated integrations
python tools/optimizers_report.py --json # same output as JSONCorpus
examples/corpus/— 11 documents: 5 fictional (operations/security/database/infra/events) + 6 real public-domain books (Project Gutenberg): Chekhov, Machado de Assis, Aluísio Azevedo, Eça de Queirós, Jane Austen and Conan Doyle, mixing PT and EN.tools/fetch_corpus.py— downloads the Gutenberg catalog and auto-generates the evaluation dataset: each query is a real excerpt from a document and the expected document is that excerpt's exact source (self-supervised ground truth, no manual curation).
Running
Prerequisites: Python 3.11+, running Ollama (ollama serve).
# 1. Local models (once)
ollama pull bge-m3 # embeddings
ollama pull qwen3:8b # generation (or another)
ollama pull bge-reranker-v2-m3 # optional: Ollama >= 0.36 only
# 2. Install
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
# 3. ~1 min demo (ingest + search + ask)
bash examples/demo.sh[ingest] 11 docs, 1964 chunks (+0 novos, 1964 no índice)
[search] 'Qual a porta padrão do servidor?'
- [pqpbr-operations.txt] score=0.065: ## Configuração de Rede O servidor escuta na porta 8443...
[ask] 'De quantas em quantas horas são os backups?'
[ollama/qwen3:8b] Os backups incrementais são executados a cada 4 horas [pqpbr-operations.txt].
fontes: gutenberg-dom-casmurro.txt, pqpbr-operations.txtAs an MCP client
stdio: example client — python examples/client.py "Qual a porta padrão?"
Progress streaming (tokens + steps):
python examples/client_stream.py "Qual a política de manutenção do banco?" — the server
emits step events and response tokens in real time via notifications/progress
(the client sends a progressToken in the request).
HTTP:
# terminal 1
python -m hybrid_rag_mcp --transport http --host 127.0.0.1 --port 8000
# terminal 2
python examples/client_http.py "Qual a porta padrão?"Visual Web Dashboard (Live Inspector)
To interactively explore hybrid retrieval (comparing RRF, Vector (Qdrant), and BM25 Lexical side-by-side), ask questions with agent trace steps, and inspect indexed chunks in real time:
hybrid-rag-ui --port 8501
# or
python -m hybrid_rag_mcp.ui --port 8501Open http://127.0.0.1:8501 in your browser.
HTTP auth (recommended when exposing on a network): generate a token
(openssl rand -hex 32), export MCP_AUTH_TOKEN on the server and the
client — without the Authorization: Bearer header the server answers 401.
With no token configured, it behaves as before (localhost only).
MCP_AUTH_TOKEN=... python -m hybrid_rag_mcp --transport http --port 8000
MCP_AUTH_TOKEN=... python examples/client_http.py "Qual a porta padrão?"Register in any MCP client (Claude Desktop, editors, agents):
{
"mcpServers": {
"hybrid-rag": {
"command": ".venv/bin/python",
"args": ["-m", "hybrid_rag_mcp"],
"env": { "PYTHONPATH": "src" }
}
}
}Cloud fallback (optional)
Copy .env.example to .env and fill in CLOUD_BASE_URL + CLOUD_API_KEY + CLOUD_MODEL
(any OpenAI-compatible endpoint). Cloud takes priority; Ollama answers automatically
if the API fails or goes offline. For cloud embeddings, also set
CLOUD_EMBED_MODEL — note: switching models changes the dimension and requires
recreating the index (re-run ingest). For a multi-API cascade (e.g. Nvidia →
Gemini → Ollama), use LLM_CHAIN with the list in priority order — the first
to answer wins, you pay only it per question.
Deploy
systemd (bare metal): deploy/hybrid-rag.service is a user unit that brings
up the HTTP transport on 127.0.0.1:8000 with automatic restart — adjust the paths
(%h = your home) and install with
systemctl --user enable --now pointing at the file. .env is optional
(cloud/token only if you export them).
Docker (Qdrant + RAG): docker compose up -d --build brings up Qdrant and the
server already pointed at it (QDRANT_URL=http://qdrant:6333, Ollama via
host.docker.internal). No Docker installed here so the compose was not run
locally — validated by inspection; CI keeps covering embedded mode.
Data hygiene: audit.jsonl rotates by size
(AUDIT_MAX_BYTES, default 5MB, keeps AUDIT_KEEP=3 backups); the semantic
cache is already capped (CACHE_MAX_ENTRIES).
Observability
In-process metrics, zero dependencies (src/hybrid_rag_mcp/metrics.py):
counters (search_total, ask_total, ask_cache_hits, *_errors,
ask_provider_<name>) + latencies (search_ms, ask_ms, ingest_ms).
HTTP:
GET /metricsin Prometheus format (inherits the bearer auth). Scrape example:scrape_configs: - job_name: hybrid-rag static_configs: [{targets: ["127.0.0.1:8000"]}] # authorization: {credentials: <MCP_AUTH_TOKEN>} # if auth is onBoth transports:
metricsMCP tool with a human summary.Logs: one JSON line per
search/askon stderr ({"op": "ask", "elapsed_ms": 123.4, "cache_hit": false, ...}) — stdout belongs to the MCP protocol in stdio mode and is never polluted.
MCP tools
Tool | Description |
| Indexes |
| Hybrid search (RRF, optional re-ranking) returning snippets + sources. |
| Multi-step agent: retrieves, generates, detects insufficient context, re-searches and answers citing sources (with audit log). Consults the semantic cache before generating. |
| Server metrics summary (counters + p50/p95 latencies since boot). |
Extending
Nothing here is mandatory — every piece has a local default and can be swapped without forking:
Compressor: any
(str) -> strcallable viaCONTEXT_COMPRESSOR=my_package:clean(beats the built-in 0/1/2 levels). E.g. a sidecar like Headroom wrapped in a function.Embeddings/LLM:
EmbeddingProvider/LLMProviderABCs (src/hybrid_rag_mcp/providers/); cloud viaCLOUD_*with no code changes. Switching embeddings changes the dimension: wipe the index and re-runingest, or setQDRANT_RECREATE_ON_DIM_CHANGE=1.Reranker:
score(query, texts) -> list[float]duck-type (seeproviders/rerank.py); empty = off, with graceful degradation.
Structure
src/hybrid_rag_mcp/
├── server.py # MCP server (stdio + streamable HTTP, optional bearer auth)
├── ui.py # Visual Web Dashboard (Starlette + Tailwind CSS)
├── config.py # .env configuration (pydantic-settings)
├── eval.py # recall@k / nDCG@k evaluation
├── judge.py # Answer evaluation via llm-as-judge (score 0/1/2)
├── bench.py # Scale-bench blocks (percentiles, synthetic corpus)
├── metrics.py # In-process metrics (counters + p50/p95, Prometheus)
├── rag/
│ ├── agent.py # Multi-step loop (source memory, [MORE_CONTEXT])
│ ├── chunker.py # Chunking by markdown sections + sentences
│ ├── engine.py # Orchestration: ingest → search → ask + audit
│ ├── hybrid.py # RRF fusion + re-ranking
│ └── ingestion.py # md/txt/pdf reading
├── providers/
│ ├── embed.py # Embeddings via Ollama
│ ├── llm.py # FallbackLLM (cloud → Ollama)
│ └── rerank.py # Multi-provider cross-encoder (Ollama, Cohere, Custom HTTP)
├── optimize/
│ ├── cache.py # Semantic cache (JSONL + cosine + TTL)
│ └── compress.py # Caveman-style compressor (PT/EN)
└── stores/
├── vector.py # Embedded Qdrant (no Docker, persistent)
└── lexic.py # BM25 with smoothed idfArchitecture decisions with context and evidence: docs/adr/
(RRF, storage, cache, judge, lazy-init, external integrations, cascade scope,
BM25 tradeoff, open seams). Contributing guide: CONTRIBUTING.md.
Release history: CHANGELOG.md.
Quality
110 unit tests (
pytest) with no network/Ollama — code-block-aware chunking, RRF, BM25 (incl. vectorized × brute-force parity), persistence, eval metrics, agent loop, semantic cache, compressor, lexical-index thread-safety, judge parsing/aggregation, HTTP auth, audit rotation, observability, pluggable compressor, cloud embeddings, dimension check, LLM cascade, UI endpoints, rerank providers and multi-format document ingestion (.md, .txt, .pdf, .docx, .csv, .tsv, .html, .json).CI in 2 jobs:
test(ruff + mypy strict + pytest with ≥75% coverage +pip-audit+ stdio/HTTP smoke) andeval(real Ollama +recall@1 >= 0.8gate). Weekly Dependabot (pip + actions).Two storage modes: embedded (default, no Docker, 1 process at a time) or server (
docker compose up -d+QDRANT_URL=http://localhost:6333) for simultaneous sessions — seedocker-compose.yml.
Available Tools
4 toolsaskC
Responde com RAG e faz streaming do progresso/tokens via progress notifications.
Inputs: question (required), top_k. Returns: result
ingestA
Indexa documentos (md/txt/pdf) de um diretório nos índices vetorial e BM25.
Inputs: corpus_dir. Returns: result
metricsA
Resumo das métricas do servidor (contadores + latências p50/p95 desde o boot).
Inputs: no named parameters. Returns: result
searchA
Busca híbrida (vetorial + BM25 via RRF) no corpus indexado. Retorna trechos e fontes.
Inputs: query (required), top_k. Returns: result
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 4 tools
As ferramentas têm propósitos majoritariamente distintos: ingest adiciona documentos, search faz consulta híbrida crua, ask gera resposta com RAG e metrics expõe estatísticas. A única ambiguidade possível é entre ask e search, já que ambos envolvem recuperação, mas as descrições deixam clara a diferença entre resposta gerada e trechos brutos.
Os nomes são todos curtos e em minúsculas, seguindo um estilo simples e consistente. Há uma pequena mistura entre verbos (ask, ingest, search) e substantivo (metrics), mas o padrão geral é previsível e uniforme.
Com 4 ferramentas, o servidor é enxuto e cada uma cobre uma função essencial do ciclo RAG: ingestão, busca, geração e observabilidade. O número é apropriado para o escopo declarado, sem excesso ou carência evidente.
O fluxo principal de RAG está coberto: ingestão, busca híbrida, perguntas com streaming e métricas. Faltam operações secundárias como remover/atualizar documentos ou listar o corpus, mas essas são lacunas menores que não impedem o uso básico do servidor.
Maintenance
Related MCP Connectors
Governed, auditable knowledge your team curates for its AI assistants, self-hostable
Private, multimodal knowledge store for agents: hybrid search with cited, provenance-backed results.
Convert PDFs, Office docs, websites and audio into AI-ready Markdown, then search and ask your docs.
Ingest, manage, and retrieve documents for RAG-powered AI applications
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceEnables intelligent ingestion and querying of PDF, Markdown, and text files using hybrid search that combines keyword matching and semantic embeddings with citations.2-
- FlicenseAqualityDmaintenanceEnables indexing local documents (PDF, Markdown, text, code) into a knowledge base and querying them via semantic search using local embeddings, all running privately on your machine.4-
- AlicenseAqualityDmaintenanceEnables semantic search and question answering over a knowledge base using hybrid retrieval and grounded answers, all running offline with no API keys.4MIT
- FlicenseAqualityDmaintenanceEnables retrieval-augmented question answering over Obsidian vaults and document folders, with local embeddings, vector search, and cited source paths.7-