RAG knowledge base
pacod-kb
Turn files and web pages into versioned, searchable knowledge — with a stable contract for the models that read it.
pacod-kb ingests files and web pages, converts them to canonical Markdown, indexes them for hybrid semantic and keyword search, keeps every version, and hands downstream models chunks with a stable metadata contract. It runs on CPU alone.
- Apache-2.0
- Version 1.0.0
- Stable
Features
-
Ingest anything
PDF, DOCX, HTML, Markdown, text and JSON — uploaded, dropped into a folder, fetched from a URL or watched for changes.
-
Canonical Markdown
Structure-aware headings for legal texts in eight languages, papers and articles; boilerplate filtered out.
-
Content security
A tiered prompt-injection and spam scan blocks or quarantines documents before they are indexed; results are fenced as untrusted data.
-
Hybrid search
BGE-M3 dense vectors in Qdrant plus BM25 keywords, fused by reciprocal rank fusion.
-
Every version kept
Superseded versions stay searchable with point-in-time queries; a git archive records every change.
-
Calibrated relevance
An optional cross-encoder reranker gives a calibrated 0–1 relevance score.
-
CPU only
Embedding, OCR and search stay responsive on CPU alone, even while documents are being ingested.
-
UI and CLI
A web UI and a CLI for documents, projects, jobs, AI usage and cost, and members with viewer, editor and admin roles.
Where it fits
Why it exists
Most of the work in retrieval-augmented generation happens before a model reads a word: getting documents in, keeping them clean, finding the right passage, and telling the model where it came from. pacod-kb is that part, built as a service of its own.
Three demands shaped it. Answers must be citable, so every chunk carries its heading path and byte offsets into a stable Markdown body. Sources change, so nothing is overwritten: a new version supersedes the old one, which stays searchable by date. And ingested text is untrusted input to a model, so it is scanned for prompt injection before indexing and fenced as data on the way out.
From documents to answers
A document takes the same path whether it was uploaded, dropped into the dropzone folder, fetched from a URL, or re-fetched from a watched web source:
- Acquire. The SHA-256 of the source bytes is the document id, so the same file is never ingested twice.
- Parse. PDF (docling, with optional OCR for scanned pages), DOCX, HTML, Markdown, text and JSON become Markdown with YAML front matter.
- Check the language. English, German, Bulgarian, Russian, Serbian, Macedonian, Croatian and Slovenian are accepted; others are refused.
- Scan the deterministic parse for prompt injection and spam, before any LLM sees the text.
- Convert (optional). An LLM pass through pacod-gw tidies the Markdown. A cut-off, refused or empty answer keeps the deterministic parse.
- Clean and structure. A noise filter strips page numbers, running headers and boilerplate; heading promotion recognises the structure of legal texts, papers and articles.
- Store
document.md, a manifest and a snapshot of the source. - Index. Heading-aware chunks of up to 500 tokens are embedded with BGE-M3 into Qdrant and written to a keyword index, in the project's own collection.
Architecture
pacod-kb is two services, kb-pipeline and kb-ui, plus the model and storage they use:
| Part | Role |
|---|---|
Pipeline API (kb-pipeline, :8000) |
Ingest, index, search, jobs, settings; the only part that calls the AI gateway |
Web UI (kb-ui, :8080) |
Sign-in through pacod-id, sessions, members, the pages; calls the pipeline with an API key |
| BGE-M3 | Embeddings, in the pipeline's own process or through a shared embeddings service |
| Qdrant | Dense vectors, embedded in the pipeline or as a server, which other products can share |
| SQLite FTS5 or PostgreSQL | The BM25 keyword index |
| Filesystem or PostgreSQL | Documents, manifests and the ledger of AI usage and cost |
The split is deliberate. The pipeline parses untrusted documents and fetches arbitrary URLs, so
it receives none of pacod-id's secrets; the UI never sees the gateway key. All containers join one
shared Docker network, pacod-net, so the separation rests on secrets, not networks: each
container gets an explicit list of environment variables, and a test checks it.
Every call from the UI carries X-KB-Actor, the signed-in person's id. The pipeline trusts it
only together with the API key, records it on jobs, settings changes and usage, and passes it to
the gateway. When pacod-id deletes an account, kb replaces that id with deleted-user everywhere
before confirming.
Security model
Content security. The scan normalises text to defeat obfuscation, matches a multilingual rule set, and returns one of three verdicts:
- block: a single high-severity injection or obfuscation finding, or a high threat score. The ingest fails and nothing is stored.
- quarantine: the document is kept with status
reviewand never indexed. Spam alone never leads to a block. - clean: the document is indexed.
An optional LLM second opinion through the gateway is asked only when the rules did not block; it
has 15 seconds and fails open. Known-safe material can skip the scan with kb ingest --trusted.
On the way out, /query wraps each chunk in an untrusted-data fence.
Fetching. WEB_FETCH_ALLOW_PRIVATE=false, recommended in production, refuses URL ingests that
resolve to private addresses: a guard against server-side request forgery.
People. The UI signs people in through pacod-id with OIDC and PKCE and keeps the tokens in an encrypted server-side session; the cookie carries only a random secret. Roles come from pacod-id's authorization feed. If the UI's copy is more than 60 seconds old, pages answer 503 rather than guess who may do what.
| Role | Can |
|---|---|
| viewer | Read and search |
| editor | Ingest, re-index and delete documents; manage projects |
| admin | AI Management, prompts, runtime switches, members |
Search, measured
A query runs against Qdrant and the keyword index, and the two rankings are fused with reciprocal
rank fusion (k = 60). Date and history filters apply inside both searches. An optional
cross-encoder, BAAI/bge-reranker-v2-m3, reorders the best candidates and gives a 0–1 relevance
score that is comparable across queries, so min_score can drop non-answers.
The defaults come from an evaluation harness: 14 documents in six languages (GDPR, the Law Enforcement Directive, four constitutions, Wikipedia articles) and 92 hand-written queries — paraphrases, exact terms, cross-lingual, native-language and unanswerable. Measured on a development machine's CPU, which the docs stress are not production numbers:
- Without the reranker, the right section is in the top five for 98.7 % of answerable queries; R@1 is 0.83, at about 50 ms.
- With the reranker, R@1 rises to 0.91, at about 2 s per query.
- Under heavy ingest load, a search used to wait about 24 s for a whole embedding batch. Queries now go ahead of ingest work and wait for one small micro-batch at most: about 0.4 s (p50) with ingest jobs run one after another, as the pipeline runs them.
- Length-sorted batches make ingest embedding about 2.2× faster, with unchanged vectors.
The reranker is off by default: its gain fell just short of the bar on the held-out queries, and its latency has to be confirmed on the production CPU.
What a model receives
POST /query returns chunks under a stable, documented metadata contract:
{"query": "maximum fine for a data breach", "top_k": 5, "as_of": "2025-12-30"}
The response is {"results": [...], "reranked": false, "score_kind": "rrf"}. Each result
carries:
| Field | What it is for |
|---|---|
chunk_id |
A stable id that encodes the heading structure |
source_id |
The document id: SHA-256 of the source bytes |
heading_path |
A breadcrumb such as ["Chapter 1", "§ 5"], for citations and section filters |
byte_start, byte_end |
UTF-8 offsets into the stored Markdown body |
valid_until |
When this version was superseded, or null while current |
text |
The embedded text, fenced as untrusted data |
score, rrf_score, rerank_score |
The ordering score and its parts |
Each document also has a manifest: title, source, document type, status, project, the parser or
model that produced the body, and a lifecycle log (ingested, superseded, security_flagged,
llm_usage and more).
Versions, not overwrites
A new version ingested with --supersedes, or a watched source that has changed, marks the old
one with valid_until — in the front matter, the manifest, the keyword index and the vector
payloads at once. Searches return current chunks by default; as_of returns what was valid on a
given day; include_historical returns everything. If a watched page reverts, the earlier version
is reinstated. Supersessions and rebuilds reuse the vectors of unchanged chunks.
Running it
kb follows the pacod shared-network convention: every container joins one Docker network,
pacod-net. docker-compose.yml holds kb's own services, kb-pipeline and kb-ui.
docker-compose.standalone.yml holds kb's copies of the shared components, switched on by Compose
profiles: Qdrant, PostgreSQL, pacod-id with its database next to kb's, a mail catcher, and a
development gateway whose mock providers keep everything on the machine. make up loads both
files. Only one copy of each shared component can run on a machine. In production you point kb
at a real pacod-gw, put pacod-id behind TLS, and drop the development profiles.
pacod-demo, live at demo.pacod.io, runs kb together with the other pacod projects. It loads only kb's app file: kb keeps its database on the shared PostgreSQL and its collections on the shared Qdrant, embeds through the shared embeddings service, and serves its UI behind the shared Caddy edge.
- Storage.
KB_STORAGE_BACKENDisfilesystemordatabase; switching does not migrate documents. - Data. The data directories are variables (
KB_ROOT,KB_MODEL_CACHE,KB_QDRANT_DATA,KB_POSTGRES_DATA). A name without a slash is a Docker volume;make setupuses volumes on macOS. - CPU.
TORCH_NUM_THREADS=0follows the container's CPU quota.EMBED_BATCH_TOKENS(1024) bounds how long a query can wait behind an ingest. - Offline models. Run once with network access to fill the model cache, then set
HF_HUB_OFFLINE=1. - Shared models.
EMBEDDINGS_URLembeds through the shared embeddings service instead of loading BGE-M3. The vectors are the same, so nothing is re-embedded, and the pipeline needs about 2 GB less memory.RERANKER_URLdoes the same for the reranker. - Shared Qdrant. On a Qdrant that other products use, the UI's Qdrant pages show and touch only
kb's collections (
KB_QDRANT_COLLECTION_PREFIX, defaultkb_). - Runtime switches. The dashboard turns the content scan, AI conversion and language validation on and off without a restart; environment variables win at the next start.
- AI. Admins choose models and edit prompts in AI Management. Every call carries opaque attribution (feature, job, project, person), and kb records the tokens with the gateway's cost. With no gateway configured, AI is off and nothing else breaks.
Backups cover KB_ROOT or kb's PostgreSQL database, the Qdrant storage (or rebuild it with
kb rebuild --all-projects), pacod-id's database on the same PostgreSQL with its key-encryption
key, and .env.
Limits and further reading
- The pipeline and the UI each run as a single worker: embedded Qdrant holds a file lock, and the UI's session handling assumes one process.
- A reranker in the pipeline's own process roughly doubles its memory; with
RERANKER_URLthe pipeline loads none. - Serbian is accepted, but the language detector has no Serbian profile, so Serbian text may be misdetected or rejected.
- Documented for the next reindex, not shipped: BGE-M3 sparse vectors for the keyword half, chunker clean-ups, and rebuilding into a shadow collection before swapping.
To go deeper:
- Architecture: containers, sign-in, the gateway, who acted.
- Metadata contract: every chunk and manifest field.
- Retrieval evaluation: the full numbers and the decisions behind the defaults.
- Runbook: development, production, operations and troubleshooting.
- Migrations: upgrading an
existing installation, including the move to
pacod-net.
Tech stack
- Written in
- Python
- Tech stack
- FastAPI
- Qdrant
- BGE-M3
- BM25
- SQLite
- PostgreSQL
- HTMX
- Docker
Quick start
make setupmake upopen http://localhost:8080/kbuv syncmake checkmake evalWorks with
-
pacod-id
People sign in through pacod-id; viewer, editor and admin roles are granted there.
-
pacod-gw
The optional LLM clean-up pass goes through the gateway, which prices and attributes every call.
-
pacod-ap
Run alongside pacod-ap, kb can embed and rerank through its shared embeddings service, with the same BGE-M3 vectors.
Licence
Apache-2.0