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.

- 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
- Three retrieval arms — vector, identifier and BM25 prose — combined by reciprocal rank fusion. Each lexical arm exists because the embedder measurably refused a question the corpus answers: "What are PL1 and PL2 set to?" scored 0.054 against a 0.25 floor, about a document the app had indexed.
- Citations are streamed before prose. The model’s JSON puts them first, the array is validated the moment it closes, and the first word of an answer goes out only after a source has been checked — so a rejected answer is refused while the screen still says checking sources, rather than being taken away after it has been read.
- Names, e-mail addresses and phone numbers become placeholders before anything leaves the process, and are restored on the way back. The embedder runs in-process, so the anonymised answering call is the only thing that crosses a network boundary at all.
- Four answering providers behind one interface, proved rather than asserted: the same code answered through OpenRouter against an OpenAI model using the Anthropic SDK — another company, account and model namespace — with no change to the prompt, the guard, the anonymizer or the audit record.
Technical design
How a question becomes an answer
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.
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.
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.
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.
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.
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.
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.
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.
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
- 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.
- 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.
- 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.