Skip to content

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

zotero-rag

Русская версия

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.

Results

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

How to run (demo, no Zotero required)

uv sync
python build_index.py --demo
python query.py "What does the suspend keyword do?" --file kotlin --demo

Limitations / what's next

  • 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.py and mcp_server.py each carry their own copy of the BM25 and fusion code (bm25_search and reciprocal_rank_fusion in query.py, Retriever.bm25 and _rrf in mcp_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.

Features

  • Zotero sync with no setup. zotero_sync.py reads ~/Zotero/zotero.sqlite and copies your attachments into data/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.

Architecture

           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

Requirements

  • 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

Installation

git clone <repo-url> zotero_rag
cd zotero_rag
uv sync

Configuration

Create 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-4o

An 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.

Usage

Run the pipeline in order.

1. Sync from Zotero

python zotero_sync.py

Every 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.

2. Build the index

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.

3. Query

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 index

query.py spawns its own Claude session through claude_agent_sdk, so run it from a plain shell outside of Claude Code.

MCP integration (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.

API keys for MCP

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.

Tools exposed

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.

How it works

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.

Project structure

.
├── 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)

Tuning

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.

License

MIT.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages