Linuxknowledge-base

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.

Distrosdebian, ubuntu, opensuse
Shelljavascript
Updated
Script
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 with wiki_path, heading, text and vector) and a .canonical-revision file holding the commit the index was built from.
  • Node.js 20 or newer (for built-in fetch and AbortSignal.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

bash

# 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

ini

[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
Why no PrivateTmp / ProtectSystem / DynamicUser here

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-index and /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, mode 0600 — 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-text needs search_query: on questions and search_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 takes input instead of prompt and accepts truncate, 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 .git directly. Running git as a different user than the repository owner trips its dubious-ownership check, and adding a safe.directory exception gives the service a capability it should not have. Reading HEAD, then the branch ref, then packed-refs covers 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 from packed-refs, correctly reported stale: true when the two diverged, returned null rather 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 returned 401, which confirms the request shape but not the answer. It stays tested: false until it has run against your own live index and key.