← Harsh Dodiya

Glyph

Hybrid retrieval over a private infrastructure-documentation corpus — vector and keyword search fused by rank, exposed to Claude as an MCP tool and to the browser as an editing workbench.

● Retrieval / RAGPythonFastAPINext.jsMCP
View ArchitecturePrivate repo — corpus holds live credentials
Glyph system architecture — an MCP client and a browser workbench reach a loopback-only FastAPI retrieval core through a thin stdio forwarder and an authenticated Next.js proxy; the core routes queries, fuses ranks, and serves document reads and writes over two search stores, a Chroma vector index and a SQLite FTS5 keyword index, fed by a redact-chunk-embed pipeline.

Fig. 1 — Two clients, one retrieval core. FastAPI is published on loopback only; nothing reaches it without passing an authenticated edge.

Glyph makes a private documentation corpus searchable — a few dozen Notion-exported infrastructure runbooks: how-tos, troubleshooting notes, node-setup histories. It answers from that corpus inside Claude Code and Claude Desktop as an MCP tool, and in the browser as a workbench for searching and editing the same files.

The problem that shaped it: half the useful queries are literal — 51820, nmcli, rtl-config.json — and half are conceptual, like “my VPN stopped routing traffic.” Neither retrieval method handles both, so Glyph runs both and fuses the rankings instead of picking a side.

One backend serves both the MCP server and the dashboard, so retrieval logic exists in exactly one place. The documents are a bind mount of real files on disk — inspectable, backup-able, editable with ordinary tools — while the index artifacts are a disposable named volume that can be thrown away and rebuilt.

Each half fails in a way the other covers, measured against the real corpus. A port number like 51820 carries almost no semantic signal, so the vector index lands it among unrelated chunks while the keyword index puts it first. Ask “how do I roll back a database engine to an older version” and it inverts — the keyword index has no matching terms to work with, and the vector index gets it on the first try.

Fusion is reciprocal rank fusion, which scores by position rather than by each engine's raw score. Cosine similarity and BM25 are not on a comparable scale, and rank-based fusion means they never have to be. A query router short-circuits the obvious cases: identifier-heavy queries skip the vector side entirely.

And if the embedding provider is down, the query degrades to keyword results instead of failing. A retrieval tool that returns something useful during an outage beats one that returns an error page.

  • 01Query arrives from an MCP tool call or the dashboard — same endpoint, same logic, no duplicate implementation
  • 02Router inspects the query: identifier-heavy goes keyword-only, everything else runs both branches
  • 03Semantic branch embeds the query and takes cosine top-K from Chroma, discarding weak matches below a similarity floor
  • 04Keyword branch runs BM25 over SQLite FTS5 with headings weighted five times body text
  • 05FTS terms are extracted, quoted individually, and OR-ed — never passed through raw
  • 06Both ranked lists merge through reciprocal rank fusion, so incomparable score scales never compete
  • 07A provider failure on the vector side degrades to keyword results rather than failing the request
  • 08Only redacted chunks are ever embedded or stored, so no credential leaves the corpus for an external API

Fig. 2 — Ingestion, with redaction before anything leaves the machine. Fig. 3 — Why both retrieval halves have to exist.

  • 01A cleanup pass repairs exported markdown into a separate tree — it never edits the originals in place
  • 02Chunks split on heading hierarchy at a ~400-token target, merging undersized sections and splitting oversized ones at paragraph boundaries
  • 03Every chunk keeps its heading line in the body, and a merged chunk is labelled with the deepest heading path its parts share
  • 04Embeddings are cached by model and content hash, so re-indexing after an edit only pays for text that actually changed
  • 05Batches are written as they complete, so an interrupted run keeps the work it already paid for
  • 06Rate limiting tracks a sliding token budget rather than request count — the free tier's real ceiling is tokens per minute
  • 07Full rebuilds are staged as a new generation, validated on counts and fingerprint, then activated by an atomic pointer swap
  • 08Readers already open finish against the old generation; incremental writes journal first so a crash can be replayed

The corpus contains live credentials and internal network detail, which sets the whole shape of the system. Redaction runs before chunking, so only redacted text is embedded or stored in a search index — the external embedding provider never sees a secret. Source documents stay untouched on disk.

The browser never talks to FastAPI directly. It goes through an authenticated Next.js proxy that verifies a signed HttpOnly session cookie, applies a nonce-based content security policy, and attaches an optional shared token compared in constant time. Docker publishes the API on loopback only, document paths resolve beneath the corpus root, and traversal or non-markdown targets are rejected outright.

Saves carry the content hash from when the file was opened, so a stale write is rejected rather than silently clobbering someone else's edit. Accepted content is written through a temporary file and atomic replace, then reindexed — and if the reindex fails, the response says so instead of pretending the index is current.

An early version stripped heading lines into metadata only. When small sibling sections merged, every heading but one vanished from the text entirely — making them unfindable by either search. That bug is the reason chunks now carry their own heading text.

The dashboard is a VS Code-shaped workbench: folder explorer, hybrid corpus search, Monaco editing with Markdown preview and split view, index health and actions, light and dark themes, and the keyboard shortcuts you already have in your fingers — quick open, save and reindex, toggle sidebar, search the corpus.

This is deliberately not a microservice system. Embedded Chroma, SQLite, and a process-local lock are correct for one user and low thousands of documents. The scaling boundary is written down — multiple writers, API replicas, or indexing that must outlive the API process — so the rewrite happens when a condition is met, not when it feels overdue.

Python 3.11FastAPIChromaSQLite FTS5Voyage embeddingsReciprocal rank fusionMCP (stdio)Next.jsMonaco EditorTypeScriptDocker Composeunittest
Private repository

The repository is private and the corpus itself was never in it — those documents hold live credentials and internal network detail, and are mounted from disk at runtime. The diagrams above cover the parts worth discussing.

Local-firstSingle userMCP + dashboard
/services
Indexer and retrieval: cleanup, chunker, embedder, vector and FTS indexes, fusion
/mcp_server · /frontend
Stdio adapter exposing three tools, and the Next.js workbench with its authenticated proxy