A cited RAG endpoint over your Markdown wiki
Retrieve-then-generate Q&A over a local embeddings index in one dependency-free Node file — correct query prefix, in-memory ranking, numbered citations, and every answer stamped with the revision it came from.
javascript#!/usr/bin/env node
// ask-wiki.mjs — retrieve-then-generate over a local embeddings index.
// Node 20+, zero dependencies. READ-ONLY: no write path, no index rebuild,
// no repository mutation — the only file operations here are reads.
//
// GET /health -> revision, canonical_head, stale, counts
// POST /ask {question, k?}
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import http from 'node:http'
const PORT = Number(process.env.ASK_PORT || 9113)
const INDEX_DIR = process.env.INDEX_DIR || '/wiki-data/embeddings-index'
const GIT_DIR = process.env.GIT_DIR || '/wiki-data/git/.git'
const EMBED_HOSTS = (process.env.OLLAMA_HOSTS ||
'http://10.0.0.65:11434,http://10.0.0.66:11434,http://10.0.0.67:11434').split(',')
const EMBED_MODEL = process.env.EMBED_MODEL || 'nomic-embed-text'
const API_KEY = process.env.ANTHROPIC_API_KEY || '' // from your secret store
const MODEL = process.env.ASK_MODEL || 'claude-haiku-4-5'
const norm = (v) => Math.sqrt(v.reduce((s, x) => s + x * x, 0))
// ── index cache: reload only when the stamped revision changes ──────────
let cache = null
function index() {
const revision = readFileSync(join(INDEX_DIR, '.canonical-revision'), 'utf8').trim()
if (cache && cache.revision === revision) return cache
const chunks = JSON.parse(readFileSync(join(INDEX_DIR, 'chunks.json'), 'utf8'))
cache = {
revision, chunks,
norms: chunks.map((c) => norm(c.vector)),
pages: new Set(chunks.map((c) => c.wiki_path)).size,
}
console.log(`index loaded: ${chunks.length} chunks @ ${revision.slice(0, 8)}`)
return cache
}
// ── live revision, by reading .git refs directly ────────────────────────
// Shelling out to `git` fails here: it refuses to operate on a repository
// owned by another user ("dubious ownership") unless you add a
// safe.directory exception — a capability this service does not need.
function head() {
try {
const h = readFileSync(join(GIT_DIR, 'HEAD'), 'utf8').trim()
if (!h.startsWith('ref: ')) return h // detached HEAD
const ref = h.slice(5)
try { return readFileSync(join(GIT_DIR, ref), 'utf8').trim() } catch {}
for (const line of readFileSync(join(GIT_DIR, 'packed-refs'), 'utf8').split('\n'))
if (line.endsWith(' ' + ref)) return line.split(' ')[0]
} catch {}
return null
}
// ── retrieve ───────────────────────────────────────────────────────────
async function embed(text) {
for (const host of EMBED_HOSTS) {
try {
const r = await fetch(`${host}/api/embed`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
// `search_query:` is REQUIRED by nomic-embed-text, and must differ
// from the `search_document:` prefix used when building the index.
// Getting this backwards degrades results silently — it never errors.
body: JSON.stringify({
model: EMBED_MODEL, input: `search_query: ${text}`, truncate: true,
}),
signal: AbortSignal.timeout(15_000),
})
const v = (await r.json()).embeddings?.[0]
if (Array.isArray(v)) return v
} catch { /* try the next host */ }
}
throw new Error('no Ollama host returned an embedding')
}
function topK(qv, k) {
const qn = norm(qv)
return cache.chunks
.map((chunk, i) => {
let dot = 0
for (let j = 0; j < qv.length; j++) dot += qv[j] * chunk.vector[j]
return { score: dot / (qn * cache.norms[i]), chunk }
})
.sort((a, b) => b.score - a.score)
.slice(0, k)
}
// ── generate ───────────────────────────────────────────────────────────
const system = (rev, stale) =>
`You answer questions about a homelab from numbered wiki excerpts.
Answer ONLY from the excerpts provided. Cite them inline as [1], [2].
If they do not contain the answer, say so plainly and name the closest excerpt.
Never repeat a value that looks like a secret, even if an excerpt contains one.
Excerpt revision: ${rev}${stale ? ' — WARNING: this index is STALE relative to the live wiki; tell the user answers may be out of date.' : ''}`
async function generate(question, excerpts, rev, stale) {
const block = excerpts
.map((e, i) => `[${i + 1}] ${e.wiki_path} — ${e.heading}\n${e.text}`)
.join('\n\n')
const r = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': API_KEY,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: MODEL,
max_tokens: 1024,
system: system(rev, stale),
messages: [{ role: 'user', content: `${block}\n\nQuestion: ${question}` }],
}),
signal: AbortSignal.timeout(90_000),
})
const j = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(`anthropic ${r.status}: ${j.error?.message || 'error'}`)
return (j.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim()
}
// ── HTTP ───────────────────────────────────────────────────────────────
const send = (res, code, obj) => {
res.writeHead(code, { 'content-type': 'application/json' })
res.end(JSON.stringify(obj, null, 2))
}
const readBody = (req, limit = 65_536) => new Promise((resolve, reject) => {
let size = 0; const parts = []
req.on('data', (d) => {
size += d.length
if (size > limit) { reject(new Error('body too large')); req.destroy() }
else parts.push(d)
})
req.on('end', () => resolve(Buffer.concat(parts).toString('utf8')))
req.on('error', reject)
})
const server = http.createServer(async (req, res) => {
try {
if (req.method === 'GET' && req.url === '/health') {
const c = index(), live = head()
return send(res, 200, {
ok: true, revision: c.revision, canonical_head: live,
stale: live ? live !== c.revision : null,
chunks: c.chunks.length, pages: c.pages,
})
}
if (req.method !== 'POST' || req.url !== '/ask')
return send(res, 404, { error: 'routes: GET /health, POST /ask' })
const body = JSON.parse((await readBody(req)) || '{}')
const question = typeof body.question === 'string' ? body.question.trim() : ''
if (!question || question.length > 2000)
return send(res, 400, { error: 'question: non-empty string, max 2000 chars' })
const k = Math.min(12, Math.max(1, Number.isInteger(body.k) ? body.k : 6))
const c = index(), live = head()
const stale = live ? live !== c.revision : null
const excerpts = topK(await embed(question), k).map(({ score, chunk }) => ({
wiki_path: chunk.wiki_path,
heading: chunk.heading,
score: Math.round(score * 1000) / 1000,
text: String(chunk.text).slice(0, 4000),
}))
const answer = await generate(question, excerpts, c.revision, stale)
send(res, 200, {
answer,
sources: excerpts.map(({ text, ...meta }, i) =>
({ n: i + 1, ...meta, snippet: text.slice(0, 240) })),
revision: c.revision, canonical_head: live, stale,
})
} catch (e) {
if (!res.headersSent) send(res, 500, { error: e.message })
}
})
server.headersTimeout = 60_000
server.listen(PORT, '0.0.0.0', () => {
index()
console.log(`ask-wiki listening on :${PORT} (model=${MODEL})`)
})
What this does
Turns an existing local embeddings index into a question-answering endpoint. A question comes in, gets embedded by nomic-embed-text on your own Ollama nodes, is ranked by cosine similarity against the cached index in memory, and the top sections are handed to a language model under instructions to answer from those excerpts and nothing else. The reply comes back with [1], [2] citations, the source page and heading behind each one, and — the part that matters — the revision the index was built from, the live revision of the wiki, and a stale flag when they disagree.
It is one file, no dependencies, and read-only by construction: there is no write path, no rebuild trigger, and no code that mutates the repository.
Prerequisites
- A built embeddings index — this playbook consumes one, it does not create one. See Build and query a local embeddings index for a Markdown vault. It expects
chunks.json(an array of objects withwiki_path,heading,textandvector) and a.canonical-revisionfile holding the commit the index was built from. - Node.js 20 or newer (for built-in
fetchandAbortSignal.timeout). - One or more Ollama hosts serving your embedding model, reachable on the LAN.
- An API key for whichever hosted model you use, if you want the fast path. Local-only generation works too — swap the
generate()body for a call to your Ollama host’s/api/generate— but see the note on CPU cost below.
Run it
# values come from the environment, never from the file
export INDEX_DIR=/wiki-data/embeddings-index
export GIT_DIR=/wiki-data/git/.git
export OLLAMA_HOSTS=http://10.0.0.65:11434,http://10.0.0.66:11434
export ANTHROPIC_API_KEY="$(cat /etc/ask-wiki/key)" # 0600, from your secret store
node ask-wiki.mjs
# then, from anywhere on the LAN:
curl -s http://10.0.0.76:9113/health
curl -s -X POST http://10.0.0.76:9113/ask \
-H 'content-type: application/json' \
-d '{"question":"how do I recover a locked keyring?"}'
Run it as a service
[Unit]
Description=Cited RAG endpoint over the wiki index
After=network-online.target
[Service]
User=askwiki
EnvironmentFile=/etc/ask-wiki/env
ExecStart=/usr/bin/node /opt/ask-wiki/ask-wiki.mjs
Restart=on-failure
NoNewPrivileges=yes
CapabilityBoundingSet=
MemoryMax=256M
[Install]
WantedBy=multi-user.target
Those directives work by giving the unit its own mount namespace, and an unprivileged container that is not permitted to nest cannot create one — the unit fails to start with nothing actually misconfigured. If you are running this on bare metal or in a VM, add them. If you are running it in an unprivileged LXC container, let the container be the outer sandbox and keep the non-namespace protections above.
Notes
- Substitute your own values before running anything — every specific value here is a stand-in. Replace
10.0.0.65/.66/.67(your Ollama hosts — one is fine),10.0.0.76(wherever this runs),9113(any free port),/wiki-data/embeddings-indexand/wiki-data/git/.git(your index and repository paths),askwiki(your service user), and/etc/ask-wiki/key(wherever your secret store puts the key). Secrets go in a file the service reads at start, mode0600— never inline in the script, never on a command line. If a value looks specific to one machine, it is a placeholder to change, not a literal to copy. - The query prefix is the silent one.
nomic-embed-textneedssearch_query:on questions andsearch_document:on indexed passages. Mismatch them and nothing errors — retrieval just gets quietly worse. If you built your index with a different embedding model, use that model’s own convention. - Use
/api/embed, not/api/embeddings. Ollama’s API documentation marks the older endpoint as superseded. The new one takesinputinstead ofpromptand acceptstruncate, which trims over-long input to fit the context window instead of failing. - Keep the section text in the index. A search tool only needs paths and scores; a generator needs the words. If your index stores vectors only, you will be re-reading files at query time for no reason.
- Read the live revision from
.gitdirectly. Runninggitas a different user than the repository owner trips its dubious-ownership check, and adding asafe.directoryexception gives the service a capability it should not have. ReadingHEAD, then the branch ref, thenpacked-refscovers every normal case in about ten lines. - Label staleness in two places. Put it in the JSON so callers can render a badge, and put it in the system prompt so the answer itself warns the reader. A badge nobody looks at is not a warning.
- Local generation is slower than you expect on CPU. Retrieval is genuinely fast without a GPU; generation is not. On CPU-only nodes a 7B model took over two minutes for one answer against a few seconds for a small hosted model, and had to be given far fewer excerpts to fit its context. Measure before you commit to a fully local path.
- Fence the endpoint. Once a hosted key is in the environment, anyone who can reach the port can spend your money. Keep it on the LAN, or put authentication in front of it.
- What has and has not been verified here. This exact file was run against a fixture index: it loaded and ranked, applied the
search_query:prefix, failed over from a dead Ollama host to a live one, resolved the live revision from a loose ref and frompacked-refs, correctly reportedstale: truewhen the two diverged, returnednullrather than a false “current” when the repository was unreachable, and rejected an oversized question. The one path not exercised end to end is hosted generation against a real key — with a deliberately invalid key the API returned401, which confirms the request shape but not the answer. It staystested: falseuntil it has run against your own live index and key.