RAG for Your Homelab Wiki: Answers That Cite Their Source

Turn your homelab wiki into a cited Q&A endpoint with dependency-free Node: retrieve-then-generate, every answer naming its source page, stale ones labelled.

On this page
  1. What retrieval already gave me, and what was missing
  2. The three parts worth getting right
  3. The honest arithmetic on running it locally
  4. The label is the feature
  5. Read-only by construction, not by good intentions
  6. What it feels like to use

I have written a lot of notes about my homelab, and for a long time the only way to use them was to remember they existed. Semantic search fixed the finding problem — but I still had to read four pages to answer one question. This post adds the last mile: a small service that reads those pages for me and answers in a sentence, with the sources listed underneath so I can check its work.

The principle

An answer without a source is a rumour. Retrieval picks the passages, the model writes the prose, and the response carries the page names and the exact revision it all came from. If that revision is behind your wiki, the answer says so — out of date is fine, out of date and quiet is not.

First: make these values your own

Every specific value below is a stand-in. Swap 10.0.0.65/.66/.67 for your own Ollama node addresses (one host is fine — point all three at it), 10.0.0.76 for whatever machine runs this service, 9113 for any free port, /wiki-data/git and /wiki-data/embeddings-index for your own repository and index paths, and YOUR_API_KEY for a real key kept in your secret store — never pasted into a note. Rule of thumb: if a value looks specific to one machine, it is a placeholder to change, not a literal to copy.


What retrieval already gave me, and what was missing

The local embeddings index from earlier in this series turns every section of every page into a vector — a list of numbers that captures roughly what the passage means, so a search for “boot repair” finds the page titled “Recover GRUB after a failed EFI update” even though they share no words. Ask it a question and you get back a ranked list of sections.

That is the retrieval half of retrieval-augmented generation, the technique introduced by Lewis et al. (2020) — pairing a language model with a searchable index instead of relying on what the model memorised during training. The generation half is what I was doing by hand: reading the top few hits and synthesising an answer.

So the whole job is a short pipeline. Embed the question, rank the sections, hand the winners to a model with strict instructions, and label the result.

Retrieve, then generate — and label what came outembed the questionsearch_query: prefixsame model as the docsrank in memorycosine over the indexkeep the section textgenerateanswer ONLY from thesecite excerpts as [n]labelindex vs live revisionstale = say sowhat the caller gets backa short answer with [1] [2] markers+ the page and heading behind each onethe part people skipindex revision · live revision · stale?a lagging index that admits it is harmless

The service that does this is about 250 lines of Node with no dependencies at all — the standard library has an HTTP server and fetch, and everything else is arithmetic. It runs beside the index it reads, on the same small container that hosts the wiki.


The three parts worth getting right

1Embed the question the same way you embedded the pages10 min

This is the step that quietly breaks things. nomic-embed-text — the model doing the embedding — expects a short task prefix on every string, and the prefix differs by job: Nomic’s documentation defines search_query as “a query for retrieval” and search_document as “a document for retrieval,” and tells you to embed documents one way and user queries the other.

Get this backwards and nothing errors. You just get slightly worse results forever, which is a much harder bug to notice than a crash.

Embed a question, on any Ollama node

curl -s http://10.0.0.65:11434/api/embed -d '{
"model": "nomic-embed-text",
"input": "search_query: how do I recover a locked keyring",
"truncate": true
}'

Note the endpoint: Ollama’s API docs mark the older /api/embeddings as superseded by /api/embed, which takes input (a string or a list) rather than prompt, and a truncate flag that trims over-long input to fit the model’s context instead of failing. If you built your index against the old endpoint, as I did, this is a free upgrade.

I ask each of my three Ollama nodes in turn and take the first one that answers. Embedding a question is a tiny amount of work, so this is about surviving one node being down for updates, not about throughput.

2Rank in memory — and keep the text15 min

My existing search tool returned page names and scores, which is all you need to find something. To answer from it you need the words themselves, so this service loads the index into memory and does the ranking in-process instead of shelling out.

That turns out to be the cheap part. Cosine similarity is a dot product and two lengths; over roughly 1,100 section chunks it is not something you optimise. The whole index sits in about 50 MB of RAM, and embedding plus ranking a question takes around four tenths of a second, nearly all of it the network round trip to the embedding model.

The index reloads itself when the revision file next to it changes, so a rebuild is picked up without a restart.

Make follow-up questions work

“And which node has the most RAM?” is meaningless on its own — embed those seven words and you retrieve noise. Before embedding, I prepend the previous user message to the current one. The conversation still gets sent to the model in full; this is only about giving the search enough context to find the right pages.

3Let the model write, but not invent20 min

The excerpts go into the prompt numbered, each with its page path and heading, and the system prompt is deliberately narrow: answer only from the numbered excerpts, cite them inline as [1], [2], say plainly when they do not contain the answer and name the closest one instead — and never repeat anything that looks like a secret, even if an excerpt contains it.

That last clause is not paranoia about the model. It is a second pair of hands on a rule I already enforce when writing the notes, and rules worth having are worth having twice.

The generation call — hosted path

curl -s https://api.anthropic.com/v1/messages \
-H 'x-api-key: YOUR_API_KEY' \
-H 'anthropic-version: 2023-06-01' \
-H 'content-type: application/json' \
-d '{
  "model": "claude-haiku-4-5",
  "max_tokens": 1024,
  "system": "Answer ONLY from the numbered excerpts. Cite them as [1], [2].",
  "messages": [{"role": "user", "content": "<excerpts>\n\nQuestion: ..."}]
}'

Four things carry the request and they are all visible above: the key goes in x-api-key, the API version is pinned in a header, the instructions ride in a top-level system field rather than as a fake first message, and max_tokens caps the reply. That shape is straight from Anthropic’s Messages API reference; a small, fast model is the right tool here because the hard thinking was already done by retrieval — what is left is summarising six passages faithfully.


The honest arithmetic on running it locally

I wanted this fully local. I measured it instead of assuming, and the numbers made the decision for me.

Same retrieval, very different generation — measured on my hardwarehosted model≈ 3.6 s total, 6 excerptslocal model, CPU only≈ 138 s — and only 1 excerptteal = embed + rank (local either way, about 0.4 s) · coloured = generationbars are compressed, not to scale — the real gap is roughly 40×your documents never leave the LAN in either case — only the excerpts chosen for one question do

Roughly three and a half seconds against a hundred and thirty-eight, and the slow one had to be handed a single excerpt rather than six to fit its context window at all. My nodes have no GPU, and reading a long prompt is exactly the work CPUs are worst at. So hosted became the default and local stayed as a per-request switch — genuinely useful when a question touches something I would rather not send anywhere, and worth re-testing the day I put a GPU in the rack.

Worth being clear about what does and does not leave the network: retrieval is local in both modes. Even on the hosted path, what goes out is the question and the handful of passages picked for it — not the wiki.

A service holding an API key is a service worth fencing in

The moment you add a hosted backend, that little endpoint can spend money. Mine listens on the LAN only and is deliberately not published through my tunnel, because anyone who can reach it can spend my tokens. If you expose one, put authentication in front of it first, and watch what it actually costs.


The label is the feature

Everything above is standard. This is the part I would fight for.

The search index is derived — built from the wiki, never the other way round. It can therefore be behind. Keeping derived copies honest is a running theme in this series, and the rule is always the same: a derived thing must know, and report, which revision it represents.

So every response carries three fields: the revision the index was built from, the live revision of the wiki right now, and whether they differ. When they differ, that fact is also injected into the model’s instructions, so the answer itself opens by warning you rather than leaving it to a badge you might not look at.

Health check — the same three fields, before you even ask anything

curl -s http://10.0.0.76:9113/health

# {
#   "ok": true,
#   "revision":       "64dc195...",   <- what the index knows
#   "canonical_head": "64dc195...",   <- what the wiki actually is
#   "stale": false,
#   "chunks": 1114, "pages": 165
# }

One wrinkle worth passing on. Reading the live revision by shelling out to git did not work: the service runs as its own unprivileged user, and Git refuses to operate on a repository owned by somebody else, failing with fatal: detected dubious ownership in repository at '<path>' unless you add a safe.directory exception. Rather than widen that (Git’s own documentation explains it exists to stop a repository you do not control from executing configuration you did not write), I read the ref files directly — HEAD, then the branch ref, then packed-refs as a fallback. A dozen lines, no shell, no exception, and the reader never gains a capability it does not need.


Read-only by construction, not by good intentions

This thing sits next to my knowledge base with a language model attached. The safest version of that is one that cannot write, so there is no write path, no rebuild trigger, and no code that mutates the repository at all — the only file operations in it are reads.

Then the ordinary hardening: no new privileges, no capabilities, a memory ceiling, and validation on everything a caller can send (a length cap on the question, a cap on how much history it will accept, a bounded result count, and a pattern check on the topic filter, with oversized request bodies dropped rather than buffered).

A container gotcha that cost me an afternoon

My first unit used systemd’s filesystem sandboxing — DynamicUser, ProtectSystem, PrivateTmp — and refused to start. All three work by building a private mount namespace for the service (systemd’s manual describes PrivateTmp as giving the unit “a private mount namespace”), and an unprivileged container that is not allowed to nest cannot create one. Nothing was misconfigured; the sandbox simply had nowhere to stand. I dropped back to a plain system user plus the non-namespace protections and let the container itself be the outer sandbox — which, on Proxmox, it already was. Worth knowing before you assume a hardened unit file will port into a container unchanged.


What it feels like to use

The honest test is not a benchmark, it is the Sunday-evening question you would otherwise answer by opening four tabs. Mine tend to be things like “what was the deal with the NAS double hop again?” — and the useful part of the reply is not the paragraph, it is the two page names underneath it, because that is where I go next.

I put the same endpoint behind a tab in my homelab dashboard app, where the citations render as chips you can tap to see the passage, and the staleness flag renders as a small amber badge on the answer. Same three fields, just wearing a UI.

If you already have the index, this is an afternoon. The paired playbook — A cited RAG endpoint over your Markdown wiki — has the whole retrieve-then-generate core in one copy-paste file. And if you have not built the index yet, start there instead: the retrieval half is the half that decides whether any of this is worth reading, and no amount of clever prompting rescues a search that returned the wrong pages.


Related posts:

Comments

Comments are powered by GitHub Discussions — sign in with a GitHub account to join the conversation.