A configurable AI context platform. Point it at documents, and it indexes them into several kinds of database at once — then answers queries by searching all of them and fusing the results into a single ranking, optionally with an LLM writing the final answer.
The distinguishing idea is not RAG. It is polyglot persistence under one facade: the same document lives in four stores simultaneously, each good at a different kind of matching, and every one of those stores has two interchangeable backends selected by a single environment variable.
| Store | Matches on | Retrieved by |
|---|---|---|
| Vector | meaning | embed the query, nearest-neighbour search |
| Full-text | exact words | BM25 keyword search |
| Property graph | relationships | an LLM writing read-only Cypher |
| RDF triples | labelled resources | a SPARQL CONTAINS filter |
A second idea runs through the codebase: one business-logic facade, several front doors. A
REST API, an MCP server, a React SPA and a background sync subsystem all call the same
Orchestrator methods. None of them holds behaviour of its own.
Every backend category has an embedded option, so the default stack needs no services at all.
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
cp env.example .envSelect the embedded backends in .env:
VECTOR_BACKEND=chroma
FULLTEXT_BACKEND=sqlite_fts
GRAPH_BACKEND=kuzuRun it:
.venv/bin/uvicorn polyrag.api.app:app --reload --app-dir src # http://localhost:8000
cd ui && npm install && npm run dev # http://localhost:5173Or bring up the whole stack in containers — three application services, no databases required:
docker compose upflowchart TB
UI["React UI"] -->|HTTP| REST["REST API — FastAPI routers"]
MCP["MCP adapter"] -->|HTTP| REST
REST --> FACADE
SYNC["Sync updaters"] -->|in-process| FACADE
FACADE["<b>Orchestrator facade</b><br/><i>orchestration/facade.py</i><br/>the only place a config value becomes an adapter"]
FACADE --> VS["VectorStore"]
FACADE --> FS["FullTextStore"]
FACADE --> GS["GraphStore"]
FACADE --> RS["RDFStore"]
VS --> V1["Qdrant<br/><i>service</i>"]
VS --> V2["Chroma<br/><i>embedded</i>"]
FS --> F1["Elasticsearch<br/><i>service</i>"]
FS --> F2["SQLite FTS5<br/><i>embedded</i>"]
GS --> G1["Neo4j / Bolt<br/><i>service</i>"]
GS --> G2["Kuzu<br/><i>embedded</i>"]
RS --> R1["SPARQL endpoint<br/><i>service</i>"]
RS --> R2["rdflib file<br/><i>embedded</i>"]
Each store interface is a small ABC — a VectorStore is upsert, search,
delete_by_document_id, and nothing else. Adding a backend costs one module, one enum member,
and one branch in the facade's lazy property. Nothing else in the codebase changes.
src/polyrag/
config.py one settings class; every backend choice is an enum field
models.py plain dataclasses moved between stages
orchestration/ facade.py — the orchestrator; status.py — the processing table
sources/ SourceConnector: filesystem, web
parsing/ chunking/ embeddings/
vectorstore/ fulltext/ graphstore/ rdfstore/ base.py ABC + one module per backend
kgextract/ schema-constrained, dynamic and simple extraction strategies
llm/ LLMProvider: OpenRouter, Anthropic
retrieval/ one retriever per modality, plus fusion, dedup and retry
sync/ repository, scanner, updater, manager
api/ FastAPI routers: health, ingest, search, sync
src/polyrag_mcp/ a separate process: MCP tools over the REST API
ui/src/ Vite + React + TypeScript SPA, four tabs
deploy/ three Dockerfiles, nginx config, smoke checker
The dependency direction never inverts: api → orchestration → adapter packages →
models. Adapter packages never import each other and never import the facade.
Every store is a lazily-initialised property on the orchestrator, so a deployment that
runs full-text only never constructs a vector client or loads an embedding model. A None
store is the supported "this modality is switched off" signal, and every caller handles it.
POST /ingest/files returns a processing_id immediately and runs the work on a worker
thread. GET /ingest/status/{id} reports which stages ran and which stores were touched.
flowchart LR
A["1 · list<br/>SourceItems"] --> B["2 · fetch<br/>raw bytes"]
B --> C["3 · parse<br/>ParsedDocument"]
C --> D["4 · chunk<br/>overlapping"]
D --> E["5 · embed<br/><i>only if a vector store exists</i>"]
E --> F["6 · write<br/>delete, then upsert"]
D --> KG["KG extraction — LLM<br/><i>unless skip_graph</i>"]
KG -->|entities + relations| PG["Property graph"]
KG -->|entities + relations| RDF["RDF triples"]
F -->|chunks| VEC["Vector store"]
F -->|chunks| FT["Full-text index"]
Delete-then-add is the only write in the system. That is what makes re-ingesting a changed document identical to ingesting it the first time, in every store, with no separate update path to keep correct.
Embedding is gated on a vector store actually existing, so a full-text-only deployment never loads a model it has no use for.
POST /ingest/textskips listing, fetching and parsing: raw text is wrapped as one document and indexed directly.POST /ingest/uploadstages files server-side and returns{accepted, skipped}with a reason per skipped file. It does not ingest. The caller then passes the accepted paths to/ingest/files. Splitting these lets a client say "3 of 5 files will be skipped, because…" before anything starts, so a partly-unusable batch is a decision rather than a failure.
Three strategies, selected by KG_EXTRACTOR_STRATEGY: schema_constrained (entities must fit
a declared ontology), dynamic (the LLM invents the types), and simple. The factory
automatically downgrades schema-constrained to dynamic for a provider that cannot reliably
follow strict-JSON prompting, with a warning.
Relations carry a document id and their own properties, which bare triples cannot hold, so each
relation is written as an RDF-reified statement: an rdf:Statement resource asserting
subject / predicate / object, with the document id and extracted properties attached to the
statement itself. That encoding lives in one shared module, so a graph written through either
RDF adapter reads correctly through the other.
flowchart TB
Q["query"] --> VR["<b>Vector</b><br/>embed → nearest neighbours"]
Q --> FR["<b>Full-text</b><br/>BM25"]
Q --> GR["<b>Graph</b><br/>LLM → read-only Cypher"]
Q --> RR["<b>RDF</b><br/>SPARQL on rdfs:label"]
VR -->|ranked hits| RRF
FR -->|ranked hits| RRF
GR -->|ranked hits| RRF
RR -->|ranked hits| RRF
RRF["<b>Reciprocal rank fusion</b><br/>score += 1 / (60 + rank), summed across modalities"]
RRF --> D1["dedup by chunk_id"]
D1 --> D2["dedup by normalised text hash"]
D2 --> D3["dedup by word overlap ≥ 0.8"]
D3 --> OUT["rescale → sort → top-k<br/><i>GET /search</i>"]
OUT --> LLM["LLM answer synthesis<br/><i>POST /query</i>"]
Rank is the only unit the four modalities share. Cosine similarity, BM25, a Cypher row and a SPARQL match live in incompatible score spaces, so fusion happens on rank rather than score.
Every dedup layer sums scores rather than dropping the loser: a passage three modalities
agree on ranks higher, it is not silently discarded. After dedup, graph-traversal boilerplate is
stripped and each hit gets a friendly source label — notes.md (Vector Search).
Failure is designed for. Each retriever call is wrapped in a bounded retry that fires only
for genuinely transient classes — 429 and 5xx, connection resets, ServiceUnavailable,
ConnectionTimeout. A 400 is never retried. Beyond that, a retriever that fails returns an
empty list: a malformed generated Cypher query is zero results, not a broken search. One flaky
modality cannot take a hybrid search down.
Retrievers are cached, keyed on the set of active modalities. If that set changes underneath the cache, the cache is dropped and the caller gets a 409 telling it to re-ingest, rather than quietly searching stores that no longer match what was indexed.
Opt in per ingest with enable_sync: true and a caller-chosen source_id.
flowchart TB
A["initial ingest completes"] -->|then| B["<b>seed DocumentState rows</b><br/>one per document, hash of raw bytes"]
B -->|then| C["start the updater"]
C --> SCAN["periodic scan<br/><i>every refresh interval, floor 30s</i>"]
C --> WATCH["live watcher<br/><i>connectors that support one</i>"]
SCAN --> U["<b>unchanged</b><br/>content never fetched"]
SCAN --> CH["<b>changed</b><br/>re-ingest"]
SCAN --> N["<b>new</b><br/>scan only if no live watcher"]
SCAN --> DEL["<b>deleted</b><br/>always the scan"]
WATCH -->|handles new items| N
The ordering matters: state rows are seeded after the initial ingest and before the watcher starts, so the first scan does not treat every document as new.
New items are handled by exactly one mechanism — if the connector pushes live creation
events, the periodic scan ignores its new bucket, so nothing is ingested twice. Deletions
always come from the scan, even for live-event sources, as a safety net against a lossy stream;
a duplicate delete is a no-op because the state row is already gone.
The scan is cheap by design: if the modification time has not advanced, the item is unchanged and its content is never fetched. Only a moved timestamp triggers a fetch-and-hash comparison, and the hash is taken over raw bytes rather than parsed text, so classification stays stable across parser changes.
Anything changed or newly seen routes through the same indexing path used by first ingest, so a change lands identically in every configured store. A source type that cannot report stable ids and modification times is rejected at ingest time rather than mis-tracked later.
| Method | Path | Purpose |
|---|---|---|
GET |
/health |
liveness |
POST |
/ingest/files |
start an ingest, optionally registering it for sync |
POST |
/ingest/text |
index a block of text directly |
POST |
/ingest/upload |
stage uploads, report accepted and skipped |
GET |
/ingest/status/{id} |
poll a run: stages completed, stores updated |
GET |
/search?q=&top_k= |
hybrid search, fused results |
POST |
/query |
hybrid search plus LLM answer synthesis |
GET |
/sync/sources |
registered sources and their running state |
POST |
/sync/sources/{id}/sync-now, /sync/sync-now |
force a scan |
POST |
/sync/sources/{id}/enable, /disable |
toggle without dropping stored config |
Routers translate request models into facade calls and facade exceptions into status codes — 503 when no modality is available, 409 when the retriever set went stale, 502 when answer synthesis fails, 400 for a rejected argument. Nothing in the HTTP layer decides anything.
polyrag-mcp is a separate process exposing the same capabilities as MCP tools, so any
MCP-speaking client can drive the backend.
| Tool | |
|---|---|
check_backend_health |
is the backend up |
start_document_ingest |
ingest from a source, returns a processing id |
ingest_raw_text |
index text directly |
check_ingest_status |
poll a run |
hybrid_search |
fused search results |
answer_question |
search plus LLM answer |
run_sample_check |
end-to-end self-test |
describe_configuration |
which backends are active |
It holds no business logic. Each tool normalises its arguments, makes exactly one HTTP request against the REST API, and returns a dict envelope — a fresh client per call, no pooling, no retry, so one tool invocation is one request and a failure is reported rather than silently repeated.
Two accommodations for the fact that MCP clients differ in what they can send: paths accepts a
list, a JSON array string, a comma-separated string, or one bare path; source_config accepts
an object or a JSON string. Invalid JSON fails before any HTTP call and returns the same keys
a successful ingest would, so a client never has to parse two response shapes.
polyrag-mcp # stdio, against localhost:8000
polyrag-mcp --transport streamable-http # networked
polyrag-mcp --backend-url http://elsewhere:8000Transports are stdio (a local pipe for desktop clients), sse and streamable-http. Setting
MCP_AUTH_ENABLED puts the networked transports behind a bearer JWT verified against an
identity provider's published keys, with optional issuer and audience checks. Every rejection
returns the same answer, with the reason going to the operator's log rather than the caller.
This is a separate layer from any credential inside a per-call source config — that one is how
the backend reaches a document source, and the adapter never inspects it.
ui/ is a Vite + React + TypeScript single-page app with four tabs: Sources, Processing,
Hybrid Search, and AI Chat.
All cross-tab state lives in the top-level component rather than inside a tab, because panels unmount when they are not active. Typed form input, an unsent chat draft, search results and the chat transcript all survive tab switches.
- Sources offers a grouped source-type picker; each form owns its validity rules behind one shared interface. "Configure Processing" unlocks once the active form validates and freezes a snapshot of the config, so later edits to the live form do not change what a configured run will do.
- Processing shows one row per item, with a skip-graph toggle always available and a sync toggle only for source types that can detect later changes. Status polling reschedules itself after each response rather than firing on a fixed interval, so a slow backend produces one in-flight request instead of a queue; it stops on a terminal status, on cancel, and on unmount.
- Hybrid Search runs either plain fused search or an LLM-answered query.
- AI Chat reuses the same one-shot question-answering call.
cd ui
npm install
npm run dev # http://localhost:5173, against http://localhost:8000
npm testOne environment variable per category. env.example is the canonical list.
| Category | Variable | Options | The contrast |
|---|---|---|---|
| Vector | VECTOR_BACKEND |
qdrant · chroma |
networked service vs. embedded directory; distances vs. scores |
| Full-text | FULLTEXT_BACKEND |
elasticsearch · sqlite_fts |
a cluster vs. one local file on the standard library alone |
| Property graph | GRAPH_BACKEND |
neo4j · kuzu |
networked Bolt vs. embedded, with typed columns instead of schemaless property maps |
| RDF | RDF_BACKEND |
sparql · file |
triples held by a shared server vs. an in-process graph serialised to disk |
| LLM | LLM_BACKEND |
openrouter · anthropic |
OpenAI-compatible chat completions vs. a top-level system field and typed content blocks |
| Source | SOURCE_TYPE |
filesystem · web |
a source that supports incremental sync vs. one that cannot |
Each second backend was chosen to exercise a genuinely different integration pattern rather than
a second instance of the same one. Retrieval modalities can also be switched off individually
with ENABLE_VECTOR_STORE, ENABLE_FULLTEXT_STORE and ENABLE_GRAPH_STORE; at least one must
stay enabled.
docker-compose.yml always runs the three application services — backend, MCP adapter and UI —
and puts every database behind its own profile, so each is toggled independently:
docker compose up # embedded backends, no database services
docker compose --profile qdrant --profile neo4j up
COMPOSE_PROFILES=qdrant,elasticsearch docker compose upWith no profiles this is still a complete working stack, because every backend category has an
embedded option: deploy/env.container selects Chroma, SQLite FTS5 and Kuzu, none of which need
a service. That file is the container layer over env.example's defaults — service names
instead of localhost, container paths instead of ./data.
deploy/smoke.sh [profiles] # up, health-check every service, down
python -m deploy.smoke --profiles qdrant # check an already-running stackThe checker reports on every service rather than stopping at the first failure, and retries each one, since compose starts services in parallel and the first probe usually arrives too early.
.venv/bin/pytest # Python suite
cd ui && npx vitest run # UI suite
cd ui && npx tsc -b && npm run build # typecheck, then production build
docker compose -f docker-compose.dev.yml up -d # qdrant, elasticsearch, neo4j, oxigraphdocker-compose.dev.yml brings up the databases the container-backed tests exercise; those
tests skip cleanly when Docker is unavailable, so the suite runs either way.