Self-hosted knowledge management for SMEs: a split-screen Markdown editor whose sections an LLM refines while you write, and RAG question answering over the documents that result. FastAPI + Postgres/pgvector on the back, SvelteKit on the front, everything OpenAI-compatible and self-hostable. Squashed into a single commit; the development history stays local. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
15 KiB
Data model
All primary keys are UUIDs. All tables get created_at / updated_at via a
shared mixin (omitted in the ER diagram). Enum-like columns are stored as
varchar(32) backed by Python StrEnums — no native Postgres enum types.
Naming rule: chat threads are conversations, login sessions are
auth_sessions — never mix these up.
The ER diagram lives in diagrams/data-model.svg
— one maintained source, updated with every model or migration change.
Tables (13)
(Plus alembic_version, Alembic's migration bookkeeping table.)
departments
id, name (unique)
users
id, email (unique), name, role (member|admin), password_hash (argon2), locale (varchar(5), nullable), department_id → departments
A person is described by a DOCUMENT, not by a profile column: the one started
from the person blueprint (GET /api/account/document). It is versioned,
approvable and findable by the search, which a text column on users was not.
locale pins the interface language (de | en); NULL follows the
browser's Accept-Language. One column rather than a preferences table: it
is the only preference that has to follow the person across devices. The
colour theme deliberately does NOT live here — it is per-device and sits in
the browser's localStorage.
department_id is nullable (ON DELETE SET NULL).
auth_sessions
id, user_id → users, expires_at, created_at
Server-side sessions; the row id doubles as the httpOnly cookie token
(UUIDv4). Sessions expire after 14 days (PABLAN_AUTH_SESSION_TTL_DAYS) and
cascade when the user is deleted. Instantly revocable (deleting rows logs the
user out everywhere) — important because "employee leaves the company" is
literally our core use case. No JWT.
templates
id, name, version, config (jsonb)
config holds an authoring template — a Markdown skeleton plus per-section
hints (see authoring-templates.md). Every row is the customer's own and
fully editable — there is no read-only template. The YAML files in
templates/ are a catalog of blueprints that an admin adds from; a
blueprint has no row until it is added, and a fresh instance is seeded with
four starter templates.
conversations
id, mode (query|insight), status (active|completed|abandoned), user_id → users
One generic table for all modes. The only core mode is query (RAG Q&A); EE
adds insight. Adding a mode requires no migration. Capture is no longer a
conversation — it writes a Document directly (see app/authoring/), so this
table holds no per-turn engine state, template link, or result-document link
any more (the state, template_id and result_document_id columns were
dropped). Deleting a user cascades to their conversations.
messages
id, conversation_id → conversations, role (user|assistant|system), content (text), meta (jsonb), created_at
Full history is stored: state reconstruction after restarts, debugging/eval
material, and (aggregated only) knowledge-gap reporting. Messages cascade with
their conversation. Privacy rules below.
meta holds an assistant turn's citation snapshot
({"sources": [{document_id, title, heading_path, excerpt, used}]}, {} for
user turns). Chunks are disposable derivatives that re-indexing replaces,
so the rendered citation is stored with the message rather than referenced
— reopening an old conversation still shows what was cited. used marks
whether a passage grounded the answer or was only retrieved and dropped as too
weak (a no-answer turn); the "?" context inspector shows the full retrieved set,
the cited-source badges only the used ones.
documents
id, title, status (draft|published|archived), is_builtin (bool), visibility (public|department|restricted), content_md (text), meta (jsonb), author_id → users, department_id → departments
Markdown is the source of truth. content_md holds the canonical
document; everything else (chunks, embeddings) is derived and disposable.
meta holds the summary, extracted entities, and — for a captured
document — the template config id it was created from, plus (when the
capture started from a chat) the LLM topic summary of that conversation as
context and its conversation_id, used as background for section refinement.
Capture writes here directly. A captured document is authored by the
user, starting in draft (author-only, never indexed), and becoming
published when its author says so — one action, no queue. There is no
source_type: everything in the table was written in Pablan. The column and
its upload member went with the tags removal — the day an import lands, a
provenance column earns its place again, and can be reintroduced then.
Three statuses, and only three. draft is being written, published is
readable and searchable, archived is retired. Doubt about the CONTENT is
deliberately not a status: it is a review_request (below), because a
document can be published for months and still carry an unanswered question
about a number in it — which is exactly when readers need to be told.
No tags, and no freshness date. Both were removed rather than kept
around: tags were write-only (free text, no way to know which ones existed,
too inconsistent to filter by, and hybrid search already finds things), and
verified_until marked documents without ever asking a person anything. A
review request does that job — it names someone, carries a question, and can
sit on a document published months ago. Treat a UI that displays or edits
tags, or a date that flags a document as stale, as a regression.
author_id and department_id are nullable with ON DELETE SET NULL —
documents survive their author leaving the company.
is_builtin marks the shipped help pages that describe Pablan itself
(source: help/*.md, re-imported on every start). They are published,
public and authorless, and the API refuses to edit or delete them
(409 builtin_readonly) — the next deploy would overwrite the change.
chunks
id, document_id → documents, chunk_index (int), content (text), embedding (vector(1024) — bge-m3; dimension change = migration + reindex), tsv (tsvector, stored generated column, german config over the content AND the heading path), meta (jsonb)
Chunks are slices of a document (~400 tokens, split along Markdown heading
hierarchy; each chunk stores its heading path in meta, e.g.
"Ablauf: Reklamation › Schritt für Schritt", included as context in prompts and
citations). The heading path is not decoration: it is embedded WITH the chunk
(rag/indexing.embedding_text) and included in tsv, because a section saying
"Solldruck 180 bar" never repeats which machine it belongs to and is otherwise
unreachable by the name the asker uses. What is STORED as content stays the
raw section. meta also carries denormalized filter fields (department_id,
visibility) so retrieval doesn't need triple joins. Chunks are deleted
and regenerated whenever the document or the embedding model changes, and
cascade on document deletion.
Indexes: HNSW on embedding (cosine), GIN on tsv;
(document_id, chunk_index) is unique.
doc_permissions
document_id → documents, department_id → departments, level (read)
Additional department read grants on top of documents.visibility — the
mechanism behind multi-department sharing (a document reaches departments
beyond its owning one). Managed through PUT /api/documents/{id}/departments
(author/admin), which replaces the full set of extra departments; the permission
filter's EXISTS doc_permission branch already unions them into reads and
retrieval, so sharing is management, not new permission logic. Composite PK
(document_id, department_id); both FKs CASCADE. Note the asymmetry with
documents.department_id, which is SET NULL: deleting a department orphans
its owned documents (they keep existing, department set NULL) but drops its
grant rows here, silently removing that department's shared access — an admin
lockout vector to weigh before deleting a department.
review_requests
id, document_id → documents, requester_id → users, reviewer_id → users, question (text, nullable), resolved_at (timestamptz, nullable), resolved_by_id → users, created_at, updated_at
"Please check this", addressed to one colleague, optionally about something
specific ("do the 14 holiday days still hold?"). Open while resolved_at is
NULL; the pair (resolved_at, resolved_by_id) is the answer. All three user
FKs are ON DELETE SET NULL (a request outlives the accounts on either side of
it); rows CASCADE with their document.
It is deliberately not a status, and it is orthogonal to one: a request can
sit on a draft the author is unsure about OR on a document published months
ago. An open request marks the document wherever it appears — the list, the
detail page, and the sources under a chat answer (review_pending on every
search hit).
Being asked is what grants the right to edit: a reviewer who spots a wrong
number should fix it rather than file a second question about it. The grant is
scoped to the open request and disappears with the answer — for a draft that
also means the reviewer's read access ends there. The relationship is loaded
with every document (lazy="selectin"), because whether a question is open
decides both who may edit and how the document is marked.
document_events
id, document_id → documents, actor_id → users, action, content_md, title, visibility, meta, created_at
An append-only audit trail: who changed or reviewed a document, and when.
action is one of created | edited | published | archived | visibility_changed | review_requested | review_resolved. Content-bearing events (created, edited)
snapshot the Markdown source of truth (content_md, title, meta) so a past
version can be viewed or diffed — the disposable chunks are never snapshotted
(rule 1); pure transitions carry no content snapshot. visibility is small, so
it is recorded on every event. actor_id is ON DELETE SET NULL so the trail
outlives the actor's account (like author_id); events CASCADE with their
document.
This is where "who checked it" lives: review_resolved records the
colleague who answered as actor_id, next to the review_requested that asked
— the two events are the record that someone with the knowledge looked at this,
which the review_requests row alone (one open flag) does not preserve once it
is answered. History is read through the document's own permission gate
(GET /api/documents/{id}/history), so it never leaks to a user who cannot read
the document.
llm_settings
id, role (unique), base_url, model, api_key, created_at, updated_at
Per-role endpoint overrides edited in the admin UI; at most three rows.
NULL means "inherit from the environment" (precedence env < DB, per
field — see architecture.md). api_key is plaintext because it is
replayed to the endpoint; it is never returned by the API and never
logged.
prompt_settings
id, key (unique), content, created_at, updated_at
Admin overrides for shipped system prompts. Every prompt has a CODE default
(app/prompts/defaults.py); a row exists here only when an admin has changed
one, and content is the full replacement. Applied without a restart via a
module cache (app/prompts/overrides.py), refreshed on every write — like
llm_settings, and single-process by design (--workers 1). Resetting a prompt
deletes its row so the code default takes over. Unlike LLM settings there is no
.env layer: prompts have no environment representation, so the reset target is
the code default. Keys: query_system, query_no_sources, refine_persona,
refine_rules, grounding_framing, topic_summary, title, bio_persona,
bio_rules.
jobs
id, type, payload (jsonb), status (pending|running|done|failed), run_after, attempts, last_error
Postgres-backed background queue, claimed via FOR UPDATE SKIP LOCKED.
Indexed on (status, run_after) for the claim query.
Permission model
Two layers:
documents.visibilityas the fast default:public— whole companydepartment— author's department onlyrestricted— only explicit grants
doc_permissionsfor additional department grants.
Authors always see their own documents: any status through the API (writing
needs drafts), published-only in retrieval — unpublished documents are never
searchable, not even for their author. A colleague with an open review
request sees the document the same way: an open-request clause in
readable_documents_filter but never in searchable_documents_filter, so
being asked to check a draft reaches the API without ever leaking into
chat/search. That clause is also the edit gate (can_edit = author, admin, or
open reviewer): whoever is asked may fix what they find, until they answer.
Self-lockout guard. Before a visibility or grant change is committed, the
proposed state is evaluated against the read rules (_guard_self_lockout, a
Python mirror of readable_documents_filter). An author always keeps access as
author, so this only bites an admin editing a document they do not own: the
change is refused (409 self_lockout_warning) until they resend it with
confirm_lockout, after which they may knowingly give up their own access.
The filter runs BEFORE the LLM ever sees anything. Retrieval
(rag/retrieval.search) requires a user argument and applies the
visibility CTE (visibility rules + doc_permissions + status = published)
before vector/full-text search. It is structurally impossible to retrieve a
chunk the requesting user may not read. The filter is shared with the
documents API (rag/permissions.py) and always reads the live documents
table — never the denormalized copies in chunk meta, which can be stale
between an edit and the next reindex.
Retrieval (hybrid)
Sequence: diagrams/retrieval-sequence.svg
One SQL query: visibility CTE → in parallel (a) vector top-20 via
embedding <=> :qvec (HNSW) and (b) full-text top-20 via
tsv @@ websearch_to_tsquery('german', :q) → merged with Reciprocal Rank
Fusion → top-k. Rationale: pure vector search underperforms on German
technical terms, product names, and error codes; hybrid is the default.
The full-text half also works alone. chunks.tsv is a stored generated
column (to_tsvector('german', content || ' ' || heading path)) behind a GIN
index — an inverted
index that needs no model at all. text_search() uses exactly that, with the
same permission CTE, and query mode falls back to it when the embedding
endpoint is unreachable. Keyword matching finds less than the hybrid path, so
the user is told (fallback in api-protocol.md), but the knowledge base
stays searchable when nothing is running behind the LLM config.
Privacy / GDPR defaults
- Deleting a user (offboarding) deletes their conversations and messages
via
ON DELETE CASCADE— the transcript is personal data of the departed employee; the published document is the legitimate artifact and survives authorless (author_idSET NULL). - Users can delete their own conversations.
- Configurable retention: query-mode conversations are auto-deleted after a
default of 90 days (
retention_cleanupjob). - A captured document is private until its author publishes it
(
draft → published, the author's own action) — nothing enters the knowledge base without the writer's consent. - Insights features (EE) only ever receive aggregated, anonymized data — never per-user raw messages.