Ask questions of your own Zotero library and get answers back with the pages they came from. Two searches run against the library at once, one by meaning and one by keyword, and their results are merged before a model reads them. You can drive the whole thing from the command line, or wire it into Claude Code as an MCP server.
I built it because I kept losing an hour to questions like "which Kotlin book had the chapter that explains reified type parameters properly".
Nothing about your library leaves the machine except the text sent to your LLM provider for embeddings and HyDE. Gemini is the default, and OpenAI works if you switch the provider.
eval/run_eval.py scores recall@k over a 25-question golden set. It runs
against a small public demo corpus (Apache-2.0 Kotlin docs plus an RFC 7519
PDF), so you can reproduce the numbers yourself:
| Config | Recall@5 | Recall@10 |
|---|---|---|
| semantic-only | 96% | 96% |
| bm25-only | 92% | 92% |
| hybrid+HyDE | 96% | 100% |
Every search() and query_rag() call logs its own timings at INFO level:
zotero_rag: bm25=135ms hyde=4432ms embed=456ms semantic=4ms total=4892ms
uv sync
python build_index.py --demo
python query.py "What does the suspend keyword do?" --file kotlin --demo- The eval set has 25 questions, and each file type in it covers a single technical domain. That's enough resolution to rank retrieval configs against each other. Calling it a benchmark would oversell it.
query.pyandmcp_server.pyeach carry their own copy of the BM25 and fusion code (bm25_searchandreciprocal_rank_fusioninquery.py,Retriever.bm25and_rrfinmcp_server.py). Consolidating them is still on the list.- Reranking today is a single LLM pass inside
query.py. A cross-encoder is the obvious next thing to try.
- Zotero sync with no setup.
zotero_sync.pyreads~/Zotero/zotero.sqliteand copies your attachments intodata/files/, keeping the collection tree intact. - PDFs and HTML both work. PyMuPDF pulls text and the table of contents out of a PDF, so every page carries a section path. HTML goes through BeautifulSoup, which strips nav and footer chrome, then splits on headings.
- Parent and child chunks. Only the 384-token children get embedded. When one of them matches, the model reads the whole parent page.
- Hybrid retrieval. Semantic search and BM25 run over the same corpus, and Reciprocal Rank Fusion merges the two rankings. HyDE writes a plausible textbook answer first and embeds that instead of your question.
- Rebuilds skip what hasn't changed. Move a file between collections in Zotero and only its metadata gets rewritten.
- MCP server. Claude Code talks to it over stdio and calls the retrieval tools directly.
- Pluggable provider. Gemini out of the box, OpenAI with one env var.
Zotero.app
│
▼
┌───────────────┐ ~/Zotero/zotero.sqlite
│ zotero_sync │◄────── ~/Zotero/storage/<key>/*.pdf|*.html
└───────┬───────┘
│ data/files/<Collection>/<parent_key>_<file>
▼
┌───────────────┐ parent_store.json (full pages)
│ build_index │───────►
└───────┬───────┘ chroma_db/ (child chunks + embeddings)
│
┌──────────┴──────────┐
▼ ▼
┌──────────┐ ┌───────────────┐
│ query.py │ │ mcp_server.py │◄── Claude Code (via .mcp.json)
│ (CLI) │ │ (FastMCP) │
└──────────┘ └───────────────┘
│ │
└── semantic + BM25 + HyDE → RRF → parent pages
- Python 3.10 or newer
- A Zotero install with items synced locally to
~/Zotero/ - An API key for one provider: Google AI Studio (Gemini) or OpenAI
git clone <repo-url> zotero_rag
cd zotero_rag
uv syncCreate a .env file in the project root:
# --- Provider selection -----------------------------------------------
# LLM_PROVIDER: "gemini" (default) | "openai"
GEMINI_API_KEY=your_key_here
# Optional: switch to OpenAI
# OPENAI_API_KEY=your_key_here
# LLM_PROVIDER=openai
# --- Model overrides (optional; defaults live in config.PROVIDERS) ----
# Gemini embeddings: "models/gemini-embedding-2-preview" (default)
# "models/text-embedding-004"
# "models/embedding-001"
# GEMINI_EMBEDDING_MODEL=models/text-embedding-004
# Gemini HyDE: "gemini-3.1-flash-lite-preview" (default)
# "gemini-2.0-flash"
# "gemini-1.5-flash"
# "gemini-1.5-flash-8b"
# GEMINI_HYDE_MODEL=gemini-2.0-flash
# OpenAI embeddings: "text-embedding-3-small" (default, 1536 dim)
# "text-embedding-3-large" (3072 dim, ~5x cost)
# "text-embedding-ada-002" (legacy)
# OPENAI_EMBEDDING_MODEL=text-embedding-3-large
# OpenAI HyDE: "gpt-4o-mini" (default)
# "gpt-4o"
# "gpt-4.1-mini"
# "gpt-4.1-nano"
# OPENAI_HYDE_MODEL=gpt-4oAn embedding space belongs to the model that produced it, so vectors written
by two different models mean nothing to each other. Touch LLM_PROVIDER or
any *_EMBEDDING_MODEL and the index has to be rebuilt with --force.
Everything else you might want to tune (chunk sizes, retrieval K, the RRF
constant, prompts) sits in config.py.
Run the pipeline in order.
python zotero_sync.pyEvery PDF and HTML attachment lands in data/files/<Collection>/ with the
parent item key glued to the front of the filename. Items filed in no
collection go to _unsorted/. Delete or move something in Zotero and the next
sync prunes the file it left behind.
python build_index.py # incremental: only new/changed files
python build_index.py --force # drop and rebuild the collection
python build_index.py --file kotlin # restrict to paths containing "kotlin"
python build_index.py --demo # build the small demo/eval index under eval/The first run takes a while, since embedding calls are rate-limited. Later runs touch only what changed.
python query.py "What is structural typing in Kotlin?"
python query.py "question" -f kotlin # filter by file substring
python query.py "question" --no-hyde # embed the raw question
python query.py "question" --no-bm25 # semantic only
python query.py --chapter "Chapter 11" # chapter summary
python query.py --chapter "Chapter 11" -f kotlin # + file filter
python query.py --chapter "Chapter 11" "focus on generics" # + custom prompt
python query.py --list-files # list indexed files
python query.py "question" --demo # query the demo indexquery.py spawns its own Claude session through claude_agent_sdk, so run it
from a plain shell outside of Claude Code.
A .mcp.json is checked in, pointing at my checkout. Swap command and cwd
for your own paths:
{
"mcpServers": {
"zotero-rag": {
"command": "/absolute/path/to/zotero_rag/.venv/bin/python",
"args": ["mcp_server.py"],
"cwd": "/absolute/path/to/zotero_rag"
}
}
}Point command at the interpreter uv sync created, then reconnect the
zotero-rag server from /mcp inside Claude Code.
The server calls load_dotenv() at startup, which is why .env alone is
enough for the CLI. Claude Code expands ${VAR} in .mcp.json against its own
environment, and that environment has never heard of your .env file. Keep a
key only in .env while referencing it as ${OPENAI_API_KEY} and the
expansion quietly yields an empty string, after which the server fails to start
with -32000.
Option A, shell environment (recommended). Export the keys from your shell profile so every process Claude Code launches inherits them:
# ~/.zshrc or ~/.zprofile
export OPENAI_API_KEY="sk-..."
export LLM_PROVIDER="openai"Then name them in the env block:
"env": {
"OPENAI_API_KEY": "${OPENAI_API_KEY}",
"GEMINI_API_KEY": "${GEMINI_API_KEY:-}",
"LLM_PROVIDER": "${LLM_PROVIDER:-openai}"
}Option B, keys stay in .env. Leave the key names out of the env block
altogether and let load_dotenv() find them when the server boots:
"env": {
"LLM_PROVIDER": "${LLM_PROVIDER:-openai}"
}Option B costs you one thing: LLM_PROVIDER then has to come from .env too,
so switching providers means editing a file.
| Tool | Purpose |
|---|---|
search |
Hybrid retrieval. Returns a SearchResult with passages and seen_parent_ids, so a follow-up call can page past what you already saw. |
get_chapter |
Every page of a chapter, in order, keyed off section_path. |
list_files |
Lists every indexed file. |
list_sections |
Lists available section paths, for finding chapter names. |
Retrieval is all the server does. Claude reads the passages and writes the answer itself.
Parent and child chunks. Embed a whole page and the vector averages out
into something that matches everything weakly. Go the other way and a
100-token span arrives at the model with no memory of the argument around it.
So the index works at two sizes: 384-token children go into ChromaDB with a
parent_id in their metadata, while the full page text sits in
parent_store.json. A child that matches resolves back to its parent, and the
parent is what reaches the LLM.
Hybrid retrieval. Semantic search finds passages that say the same thing
in different words. BM25 is there for the cases where the literal string
matters, like a code identifier or a term the embedding model has barely seen.
Both rankings feed Reciprocal Rank Fusion, score(d) = Σ 1 / (k + rank_i(d)),
with k from config.RRF_K. Output gets deduplicated by parent_id.
HyDE. A four-word question makes a poor embedding query, since the vector of a question sits nowhere near the vector of its answer. Before the semantic leg runs, a cheap model (Gemini 3.1 Flash-Lite, or GPT-4o-mini on OpenAI) writes two or three paragraphs of textbook prose that would answer the question. That prose is what gets embedded. BM25 still sees your original wording, so the keyword intent survives.
Section paths. A PDF table of contents and an HTML heading hierarchy both
collapse into a breadcrumb along the lines of
Part 1 > Chapter 3 > 3.3 Extensions. Every chunk carries one, and the LLM
sees them in its context, which is what lets it tell apart two books using the
same word for different things.
MCP server design. mcp_server.py is a FastMCP server whose
asynccontextmanager lifespan builds the ChromaDB client, the embedder and the
HyDE client once at startup and hands them to every request. The Retriever
holds a snapshot of the corpus in memory (ids, docs, metadatas, tokens) next to
a prebuilt BM25Okapi index, invalidated whenever the mtime of
parent_store.json moves. search is async: BM25 scoring goes to a thread via
asyncio.to_thread while the HyDE call, the embed call and the Chroma query
proceed on the event loop, and asyncio.gather joins them, so the two
expensive paths overlap.
.
├── zotero_sync.py # Zotero SQLite → data/files/
├── build_index.py # data/files/ → chroma_db/ + parent_store.json
├── query.py # CLI: hybrid retrieval + Claude reranking
├── mcp_server.py # FastMCP server for Claude Code
├── providers.py # LLM provider strategies (Gemini, OpenAI)
├── config.py # Paths, retrieval knobs, provider registry, prompts
├── pyproject.toml # Packaging + ruff/mypy/pytest config (uv-managed)
├── uv.lock
├── tests/ # pytest suite (fake provider, no live API calls)
├── eval/ # Demo corpus + recall@k evaluation harness
├── .github/workflows/ # CI: ruff, mypy, pytest
├── .env # API keys (gitignored)
├── .mcp.json # MCP registration for Claude Code
├── data/files/ # Synced PDFs and HTML (gitignored)
├── chroma_db/ # Vector store (gitignored)
└── parent_store.json # Full parent texts (gitignored)
The knobs in config.py:
| Constant | Default | What it controls |
|---|---|---|
CHILD_CHUNK_SIZE |
384 |
Tokens per embedded child chunk. |
CHILD_CHUNK_OVERLAP |
50 |
Overlap between adjacent child chunks. |
RETRIEVAL_K |
20 |
Top-K child chunks per retrieval method before fusion. |
RRF_K |
60 |
Reciprocal Rank Fusion smoothing constant. |
RERANK_TOP_N |
5 |
Parent pages the CLI keeps after Claude-side reranking. |
Model lineups live in the PROVIDERS registry:
PROVIDERS = {
"gemini": ProviderConfig(embedding_model="models/gemini-embedding-2-preview",
hyde_model="gemini-3.1-flash-lite-preview", ...),
"openai": ProviderConfig(embedding_model="text-embedding-3-small",
hyde_model="gpt-4o-mini", ...),
}To add a provider, write one ProviderConfig entry in config.PROVIDERS and
one subclass in providers.py decorated with @register_provider(name). Call
sites stay untouched, because build_index, query and mcp_server all go
through make_provider().
Change CHILD_CHUNK_SIZE, CHILD_CHUNK_OVERLAP or the embedding model and you
need a --force rebuild.
MIT.