Vit Busek
← Work

September 2026

Personal Knowledge Base

A knowledge base you can ask questions of. Every answer points back at the document it came from, and if your own notes do not answer the question, the app says so instead of guessing.

The Ask screen answering a question about sizing a power supply: the reply works from a 142 W processor figure and a 320 W card up to an 850 W supply, and under it three cited sources, each linking to a different document in the knowledge base.
Role
Solo build — retrieval, privacy, identity, infrastructure
Stack
Next.js, TypeScript, Postgres, pgvector, Drizzle, Auth.js, Keycloak, Anthropic SDK, OpenRouter, transformers.js, Docker

What it brings

Technical design

How a question becomes an answer

  1. app/api/ask/route.ts

    The session decides whose documents are searched — the request body cannot name a user. The per-user and deployment-wide spend ceilings are checked here, before anything can cost money.

  2. server/rag/retrieve.ts

    The question is embedded in the process, then three searches run: vector similarity, an exact identifier match if the question holds something shaped like a part number, and BM25 over prose. Each repeats the ownership and embedding-model filter in its own SQL rather than trusting another arm to have applied it.

  3. server/rag/fuse.ts

    Fusion only orders — every list arrives already filtered, so an empty result stays empty. Nothing found ends the request here, with “Not found in your knowledge base.” and no model call at all.

  4. server/privacy/anonymizer.ts

    One anonymizer per request replaces people, e-mail addresses and phone numbers in the question and in every retrieved chunk. The same instance does both, so a question about a person still matches a note about them.

  5. server/ai/prompts.ts

    Question and sources travel inside tags the application writes, and the system prompt’s first rule is that everything inside them is data. Text shaped like one of those tags is escaped rather than stripped — the sentence that tried it is still note content.

  6. server/ai/call.ts

    The one place where text leaves the process. It enforces a timeout and writes the audit row — model, timing, token counts, outcome — and never the content.

  7. server/rag/citations.ts

    Every cited number must index the set that was actually sent. A citation is a position, not an id, so the model is never shown one it could invent. If none survive, one stricter retry follows, and then the answer is refused.

  8. server/privacy/restoreStream.ts

    Placeholders become real names again as the stream reaches the reader. The mapping that can do that lives in the request and dies with it — never stored, never logged.

application/x-ndjson@anthropic-ai/sdkOIDC redirect

Client

  • app/ask/ask-form.tsx

Server

  • server/auth/guard.ts

  • server/rag

  • server/privacy

  • server/ai/providers

    Four implementations: anthropic, openrouter, gateway, mock.

  • server/ai/embedders/local.ts

    In-process. No text is sent anywhere to be embedded.

Data

  • Postgres

  • pgvector, HNSW cosine

    384 dimensions, cosine distance.

  • content_tsv / content_tsv_en

    Generated columns: simple for identifiers, english for prose.

  • Drizzle ORM

External

  • Keycloak (OIDC)

    The realm ships in the compose file.

  • Anthropic API

  • OpenRouter

POST /api/ask

Drizzle

  1. Citations before prose, in the wire format. A delta never precedes its citations in the NDJSON stream, so a client that ignored every other rule still cannot render unsourced text. The latency win is small — time to first token is 84–94% of the call — and that is the point: streaming prose first would look far better and would be showing text no guard had approved.
  2. Two seams, not one. Anthropic has no embeddings endpoint, so a single combined provider interface could not have been implemented by the file named after them. Answering and embedding are separate interfaces, and the embedder’s default runs inside the container — which is what keeps the text being indexed off the network entirely.
  3. Three retrieval arms rather than a lower score floor. A floor tuned downward until the failures stop is a threshold tuned by feel, and the refusal path is the one thing that must not rest on that. The identifier arm searches each term as a phrase, and the prose arm is admitted on IDF coverage and two matched terms, so an arm either has evidence or does not run.