RAG over private wiki with citations
Notion → pgvector → Claude with strict citation schema.
Q: How do I rotate a secret without restarting the loop? A: Call `locker rotate <name>` — running loops pick up the new value on their next tick via the signed refresh channel.
Off-the-shelf wiki search returns whole pages. Claude without retrieval hallucinates policies that don't exist.
Ask any question in Claude Desktop. Get an answer where every sentence is followed by `[chunk:42]` linking back to the source paragraph in Notion.
Ingredients & skills
- ANTHROPIC_API_KEY
- NOTION_TOKEN
- DATABASE_URL
- OPENAI_API_KEY
- Anthropic
- Notion
- Postgres + pgvector
- OpenAI embeddings
- notion-mcp
- postgres-mcp
How it works
Sync a Notion workspace into pgvector, expose `retrieve_chunks` as an MCP tool, and force Claude to cite every claim with a chunk id.
1 — Schema
One table. Cosine distance index. 1536 dims for `text-embedding-3-small`.
create extension if not exists vector;
create table chunks (
id bigserial primary key,
notion_page text not null,
body text not null,
embedding vector(1536) not null
);
create index on chunks using ivfflat (embedding vector_cosine_ops) with (lists = 100);2 — Sync + embed
Chunk on heading breaks (~600 tokens). Embed in batches of 100.
for (const page of await listNotionPages()) {
const chunks = splitByHeading(page.body, 600);
const embeddings = await openai.embeddings.create({ model: "text-embedding-3-small", input: chunks });
await db.query("insert into chunks (notion_page, body, embedding) select $1, unnest($2::text[]), unnest($3::vector[])", [page.id, chunks, embeddings.data.map((d) => d.embedding)]);
}3 — Expose retrieve_chunks via MCP
Claude Desktop calls this tool whenever it needs grounding.
server.tool("retrieve_chunks", { query: z.string(), k: z.number().default(6) }, async ({ query, k }) => {
const [e] = (await openai.embeddings.create({ model: "text-embedding-3-small", input: [query] })).data;
const { rows } = await db.query("select id, body from chunks order by embedding <=> $1::vector limit $2", [e.embedding, k]);
return { content: [{ type: "text", text: rows.map((r) => `[chunk:${r.id}] ${r.body}`).join("\n\n") }] };
});4 — System prompt for citations
Hard rule: every sentence ends with a `[chunk:N]` tag or you didn't say it.
You answer ONLY from the chunks retrieved.
Every sentence MUST end with [chunk:N] where N is the source chunk id.
If no chunk supports a claim, say "I don't know."The button above runs the same command with your saved config. This is the raw CLI form.
locker deploy wiki-rag --bind notion-mcp --bind postgres-mcp