A local, fully private semantic search engine for your PDF and epub library, integrated with Claude Code via MCP.
What it does: index your book collection once, then ask Claude natural-language questions and get answers with exact page references — all running on your own machine, nothing sent to the cloud except your conversation with Claude.
Stack: Qdrant (vector DB) · Ollama (local embeddings + LLM) · Tesseract (OCR for scanned books) · FastMCP (MCP server)
The MCP layer is optional. The indexing pipeline and vector database work standalone — you can query them directly from Python, from the included Jupyter notebook, or from any MCP-compatible client (Claude Code, GitHub Copilot, Cursor, etc.). The Qdrant collection is also queryable via its REST API from any tool.
Browse it too: Jorge, the included web UI, lets you walk the shelves, search by concept or exact words, and explore the whole library as a zoomable map.
Your PDF library lives on disk. When you index it, server.py extracts text from each file (fast via PyMuPDF, with OCR fallback for scanned books), splits it into chunks, and sends each chunk to a local Ollama model (mxbai-embed-large) which converts it into an embedding — a list of 1024 numbers that captures the semantic meaning of the text. These are stored in Qdrant, a vector database running in Docker on your machine.
When you search, your query goes through the same process: converted to numbers, then Qdrant finds the stored chunks whose numbers are most similar. No keywords needed — just natural language.
The server provides two complementary search tools:
query_library— semantic search. Finds text that is about the same concept, even if it uses different words. Best when you have a topic or idea in mind but don't know exactly where it appears. Example: searching "sound granulation" will also surface chunks about microsound, particle synthesis, stochastic clouds.search_text— literal search. Finds chunks that contain the exact term or phrase. Best when you know a specific word, name, or coined term. Example: searching "semethic" will find exactly where Hoffmeyer uses that word.
If you connect this server to Claude Code via MCP, you can ask Claude questions directly and it will query your library under the hood.
You ask a question
→ Claude converts it to an embedding (via Ollama)
→ Qdrant finds the most similar PDF chunks
→ Claude reads them and answers you
Everything runs locally. No data leaves your machine except your messages to Claude.
| What | Where |
|---|---|
| Vector database | ~/qdrant_storage/ |
| Server code | server.py |
| Claude Code MCP config | ~/.claude/settings.json |
| Setup notebook | install.ipynb |
| Usage notebook | knowledge_server.ipynb |
- Fast Indexing: Uses
PyMuPDFfor high-speed text extraction. - OCR Support: Fallback to Tesseract OCR for scans and images.
- Semantic Search: Powered by Ollama (
mxbai-embed-large) and Qdrant. - Literal + Semantic Search: Exact term lookup and concept-based search.
server.py: The main MCP server entry point.scripts/: Utility and maintenance scripts (manage_index.py,organize_books.py, etc.).scripts/dev_legacy/: Old dev/debug scripts, kept for reference.jorge.py: Jorge, a local web UI to browse and search the library (see below).install.ipynb: Setup and dependency installation notebook.knowledge_server.ipynb: Notebook for indexing and querying interactively.
sudo apt install docker.io tesseract-ocr poppler-utils python3 python3-venvcurl -fsSL https://ollama.com/install.sh | sh
ollama pull mxbai-embed-large
ollama serve &sudo docker run -d \
--name qdrant \
-p 6333:6333 \
--restart unless-stopped \
-v ~/qdrant_storage:/qdrant/storage \
qdrant/qdrantcd knowledge_server
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtEdit ~/.claude/settings.json (replace paths with your actual paths):
{
"mcpServers": {
"knowledge-server": {
"command": "/home/youruser/knowledge_server/.venv/bin/python",
"args": ["/home/youruser/knowledge_server/server.py"],
"env": {
"OLLAMA_MODEL": "mxbai-embed-large",
"QDRANT_HOST": "localhost",
"QDRANT_PORT": "6333",
"COLLECTION_NAME": "pdf_library"
}
}
}
}Open knowledge_server.ipynb, set PDF_DIR to your folder, and run the indexing cell.
Restart Claude Code and ask: "find something about [your topic]"
Note:
open_pdf_pageusesevince(Document Viewer on Ubuntu/Debian). On macOS replaceevincewithopeninserver.py. On Windows usestart.
brew install tesseract poppler pythonInstall Docker Desktop from https://www.docker.com/products/docker-desktop
brew install ollama
ollama pull mxbai-embed-large
ollama serve &docker run -d \
--name qdrant \
-p 6333:6333 \
--restart unless-stopped \
-v ~/qdrant_storage:/qdrant/storage \
qdrant/qdrantcd knowledge_server
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtEdit ~/.claude/settings.json:
{
"mcpServers": {
"knowledge-server": {
"command": "/Users/youruser/knowledge_server/.venv/bin/python",
"args": ["/Users/youruser/knowledge_server/server.py"],
"env": {
"OLLAMA_MODEL": "mxbai-embed-large",
"QDRANT_HOST": "localhost",
"QDRANT_PORT": "6333",
"COLLECTION_NAME": "pdf_library"
}
}
}
}In server.py, find open_pdf_page and replace "evince" with "open":
subprocess.Popen(["open", "-a", "Preview", file_path])Note: macOS Preview doesn't support opening to a specific page via CLI. For page-level navigation install
mupdf:brew install mupdf-toolsand usemupdf.
Same as Linux: open knowledge_server.ipynb, index, restart Claude Code and search.
Install from their official sites:
Download from https://ollama.com, then in PowerShell:
ollama pull mxbai-embed-large
ollama servedocker run -d `
--name qdrant `
-p 6333:6333 `
--restart unless-stopped `
-v $env:USERPROFILE\qdrant_storage:/qdrant/storage `
qdrant/qdrantcd knowledge_server
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txtEdit %USERPROFILE%\.claude\settings.json:
{
"mcpServers": {
"knowledge-server": {
"command": "C:\\Users\\youruser\\knowledge_server\\.venv\\Scripts\\python.exe",
"args": ["C:\\Users\\youruser\\knowledge_server\\server.py"],
"env": {
"OLLAMA_MODEL": "mxbai-embed-large",
"QDRANT_HOST": "localhost",
"QDRANT_PORT": "6333",
"COLLECTION_NAME": "pdf_library",
"TESSERACT_CMD": "C:\\Program Files\\Tesseract-OCR\\tesseract.exe",
"POPPLER_PATH": "C:\\path\\to\\poppler\\bin"
}
}
}
}In server.py, find open_pdf_page and replace the subprocess.Popen line with:
subprocess.Popen(["start", "", f"/p {page_number}", file_path], shell=True)Same as Linux: open knowledge_server.ipynb, index, restart Claude Code and search.
-
Ollama running with
mxbai-embed-large:ollama pull mxbai-embed-large ollama serve
-
Qdrant running on port 6333, with a persistent bind mount so your indexed data survives container restarts and is easy to back up:
docker run -d \ --name qdrant \ -p 6333:6333 \ --restart unless-stopped \ -v ~/qdrant_storage:/qdrant/storage \ qdrant/qdrantThe database will be stored at
~/qdrant_storage/on your machine.Warning: the plain
docker run -d -p 6333:6333 qdrant/qdrantcommand stores data inside the container — it will be lost if the container is removed. -
Python dependencies:
pip install -r requirements.txt
Add the server to your MCP client configuration:
Edit your MCP settings file (usually at ~/Library/Application Support/Antigravity/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json on macOS):
{
"mcpServers": {
"knowledge-server": {
"command": "/path/to/your/python",
"args": [
"/path/to/knowledge_server/server.py"
],
"env": {
"OLLAMA_MODEL": "mxbai-embed-large",
"QDRANT_HOST": "localhost",
"QDRANT_PORT": "6333",
"COLLECTION_NAME": "pdf_library"
},
"disabled": false,
"autoApprove": []
}
}
}Edit ~/.claude/settings.json:
{
"mcpServers": {
"knowledge-server": {
"command": "/path/to/knowledge_server/.venv/bin/python",
"args": ["/path/to/knowledge_server/server.py"],
"env": {
"OLLAMA_MODEL": "mxbai-embed-large",
"QDRANT_HOST": "localhost",
"QDRANT_PORT": "6333",
"COLLECTION_NAME": "pdf_library"
}
}
}
}Add to your Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json on macOS):
{
"mcpServers": {
"knowledge-server": {
"command": "/path/to/your/python",
"args": ["/path/to/knowledge_server/server.py"],
"env": {
"OLLAMA_MODEL": "mxbai-embed-large",
"QDRANT_HOST": "localhost",
"QDRANT_PORT": "6333"
}
}
}
}| Variable | Default | Description |
|---|---|---|
OLLAMA_MODEL |
mxbai-embed-large |
Embedding model for semantic search |
QDRANT_HOST |
localhost |
Qdrant database host |
QDRANT_PORT |
6333 |
Qdrant database port |
COLLECTION_NAME |
pdf_library |
Vector collection name |
TESSERACT_CMD |
/opt/homebrew/bin/tesseract |
Path to Tesseract OCR |
POPPLER_PATH |
/opt/homebrew/bin |
Path to Poppler utils |
Once configured, the following tools are available:
index_library(path)- Fast parallel PDF indexingindex_single_pdf(file_path)- Index a single PDF fileindex_with_ocr(path)- OCR-based indexing for scanned documentsreindex_with_ocr(file_path)- Force OCR on one PDF whose text layer is garbled, replacing its chunks; the file is remembered (by hash) so later full re-indexes OCR it tooquery_library(query, n_results)- Semantic search: finds text about the same concept, even with different wordssearch_text(term, n_results)- Literal search: finds exact words or coined terms (e.g. author-specific terminology)get_document_info(file_hash)- Get document metadataread_page(file_hash, page_number)- Read a specific pagereconstruct_document(file_hash)- Reconstruct full document textopen_pdf_page(file_path, page_number)- Open a PDF at a specific page in Document Viewer
When the MCP server is connected, ask Claude naturally and it will search your library and present numbered results:
You: "find something about spectral harmony"
Claude: Found 5 results:
1. Murail - Spectral Music (p.42/210)
"...the harmonic series as a structural principle..."
2. Grisey - Temporal Spaces (p.17/180)
"...spectral harmony differs from traditional tonality..."
Open which? (e.g. "1", "2 and 3", "all")
You: "2"
Claude: [opens Grisey - Temporal Spaces at page 17 in Document Viewer]
-
Index your PDF library:
index_library("/path/to/your/pdfs") -
Search your documents:
query_library("machine learning music generation", n_results=5)
The simplest and most maintainable layout is a flat folder of PDFs — no library manager required.
~/Books/
Silence - John Cage.pdf
Computer Music Tutorial - Curtis Roads.pdf
Syntactic Structures - Noam Chomsky.pdf
...
- Drop your PDFs into
~/Books/(or any flat folder). - Point the indexer at it:
index_library("/home/youruser/Books") - Search with natural language via Claude Code or
manual_search.py:query_library("semiotic interaction and sound") - Claude returns ranked results with titles, pages, and snippets — pick one and it opens in your PDF viewer at the exact page.
The knowledge server is the index — filenames and Qdrant are the only metadata you need. No external library manager, no path drift when metadata changes.
Using Calibre? It works too — just point
index_libraryat your Calibre Library folder. But be aware that renaming books in Calibre moves their files, which can make source paths in Qdrant go stale.scripts/cleanup_orphans.pycleans those up.
A local web UI for exploring the library, independent of Claude. Named for Borges and for Jorge of Burgos, the librarian in Eco's The Name of the Rose; its map view is the Aedificium, after his labyrinthine library.
Works on Linux, macOS and Windows. Use the Python from the project's virtual environment:
.venv/bin/python jorge.py --launch # start in the background if needed, open an app window, exit
.venv/bin/python jorge.py # or run the server in the foreground (Ctrl+C to stop)On Windows the interpreter is .venv\Scripts\python.exe (use pythonw.exe for a shortcut, so no console window stays open). The app window uses Chrome, Edge, Brave or Chromium when one is installed (--app mode); otherwise Jorge opens in your default browser. Options: --port 8765 (or JORGE_PORT), --no-open.
To put Jorge in your app menu or dock, make a shortcut that runs jorge.py --launch with the venv's Python and uses jorge_static/jorge.png as its icon:
-
Linux: a
.desktopfile in~/.local/share/applications/withExec=/path/to/.venv/bin/python /path/to/jorge.py --launch -
macOS: an Automator "Application" with a Run Shell Script action running the same command
-
Windows: a shortcut to
.venv\Scripts\pythonw.exe jorge.py --launch, with "Start in" set to the project folder -
Shelves: folder tree under
BOOKS_DIRwith book counts, plus two housekeeping shelves: files on disk that are not indexed, and indexed books whose file has moved or been deleted. -
Covers / List: sortable by title, date added, pages or size; the search box filters titles live.
-
Concepts / Exact words: the same semantic and literal search as
query_library/search_text, limited to the selected shelf, with results grouped by book. -
Book topics: ranks whole books for an idea. It retrieves the 600 closest passages and scores each book on its five best ones, weighted 1, 0.6, 0.4, 0.3, 0.2, so a book that keeps returning to the topic outranks a single passing mention.
-
Match strength: semantic search always returns the nearest passages, even for a query the library knows nothing about. Concepts and Book topics therefore label each result strong, possible or weak by its best passage score (thresholds
STRONG_MATCH/POSSIBLE_MATCHinjorge.py, calibrated for mxbai-embed-large), fold weak matches away, and say so when nothing is close. They also run an exact-phrase search alongside: books containing the words are marked, and books found only that way are listed below, which catches names and coined terms that embeddings miss. When a shelf is selected, results say so and offer to search the whole library. -
Aedificium: a zoomable map where each book is a dot placed by what its text is about (the mean of its passage vectors, linked to its 6 nearest books), coloured by top-level shelf. Searches and the title filter light up matching books and zoom to them; the legend filters by shelf. Regions are labelled at two zoom levels (about 12 broad areas, about 40 neighbourhoods), using title words that are frequent in a region but rare in the library and in other regions; broad areas take the leading terms of the neighbourhoods inside them. The first build reads every vector (about 1.5 minutes for 240k passages) and caches book vectors in the knowledge_server cache folder (
~/.cache/knowledge_serveron Linux,~/Library/Caches/knowledge_serveron macOS,%LOCALAPPDATA%\knowledge_server\Cacheon Windows), next to cover thumbnails andjorge.log; after that only new or re-indexed books are recomputed. -
Opening books: "Open in viewer" jumps to the page in Evince, Okular or Zathura (Linux), SumatraPDF (Windows; otherwise the default PDF app), or Preview (macOS; the page jump needs Accessibility permission for System Events, otherwise the book opens at the start).
PDF_VIEWER/EPUB_VIEWERinconfig.pyoverride the choice. "Show folder" opens the file manager with the book selected (macOS, Windows) or its folder (Linux). -
Book card: indexed text page by page (arrow keys to turn pages), open in the system viewer or a browser tab at that page, and "nearby" books whose passages are closest to this one's.
The catalog is read from Qdrant on the fly (one entry per file_hash) and rebuilt whenever the collection's point count changes, so new indexing shows up on reload. Rescan forces a rebuild. Cover thumbnails are cached in the same cache folder. The server listens on 127.0.0.1 only.
Two files, no build step, and no dependencies beyond what the knowledge server already installs (Starlette, uvicorn, numpy, PyMuPDF, the Qdrant client):
| What it does | |
|---|---|
jorge.py (~630 lines of Python) |
A small Starlette server on 127.0.0.1:8765. It owns everything that touches data: the catalog, searches (query embeddings through Ollama), the per-book vectors and map geometry, cover thumbnails, serving files, opening books in a desktop viewer, and the --launch launcher. |
jorge_static/index.html (~880 lines) |
The whole interface in one page: CSS for layout and light/dark themes, and plain JavaScript (no framework) for shelves, covers, search, the book card and the Aedificium. The only outside library is d3 for the force layout, zoom and hit-testing; the map itself is drawn by hand on a <canvas>. |
Jorge has no database of its own: the page asks the server for JSON, and the server reads the same Qdrant collection the MCP server maintains.
browser (index.html) ──JSON──▶ jorge.py ──▶ Qdrant (passages, vectors, metadata)
│ ──▶ Ollama (query embeddings)
└────────▶ ~/Books (files, covers, unindexed check)
Catalog. Qdrant stores passages, not books. Jorge reads only the metadata of every passage (about 4 s for 240k), groups it by file_hash into one entry per book, and compares it with the files under BOOKS_DIR. The catalog is kept in memory and rebuilt whenever the collection's point count changes.
Search. Concepts and Exact words are the same vector and full-text searches as query_library and search_text. Book topics takes the 600 passages closest to the query, groups them by book and scores each book on its five best passages, weighted 1, 0.6, 0.4, 0.3, 0.2.
Book card. The page reader joins a page's passages and trims the ~200-character overlap between neighbours. Nearby on other shelves takes 12 passages spread through the book, asks Qdrant for each one's nearest passages in other books (using stored vectors, no new embeddings) and sums the scores per book. Covers are page 1 rendered with PyMuPDF and cached as JPEGs.
Aedificium. The work is split between the two files:
- Server (numpy): each book's vector is the mean of its passage vectors, cached in
book_vectors.npzand recomputed only for new or re-indexed books. The direction shared by all books is subtracted, each book is linked to its 6 nearest neighbours, and PCA gives starting positions. The browser receives ~1,200 points and their links. - Browser (d3 + canvas): a force simulation (links attract, books repel) runs 320 steps before the first frame, so the map opens already settled. A quadtree finds the dot under the cursor.
- Region labels (browser): k-means on the laid-out positions gives ~12 broad areas and ~40 neighbourhoods. A neighbourhood is named by title words and word pairs that are frequent in it but rare in the library and in other regions (TF-IDF at two levels); a broad area takes the leading names of the neighbourhoods inside it.
Launcher. jorge.py --launch starts the server as a detached background process if it is not already answering, then opens a Chromium-family browser in --app mode (no tabs or address bar), falling back to the default browser.
On a new machine, copy the templates and edit for your setup:
cp config.example.py config.py
cp scripts/ocr_priority_config.example.py scripts/ocr_priority_config.py| Setting | Typical value | Description |
|---|---|---|
BOOKS_DIR |
~/Books |
Path to your flat PDF folder |
EMPTY_HASH_CACHE |
~/.local/share/knowledge_server/empty_hashes.json |
Cache of no-text file hashes — skip on future re-indexes |
PDF_VIEWER |
evince |
App to open PDFs (evince, okular, zathura, or None for system default) |
EPUB_VIEWER |
foliate |
App to open epubs (foliate, or None for system default) |
These are set in ~/.claude/settings.json under mcpServers.env, or exported in your shell:
| Variable | Default | Description |
|---|---|---|
QDRANT_HOST |
localhost |
Qdrant host |
QDRANT_PORT |
6333 |
Qdrant port |
COLLECTION_NAME |
pdf_library |
Qdrant collection name |
OLLAMA_MODEL |
mxbai-embed-large |
Embedding model |
OLLAMA_URL |
http://localhost:11434 |
Ollama base URL |
| Setting | Description |
|---|---|
BOOKS_DIR |
Inherited from config.py — no need to set separately |
TIER1_KEYWORDS |
List of filename substrings to OCR first (your high-value books) |
SKIP_KEYWORDS |
Books to skip even if they match Tier 1 |
Do this when you want a fresh database — e.g. after a code update that changes the metadata format, or when the collection has accumulated stale/duplicate entries.
Takes several hours. Do it when you can leave the machine running overnight.
# In knowledge_server.ipynb — run cells in order:
# 1. Wipe the collection (cell 4)
db = server.get_db()
db.delete_collection()
# 2. Re-index all text PDFs and epubs (no OCR) (cell 5)
server.index_library("/home/youruser/Books")# 3. After step 2 finishes, run OCR on scanned books
python scripts/ocr_priority.py
Steps 2–3 are safe to resume if interrupted — each one skips already-indexed files (by hash) or already-fixed titles.
scripts/add_book.py checks a book against the library conventions in library_checks.py, then moves it into its shelf, indexes it, sets the title from the filename and reports chunks and page coverage. Nothing is moved or indexed unless every check passes.
The conventions: books live in a shelf folder (never the library root); filenames are Author - Title (Year) (A & B - ..., A, B & C - ..., A et al. - ..., Name (ed.) - ...); the indexed title comes from the filename; figures, scraps and duplicates go to ~/Books Extra/debris, which is not indexed.
# Check only (changes nothing)
python scripts/add_book.py ~/Downloads/x.pdf --shelf Semiotics/General-Theory --name "Umberto Eco - Kant and the Platypus (1997)" --check
# Add it: move + rename into the shelf, index, title, verify
python scripts/add_book.py ~/Downloads/x.pdf --shelf Semiotics/General-Theory --name "Umberto Eco - Kant and the Platypus (1997)"
# Garbled text layer (broken font encoding): OCR every page instead
python scripts/add_book.py FILE --shelf ... --name ... --force-ocr
# Books already placed in a shelf but not indexed yet
python scripts/add_book.py --scan --check
python scripts/add_book.py --scanIt refuses duplicates (same file already indexed), the library root, non-conforming names, likely debris and suspect text layers; --allow-name, --allow-junk and --allow-garbled override a check you have looked at. Scanned pages are OCR'd automatically. The MCP indexing tools follow the same rules: they set titles from filenames and report convention warnings.
To audit the whole library (read-only), python scripts/check_library.py writes ~/Books/library_audit.tsv: files at the root, non-conforming names, likely debris, scans and suspect text layers, and books missing from the index.
When you delete or replace a PDF, its old chunks remain in Qdrant pointing to a path that no longer exists. Clean them up with:
# Dry run — shows what would be deleted
python scripts/cleanup_orphans.py
# Apply
python scripts/cleanup_orphans.py --applyThen run index_library to pick up any new/replacement files.
| Script | What it does |
|---|---|
scripts/add_book.py |
Check → move into shelf → index → title from filename → verify. Single books, or --scan for new books already in a shelf |
scripts/check_library.py |
Read-only audit of the library against the conventions; writes ~/Books/library_audit.tsv |
scripts/reindex_nightly.py |
Resumable re-index into a new collection in nightly windows (see scripts/systemd/) |
scripts/fix_titles.py |
Legacy: LLM-generated titles. Superseded by filename titles; don't use for new books |
scripts/ocr_priority.py |
Scans ~/Books/ for unindexed PDFs, runs Tesseract OCR on priority books |
scripts/export_library_report.py |
Generates a markdown or CSV report of all indexed books from Qdrant |
scripts/backfill_file_hash.py |
Backfills missing file_hash metadata on existing Qdrant chunks |
scripts/cleanup_orphans.py |
Removes Qdrant chunks whose source file no longer exists on disk |
scripts/backfill_full_text.py |
Backfills full chunk text in Qdrant (fixes truncation from older index runs) |
This project started from an original idea and initial implementation by Carlo Barbagallo (nojarec/knowledge_server). This repo has since diverged significantly and is maintained independently.
