Skip to content

Guide — using Pinakes

How to build, feed, search and share a knowledge base. Every command here was re-run against 0.28.1 (20260823, 02:26–02:39 UTC) — the build current when the pass ran, published as 0.28.2, which changed no code path — and the output shown is what it printed, with three exceptions named where they appear: the paid --deep run in Paying for an answer is a real transcript from 0.24.0 kept rather than re-bought, and the two-KB walkthrough in Following links keeps its original ULIDs, since re-running it would change every identifier in the section without making a single sentence truer. The previous stamp said 0.2.0 (20260728), twenty-six releases back — it had been wrong on the published site for most of this project's life, and nine output blocks and four prose claims below had drifted with it.

For the flag-by-flag reference see CLI.md; for every manifest and sidecar field see MANIFEST.md; for whether a feature exists yet see STATUS.md.


Install

uv add "pinakes[st]"          # default backend
uv add "pinakes[light]"       # ONNX, no torch
uv add "pinakes[light,pdf]"   # + PDF ingest

⚠️ uv add needs a pyproject.toml, and a knowledge base directory does not have one — it exits No pyproject.toml found. That is where a new user is standing, so start with one of these instead:

uv init && uv add "pinakes[light]"    # make the directory a uv project first
uvx --from "pinakes[light]" pnk      # no project, no install, run it directly

Python 3.13+. To try it without installing anything:

uvx --from "pinakes[light]" pnk --version

To install unreleased work from main — a contributor, or anything listed under [Unreleased] in the CHANGELOG, which is where work that has landed but not shipped is recorded:

uv add "pinakes[light] @ git+https://github.com/lucagattoni/pinakes"
Extra Pulls Gives you
(none) Parsing, FTS5, storage, MCP, CLI. Cannot embed — a supported state, not a broken one
[st] sentence-transformers (~2 GB, torch) Default backend; widest model choice
[light] fastembed (~100 MB, ONNX) Same default models, no torch
[pdf] pypdfium2 Free PDF text extraction
[claude] Anthropic SDK — requires [pdf] The opt-in paid extractor (shipped in 0.3.0)

Extras compose: pinakes[light,pdf] is a normal install. A core-only install fails with the exact extra to add, rather than a traceback:

error: the `sentence-transformers` backend is not installed.
Install it with `uv add "pinakes[st]"`. A core-only install can index and search nothing that
needs embeddings — that is expected, not a fault.

Model weights are a separate, one-time download, not part of the install: about 1.4 GB for the default embedding + reranker pair, cached in the shared HF_HOME so every KB on the machine shares one copy.

Your first KB

pnk init my-kb --name "My notes"

notes is the blueprint, and it is the only one shipped. To see what this build has, and the version each is on:

pnk templates
notes  1.2  Plain Markdown notes: the smallest useful knowledge base.

It takes no --kb — the answer is a property of the install, not of a KB. Once you have one, pnk upgrade is what says which template it is on and what has changed since.

created /path/to/my-kb from notes@1.2
  kb id: 01KYMJMH8ECH945D5056CJD72V  (permanent — never edit it)

Next:
  1. put Markdown files in /path/to/my-kb/docs
  2. `pnk sync` to index them, then commit the sidecars it writes
  3. `pnk search "…"` to search, for free, offline

You get:

my-kb/
├── pinakes.toml     # the manifest — sources, models, chunking, budget
├── docs/            # SOURCE OF TRUTH: your files, never modified
├── eval/
│   └── questions.yaml   # the golden set, shipped empty on purpose
├── README.md        # the template's own — replace it with yours
└── .gitignore       # ships covering .pinakes/

That KB id is permanent. Every cross-KB link ever written to this KB resolves through it, and there is no migration machinery by design. Never edit or regenerate it.

Adding a KB to a repository you already have

The usual way to start is the other order: create the repository, clone it, and run pnk init inside it. That works — init adopts a directory that already has content and never overwrites a file it finds there.

git clone git@github.com:you/team-notes.git
cd team-notes
pnk init . --name "Team notes"
created /path/to/team-notes from notes@1.2
  kb id: 01KZ9ZEX1A4RYP0BSZFSSBATQG  (permanent — never edit it)
  left as they were: .gitignore, README.md

  ⚠️  your .gitignore does not ignore `.pinakes/`. Add this line:
        .pinakes/
      It holds the index and the spend ledger — ignoring it is what keeps them
      off any remote you push to.

Your README.md and .gitignore are untouched. Add .pinakes/ to your .gitignore when it says soinit will not edit that file for you, and without the line your index and spend ledger are committable.

If you passed --ci and a .github/workflows/pinakes.yml already exists, init refuses before creating anything and tells you so. Re-run without --ci to set the KB up and keep your workflow.

Drop a file in and index it:

echo '# Retrieval notes

Hybrid retrieval fuses BM25 with dense vectors using reciprocal rank fusion.' > my-kb/docs/retrieval.md

pnk sync --kb my-kb
1 indexed, 0 renamed, 0 metadata-only, 0 unchanged, 0 removed
1 edge(s) derived in 0.00s, 0 authored read from links: membership=1 sibling=0 parent-child=0 in-section=0 co-located=0 shared-tag=0 authored=0

The second line is the structural graph, derived at sync time and reported per kind so a kind deriving nothing says so rather than being absent. One document with one chunk has only its own membership edge; the kinds that connect documents to each other need more than one document. Nothing queries it unless you turn the expansion channel on, and it ships off (MANIFEST, STATUS).

Sync also wrote docs/retrieval.md.pnk.yaml — the sidecar, holding that document's permanent ULID, its title, tags and links. Commit it alongside the document; the ID is the thing every inbound link depends on. (MANIFEST §sidecar)

pnk sync is incremental and free. It compares content hashes and re-processes only what changed, so running it on every commit costs nothing.

Choosing a backend

Tell pnk init which extra you installed, and it stamps the matching models in both blocks:

pnk init my-kb --backend light     # fastembed, the [light] install
pnk init my-kb                     # sentence-transformers, the default

It is a flag rather than detection, deliberately. pinakes.toml is portable and committed, so stamping whatever happens to be installed on the machine that ran init bakes one author's install into a file their collaborators read — and the KB then fails for whoever has the other extra. A flag records a choice; sniffing records an accident.

If you already have a KB, or you omitted the flag on a [light] install, set provider in both blocks by hand before your first sync:

[embedding]
provider = "fastembed"                 # was "sentence-transformers"
model    = "BAAI/bge-small-en-v1.5"
dim      = 384

[rerank]
provider = "fastembed"                 # this one too
model    = "BAAI/bge-reranker-base"

The model ids are identical on both backends, so only provider changes. Skip this and the first sync stops — but on a [light] install the error is not the core-only one above. Because fastembed is installed, it names the alternative and the edit rather than an install:

error: the `sentence-transformers` backend is not installed.
`fastembed` is already installed on this machine — no install needed. Set `provider = "fastembed"`
in both `[embedding]` and `[rerank]` in pinakes.toml, with the model fastembed expects.

The fence above is the core-only wording — no embedding backend at all, so there is nothing to point you at and installing one is the only remedy.

Changing the embedding model later invalidates the index: queries refuse to run rather than return garbage, and pnk doctor names the mismatch. pnk sync --rebuild fixes it, and costs nothing.

Indexing PDFs

Needs pinakes[pdf]. PDFs are not indexed by default — the shipped template does not include them: whether a PDF extractor is installed is a fact about the machine, and a glob stamped without one turns every PDF into a failed document. (--backend chooses embedding models, and says nothing about PDF support.) pnk sync names what it skipped rather than leaving you to guess:

0 indexed, 0 renamed, 0 metadata-only, 0 unchanged, 0 removed
1 file(s) matched no `include` pattern: .pdf (1) — add "**/*.pdf" to `[sources] include` to index them, or `exclude` them to silence this.

That line lists any file Pinakes could have indexed but had no pattern for, grouped by extension. Files it could not read either way — images, archives, anything not valid UTF-8 — are never mentioned, since adding a glob for them would only produce a failed document. It also names pinakes[pdf] when a PDF is waiting and the extractor is not installed, because adding the glob alone would turn a skipped file into a failed one. pnk sync --quiet still prints it, on stderr.

Add the glob to your manifest:

[sources]
roots   = ["docs/"]
include = ["**/*.md", "**/*.txt", "**/*.pdf"]   # ← add the PDF glob

Then pnk sync extracts, chunks and indexes it like any other document. Extracted text is cached under .pinakes/cache/extract/, keyed on the file's content hash and the extractor's fingerprint — so a --rebuild re-indexes without re-extracting.

What the free path does and does not do:

Text-layer PDFs: columns, running heads stripped, hyphenation joined across line and page breaks
Page spans recorded per chunk in the index
path:page citations in results, on the CLI and the MCP surface alike — docs/paper.pdf:p7, or :p7-8 for a chunk straddling a page break
pnk doctor names the pages with no text layer, by path and page, before you decide whether to pay for any of them
⚠️ Tables are read column by column, not row by row. Column detection is geometric, not structural — a disclosed limitation, measured by pair_adjacency in the quality harness
Scanned / image-only PDFs. The free path yields nothing on them. The paid extractor reads them — shipped, opt-in, and it spends: pnk sync --extract=claude-vision

Filter to PDFs with --source-type pdf.

Searching

pnk search "how are dense and lexical results combined" --kb my-kb
[1] docs/retrieval.md — Retrieval notes
    # Retrieval notes

    Hybrid retrieval fuses BM25 with dense vectors using reciprocal rank fusion.
    (docs/retrieval.md:0-95 (Retrieval notes))

confidence: unknown — no calibrated thresholds in the manifest ([retrieval.confidence])
retrieval-only result. `pnk ask` prints the same evidence plus what answering the question would
take and what it would cost, and `pnk ask --deep` pays to answer it. Narrowing the query or adding
a filter is the free lever.

Free, offline, and unlimited. The pipeline is BM25 (FTS5) + dense vectors, fused with reciprocal rank fusion, then reranked by a local cross-encoder — all on your CPU.

Filters narrow before retrieval, and compose:

pnk search "conservation" --tag policy --tag draft     # repeatable; documents carrying the tag
pnk search "conservation" --path-prefix docs/policies/ # by path
pnk search "conservation" --source-type pdf            # markdown, text, code or pdf
pnk search "conservation" --modified-after 20260101    # by document mtime
pnk search "conservation" -k 20 --json                 # more passages, machine-readable

Tags come from the sidecar, so tagging a document means editing its .pnk.yaml — sync picks the change up without re-embedding anything.

About that confidence: unknown

It is the honest default, not a bug. Cross-encoder scores are not comparable across queries, so an absolute threshold is meaningless until it is fitted against a golden set for your own corpus. Thresholds fitted on someone else's corpus are not a calibration, so the template ships [retrieval.confidence] commented out.

To calibrate: write questions with known-correct sources in eval/questions.yamlpnk init scaffolds that file for you, with the entry schema in comments — then run:

python -m pinakes.calibrate my-kb

It is a module entry point, not a pnk subcommand. It prints a [retrieval.confidence] block for you to paste and never writes one. Until you paste it, every result reports unknown.

The cost of the heuristic once calibrated is published rather than hidden: measured false-confidence on the demo corpus is 0.25 (STATUS).

Asking a question

pnk search answers what is in the KB about this. pnk ask answers what it would take to answer this — the same passages, the same filters, plus the size of the job:

pnk ask "who may assign catalogue numbers" --kb my-kb -k 2
[1] docs/volunteer-programme.md — Volunteer programme
    # Volunteer programme

    Volunteers work on listing and repackaging, always alongside a member of staff. They do not carry
    out conservation treatment or assign catalogue numbers.
    (docs/volunteer-programme.md:0-176 (Volunteer programme))

[2] docs/catalogue-numbers-format.md — Catalogue number format
    # Catalogue number format

    A catalogue number is three letters for the collection, a slash, and a running number. The letters
    are assigned once and never re-used, even after a collection is fully withdrawn.
    (docs/catalogue-numbers-format.md:0-206 (Catalogue number format))

confidence: high — top rerank score 3.514 is above -3.5016
no answer was synthesised — this is evidence, not a conclusion.
answering this would take one synthesis call over the passages above.
`pnk ask --deep` pays to turn this evidence into an answer, estimated at €0.20 worst case — every call is reconciled to what it actually cost, which `pnk budget` shows.

There is no answer there, and nothing free will produce one. What ask can do is say honestly whether the evidence above is enough, what answering would take, and what that would cost — before you decide to spend anything. Nothing is spent working the price out: the table is shipped with Pinakes.

On a KB you have not calibrated — which is every KB the template stamps — the closing lines read instead:

confidence: unknown — no calibrated thresholds in the manifest ([retrieval.confidence])
no answer was synthesised — this is evidence, not a conclusion.
how much answering this would take cannot be told from here: with no calibrated signal, a run ends at its caps rather than at sufficiency.
`pnk ask --deep` pays to turn this evidence into an answer, estimated at €1.33 worst case — every call is reconciled to what it actually cost, which `pnk budget` shows.
fit [retrieval.confidence] with `python -m pinakes.calibrate <kb>` — with reranking on, and with the fitted reranker the one actually in use.

That is not a refusal, and the reason is worth knowing. Without a fitted signal nothing can say enough evidence has been gathered, so a paid run would stop when it ran out of rounds or out of budget rather than when it was finished — which costs more for the same question. See About that confidence: unknown above; a question that simply matches nothing is told that instead, and is not sent off to calibrate a signal that was never the problem.

Every filter from Searching works here too, and each narrows the work as well as the results:

pnk ask "who may assign catalogue numbers" --path-prefix docs/policies/ --tag policy
pnk ask "who may assign catalogue numbers" --json      # answer: null, plus an escalation block

--json returns pnk search's payload with answer (null until a paid run fills it) and escalation beside it, so a script parses one shape whether or not the loop ran (CLI).

Paying for an answer

pnk ask --deep is the one command in Pinakes that reasons, and the second of two that can spend. It runs the free retrieval above first — that is round 0, not a preamble — and then pays to turn it into an answer:

pnk ask "who may assign catalogue numbers" --kb my-kb --deep --yes

⚠️ This one block is a real run from 0.24.0, kept rather than re-bought — it is the only output on this page not re-run against 0.28.1, because reproducing it means paying for it again. Two figures in it are therefore historical. The €0.08 actually spent is what that run cost and does not depend on the estimator. The €0.26 estimate does: deep/estimate.py was re-measured against the live API in 0.25.3, and this exact command — no -k, so the default passage count — is now quoted at €0.21. The €0.20 under Asking a question above is the same question at -k 2: fewer passages, one cent less. They are two invocations, not one number measured twice, and quoting either as "the" new figure would be inventing a precision nobody ran. Everything else here — the answer, the citations, the suggested-links block — is unchanged.

answer — synthesised from the evidence above, and cited back into it:

Volunteers may not assign catalogue numbers; they work on listing and repackaging alongside
staff [1]. The numbers themselves are three letters and a running number, assigned once and
never re-used [2].
  [1] docs/volunteer-programme.md:0-176 (Volunteer programme)
  [2] docs/catalogue-numbers-format.md:0-206 (Catalogue number format)

answered in one synthesis call — the calibrated signal said the retrieved evidence was
already enough, so no decomposition was paid for.
1 paid call(s), €0.08 spent against an estimated €0.26 worst case. `pnk budget` has the record.
what was asked and what came back is kept in .pinakes/deep/01K2ZQ…ZQ.json

suggested links — documents this run cited together. Nothing was written: paste a block into the sidecar its first line names.

# docs/volunteer-programme.md.pnk.yaml
links:
- to: pnk://01K2ZQ…ZQ/01K2ZR…ZR  # docs/catalogue-numbers-format.md — cited together in 1 round
  rel: co-cited
  origin: deep

That last block is the run telling you something about your KB. Those two documents answered one question together, and nothing in the KB says so — so the run offers the links[] entry that would. Paste it into the sidecar named on its first line, rename co-cited to whatever the relationship really is, and commit it: from then on it is free, visible to every future query, to pnk links, and to every connected KB. Paid inference bought once instead of every time you ask.

Nothing was written — Pinakes only prints it. A run that cites one document per call has no pair to propose and prints no block at all. See CLI for the rules, including the one that matters: a document's own text cannot talk the model into suggesting a link, because the model is never shown a document identifier to name.

The confidence decides the price, not whether you get an answer. --deep is you asking to spend, so it always answers. A high or medium question costs one call, as above. A low one decomposes into subquestions, searches for each, answers from what they return, and asks whether that is now enough — stopping the moment it is. An uncalibrated KB runs the same loop with no early stop, because the step that would end it is the missing signal, and the last lines say so:

stopped at the round cap — 3 of 3 round(s) — not at sufficiency. `[deep] max_rounds` is what
bounds it. There is no calibrated signal on this KB, so the run could not stop at sufficiency:
it was bounded by the caps rather than by the evidence (`python -m pinakes.calibrate <kb>`).

That is the honest cost of not calibrating: the same question, answered, for up to six calls instead of one.

--yes, and the refusal you will probably meet first

Every --deep run asks before it spends, because confirm_above_eur defaults to 0.01. --yes answers that prompt — it is what cron wants, and it raises no cap. Without it and without a terminal, the run refuses rather than assuming consent.

On a KB you created before the deep release, the first thing you meet is a budget refusal. The default caps were raised so a deep run fits, but your manifest stamped the old ones and Pinakes will not rewrite your manifest. The refusal is the remedy:

error: refused: answering this question with claude-opus-5 is estimated at €1.38 (the unknown
branch: 6 paid call(s) across 3 round(s), worst case), which exceeds 2 of the three budget
windows:
  - per_operation_eur: cap €0.30, already spent €0.00 this window, headroom €0.30 — this run
    needs €1.38.
  - daily_eur: cap €1.00, already spent €0.00 this window, headroom €1.00 — this run needs €1.38.
The complete manifest edit that would admit this run:
  [budget]
  per_operation_eur = 1.38
  daily_eur = 1.38
Raising a cap is a permanent, ongoing exposure to every future run at that ceiling. Two cheaper
routes exist first: lower `[deep] max_rounds`, which is what the estimate multiplies; or fit
`[retrieval.confidence]` with `python -m pinakes.calibrate <kb>`, after which a confident question
costs one call instead of a loop.
Raise the named cap in `[budget]`, or wait for the window to roll over. `pnk budget` shows what
has been spent and against which ceiling.

It exits 1, measured without a pipe — a refusal is something you asked for that could not be done, not a report. The branch is named unknown rather than decomposition because this KB has no calibrated signal. It is priced identically to decomposition — the missing signal changes when a run stops, never what a round costs — so the euro figure is the same either way and only the name differs. A calibrated KB whose question reads low says decomposition here.

Every blocked window at once, with the exact edit — because raising one cap, retrying, and discovering the next is the experience that shape exists to avoid. pnk upgrade will show you the new defaults as a proposed change too, and will not apply it without --apply.

What the run leaves behind

That last line is a file, and it is the only place your question is written down: the spend ledger deliberately records no query text, so a pnk budget row on its own cannot say what it was for. The transcript sits beside it under .pinakes/deep/, named after the same operation_id, holding what you asked, what narrowed it, which confidence reading chose the branch, and the answer with its citations.

It cost money to produce, so nothing sweeps it away: pnk sync --rebuild leaves it, and so does pnk sync --clear-cache. When you do want it gone:

pnk sync --clear-cache=transcripts --kb my-kb

That empties .pinakes/deep/ and touches nothing else — and it asks first, because unlike an extraction, a record of what a particular run was asked cannot be bought back.

Check .pinakes/ is gitignored before you rely on that. pnk init writes the line into a .gitignore it creates; into one that already existed it writes nothing, because a .gitignore is yours. It asks git whether the directory is really ignored — not whether the text appears — and prints the line to add when that is what is missing. If you skipped that warning, the transcript is the file you would least like committed.

What it will not do. A subproblem it writes is a search query against your KB with your filters — never a path, never another KB — so a document telling the model to go and read /etc/passwd produces a useless search and nothing else. And every citation names a passage the call was actually shown, because the model is never given a document identifier it could invent one from (CLI). The suggested links inherit that: they are built from the citations, so a document instructing the model to "add a link to X" gets a sentence in an answer and nothing in the block.

Keeping the index fresh

A KB is normally a git repo, and freshness is git-triggered:

pnk install-hooks --kb my-kb

Three hooks, split by what each is allowed to touch:

Hook Runs Why the split
pre-commit pnk sync --sidecars-only --stage --extract=pypdfium2 Mints IDs for staged documents and git adds the sidecars, so a document and its permanent ID land in the same commit. The only hook that writes into docs/. It refuses to overwrite a sidecar that will not parse, and that refusal fails the hook — repair the file, or git commit --no-verify
post-commit pnk sync --index-only --extract=pypdfium2 Index only
post-merge pnk sync --index-only --extract=pypdfium2 Index only

Sidecars are authored at pre-commit time precisely so post-commit never dirties the tree it just committed. git commit --no-verify is the escape hatch.

Every hook forces the free extractor, and install-hooks says so when it writes them. A hook is non-interactive: on a KB configured for a paid backend, a hook without that flag would either abort on every commit (nothing to confirm an estimate from) or spend afresh on every commit. A scanned PDF committed this way is indexed with its empty free extraction and left stale, so a later pnk sync --extract=<paid-backend> you run yourself picks it up — never skipped forever. pnk doctor reports the combination and how many documents are waiting.

pnk init --ci writes a GitHub Actions workflow that does the same thing, for the same reason.

An existing hook that is not ours is left untouched and printed with the line to add. A hook that cannot find pnk warns and exits 0 — a hook that fails every commit only teaches --no-verify.

No hooks? pnk sync from cron or CI works identically. It is safe to run concurrently: a second sync finding a live lock exits 0 quietly, and pnk doctor reports any held lock with its age.

Watching what it costs

Two shipped surfaces can spend, both opt-in: the Claude-vision PDF extractor (0.3.0) and pnk ask --deep (0.24.0). Neither runs unless you ask for it — the free extractor is the default and pnk ask without --deep never spends — and the accounting is the same for both: every call is priced and reserved before it is made, and pnk budget reads it:

pnk budget --kb my-kb

It prints today's and this month's spend against their caps, how many calls were reconciled, voided or left with an unknown outcome, and the last few operations. On a KB that has never spent it prints zeros; it can only ever read.

Caps live in [budget] (MANIFEST) and there are three, all enforced before every call: per_operation_eur bounds one whole command — one pnk sync, or one pnk ask --deep run including every round it pays for — while daily_eur and monthly_eur bound sequences of them: a per-operation cap alone is no protection against a hook-driven KB syncing thirty times a day. They are per KB: ten paid KBs have ten monthly allowances, and there is no global cap in this release.

An unknown outcome is a call that timed out: it may or may not have billed, so it keeps consuming its reserved amount until you check the vendor's dashboard and close it:

pnk budget --kb my-kb --resolve <call_id> --actual 0.043

That appends a reconciliation — .pinakes/ledger.jsonl is append-only, survives every --rebuild and every --clear-cache, and is the one thing in .pinakes/ that cannot be recomputed. Never edit it by hand.

Using it from an agent

pnk serve speaks MCP. Point it at one or more KBs:

pnk serve /path/to/my-kb /path/to/other-kb

For Claude Code, add it to .mcp.json:

{
  "mcpServers": {
    "pinakes": {
      "command": "pnk",
      "args": ["serve", "/path/to/my-kb"]
    }
  }
}

Or without installing anything:

{
  "mcpServers": {
    "pinakes": {
      "command": "uvx",
      "args": ["--from", "pinakes[st]", "pnk", "serve", "/path/to/my-kb"]
    }
  }
}

Four tools, namespaced so they cannot collide with another KB server the agent has loaded:

Tool Does
pinakes_search Ranked, cited passages with a confidence signal. Each carries page_start/page_end (both null for a source with no pages) beside the rendered citation
pinakes_get A document by ULID. page_start/page_end read one range of a PDF; page boundaries come back marked by a line reading [page N]
pinakes_links What a document connects to, and what connects to it. depth is capped at 3 server-side; a neighbour in another KB is returned and never expanded
pinakes_list_kbs The KBs this server was pointed at

pinakes_links reports confidence: "unknown" on every call — with a query and without one. The signal pinakes_search reports is fitted per KB on the reranker score of a retrieved passage; a traversal neighbour is not one, and a neighbour list spanning two KBs has no single manifest whose thresholds would apply. unknown is the honest answer, and it is the only one this tool gives.

A neighbour in a KB this server was not pointed at still comes back — with its kb_id, its doc_id and reachable: false — because a link that exists is worth knowing about even when this process cannot follow it. Point pnk serve at both KBs and it becomes reachable; nothing about the KBs themselves changed. A reachable neighbour in a different KB needs its kb_id passed too — pinakes_get(doc_id, kb=kb_id), since an id resolves inside one KB — and the row carries a fetch_with object holding exactly that pair.

Rows come back in rank order. score is comparable only among rows with the same scored_by_query: with a query, a neighbour with no local chunks to embed falls back to its edge weight, which is a different scale from a cosine — so re-sorting by score reorders the list against itself.

One citation vocabulary across both surfaces. An agent can cite docs/paper.pdf:p7 from a get exactly as it can from a search — the numbers are the same numbers, and the trace tests assert that by comparing them.

Multi-hop falls out of composition. pinakes_search → pinakes_get → pinakes_search is a plan-retrieve-read-refine loop, and your agent already runs it in its own context — on reasoning you are already paying for. There is no second agent framework here, and the KB never spends your money.

Two boundaries worth knowing: the server answers only about the KBs named on its command line — no tool argument accepts a filesystem path — and retrieved text comes back inside a delimited evidence field stating it is data to reason about, never instructions to follow. A KB whose documents say "ignore previous instructions" is a KB, not an exploit.

Health checks

pnk doctor --kb my-kb

Checks the environment (SQLite version, FTS5, loadable extensions), the backend and whether weights are cached, template drift, index/model coherence, calibration validity, orphaned sidecars, duplicate IDs, dangling links and link coverage, recorded failures, the extraction cache, the 50k-chunk NumPy-tier threshold, a held sync lock, and hook status.

Every non-OK check carries a remedy. --prune is the only thing that changes anything, and it prints every path before removing it.

Adopting a template change

Your KB was stamped from a template, and pinakes.toml is yours from that moment on. Templates move on: a comment gets clearer, a default gets raised. pnk doctor tells you that happened; pnk upgrade tells you what.

pnk upgrade --kb my-kb

It writes nothing. It prints the diff between the template version your KB records and the one installed, then says, hunk by hunk, whether each change still fits your file: applies cleanly, already applied (you adopted it by hand, or a newer pnk init wrote it), or conflicts — the lines it expects are not in your file the way it expects them, so nothing can be placed there mechanically.

Read that first. Then, if you want the changes, ask for them:

pnk upgrade --kb my-kb --apply

--apply writes every hunk that applies cleanly and skips the ones already there. If any hunk conflicts it writes nothing at all and names the region — a half-upgraded manifest with no record of which half is worse than an unupgraded one, so adopting a conflicted change stays your own edit against the diff above.

Watch for the spending-cap section. A [budget] default is a change like any other and --apply writes it, so both commands print any cap that would move, with the old value and the new one, under their own heading:

⚠️  a spending cap changes:

  per_operation_eur: 0.30 → 2.00
  daily_eur: not set → 6.00

It appears only when a cap really would move, so seeing nothing means nothing moved. A raised cap is permission, not spending: the free extractor stays the default and a paid one still has to be asked for.

Your previous manifest is copied to pinakes.toml.orig and the path is printed. Nothing ignores that file — the .gitignore pnk init writes covers .pinakes/ only — so in a git repository it will show up in git status. Move or delete it once you are sure; Pinakes never overwrites it and never removes it.

If an applied change touches a key your index was built under — a chunking size, an embedding model — the output names the key and tells you to run pnk sync --rebuild. --apply never syncs, re-chunks or re-embeds by itself.

On a KB stamped notes@1.0 it says this instead — re-run against 0.28.1 in the same pass as the rest of this page, on a KB created before the version archive existed:

cannot compare: notes@1.0 is not in this build's archive

Nothing is wrong with your KB and nothing needs changing. A manifest records a version
string, never the content that version meant, and this build ships the content of notes@1.1,
notes@1.2 — so there is no baseline to diff against, and there will not be a later one: an
unarchived version's content is gone, not pending. To see what moved, compare it by hand:
run `pnk init` on a throwaway directory and diff its pinakes.toml against yours. A KB
stamped from notes@1.1 or later is compared automatically.

The archive list in that message grows with each template version, which is why it now reads notes@1.1, notes@1.2 where it once read notes@1.1 alone — the message enumerates what this build actually ships rather than naming a latest.

That is not a fault in your KB and there is nothing to fix. A manifest records notes@1.0 — a name. The content behind that name changed ten times while the name stayed the same, so no build can say which of the eleven different contents yours came from, and diffing against a guess would report changes you never received. KBs created from notes@1.1 onward are compared automatically.

It exits 3 on that path — not 0, which a script reads as up to date, and not 1, which means something is wrong. A conflict is the one case whose code depends on what you asked for: pnk upgrade exits 0, because the command did its job and what it found is your answer, while pnk upgrade --apply exits 1, because you asked for a write it could not make.

Moving, sharing and publishing a KB

A KB is a directory. Move it, copy it, commit it, hand it to someone — .pinakes/ is derived state and rebuilds for free with pnk sync --rebuild.

Commit docs/, pinakes.toml and every sidecar. Never commit .pinakes/ — the shipped .gitignore already covers it, which is what keeps your index and your spend ledger off any remote.

⚠️ Publishing a KB repo publishes every sidecar, not just your documents — titles, tags and provenance.source URLs included. Those routinely carry more signal than people expect. The engine cannot enforce anything here; check before you push.

If you hand-set a manifest key, say so with a floor. Pinakes treats an unknown key as a hard error, so a KB using a key your reader's build predates is simply unreadable to them — and the refusal can only report the symptom, not the cause, unless the manifest declares one. Set [kb] requires_pinakes yourself and their build says "this KB requires pinakes >=X" instead. Nothing sets it for you: pnk init does not stamp it and pnk upgrade --apply does not write it, both by design (MANIFEST).

Links between KBs use pnk://<kb-ulid>/<doc-ulid> — ULIDs, never aliases — so they survive renames, moves, and being shared with someone whose local alias for your KB is different. pnk link authors them and pnk links traverses them: see Following links between two KBs.

Troubleshooting

Symptom Cause Fix
the sentence-transformers backend is not installed [light] install, default manifest Set provider = "fastembed" in [embedding] and [rerank]
N file(s) matched no include pattern Those files are in your roots but no glob picks them up Add the glob it names (above), or exclude them
A PDF indexes with no text Scanned / image-only — the free path has no OCR The opt-in paid extractor: uv add "pinakes[pdf,claude]", then --extract=claude-vision (STATUS)
the pypdfium2 extractor is not installed [pdf] extra missing uv add "pinakes[pdf]"
Queries refuse to run, naming a model mismatch Embedding model changed since the index was built pnk sync --rebuild — free
pnk doctor reports WARN sync completeness A first sync was interrupted before it finished, so meta carries no embedding identity yet pnk sync. It resumes incrementally and keeps every embedding already written; do not run --rebuild, which would discard them
Index refuses to open, naming schema_version The index was built by a Pinakes with a different schema. 3 is current; every index built before the graph release is a 2 or lower pnk sync --rebuild. There are no migrations, by design
this KB requires pinakes >=X (this build is Y) The KB was written by a newer Pinakes and declares a floor Upgrade: uv add --upgrade pinakes. Downgrading a KB is not supported — nothing rewrites a manifest you own
unknown key(s) in a KB you did not edit The same cause, on a KB that declares no floor, so the refusal can only report the symptom Upgrade Pinakes. Unknown keys stay a hard error by design (MANIFEST)
confidence: unknown on every search No fitted [retrieval.confidence] Expected. Calibrate against your own golden set (above)
Sync exits non-zero listing documents Per-document failures, isolated by design pnk doctor lists them with the error; the rest of the corpus indexed fine
A sync seems stuck behind a lock A killed sync, or another machine A lock left by a dead process on this host is reclaimed automatically — re-run pnk sync and it continues incrementally, re-embedding nothing. --force-unlock is for a lock held by another host, and is the destructive one: pnk doctor reports the holder and the time the lock was taken, never a computed age. Check the process, not the clock — every stamp Pinakes writes is now UTC, but a KB last synced by an older build carries local stamps, so a fresh lock can read as hours old beside them
`vector_tier` must be one of 'auto', 'numpy' The manifest says vector_tier = "sqlite-vec" — a tier that is not built. It used to be accepted and ignored; the KB now refuses to load on every command rather than pretend vector_tier = "auto". It changes nothing about how that KB behaves: it was already getting the NumPy tier (MANIFEST)
Searches slow past ~50k chunks NumPy tier is exact, not sublinear Expected; pnk doctor warns. The sqlite-vec tier is deferred rather than scheduled — it returns if a queried KB crosses ~50 000 chunks with latency you actually feel, which is a good reason to say so. Setting vector_tier = "sqlite-vec" is refused, not a workaround. Splitting the KB is the honest answer today

Everything in this section is free. Two paths can spend, and neither is reachable by accident. The claude-vision extractor above needs pinakes[claude], an explicit --extract=claude-vision or manifest key, and a real PINAKES_ANTHROPIC_API_KEY; pnk ask --deep needs the --deep flag and the same key. Both are bounded by the three [budget] caps and recorded in pnk budget (STATUS).

Two KBs know about each other through [[links.kb]], and a link is written in the source document's sidecar:

# archive/pinakes.toml
[[links.kb]]
name = "museum"                        # a local alias; it means nothing on another machine
id   = "01KYP11WY2ZGX9B2Q5V7PJ8DW1"    # the KB's ULID — this is what travels
path = "../museum"                     # where it lives here

pnk link writes the entry, and is the only thing that ever needs to know the alias:

$ pnk link docs/loans-outward.md museum:docs/incoming-loans.md --rel counterpart
docs/loans-outward.md.pnk.yaml: counterpart -> pnk://01KYP11WY2ZGX9B2Q5V7PJ8DW1/01KYP8878AZWS2ZWEBD0KQYTXE
`pnk sync` to index it, then commit the sidecar.

which leaves:

# archive/docs/loans-outward.md.pnk.yaml — the id, title and any other links are above
links:
- to: pnk://01KYP11WY2ZGX9B2Q5V7PJ8DW1/01KYP8878AZWS2ZWEBD0KQYTXE
  rel: counterpart

museum: is looked up in this manifest and never written down — what lands on disk is the pair of ULIDs, which is what makes the link mean the same thing on someone else's machine. A path in this KB (docs/pest-management.md) or a pnk:// URI work as the target too. Run the same command again and it writes nothing:

$ pnk link docs/loans-outward.md museum:docs/incoming-loans.md --rel counterpart
docs/loans-outward.md.pnk.yaml already carries counterpart -> pnk://01KYP11WY2ZGX9B2Q5V7PJ8DW1/01KYP8878AZWS2ZWEBD0KQYTXE; nothing written.

(That is the state tests/demo-kb ships in, if you are following along against it — the link is already there, so the first command prints the second line.)

It writes into the source document's sidecar and nothing else — the museum's files are never touched — and it refuses a document that has no sidecar yet, because the ULID a link needs is minted by pnk sync. CLI has the full grammar and the refusals.

Write it by hand instead if you prefer, in whatever style you like:

# archive/docs/loans-outward.md.pnk.yaml
links:
  - to: pnk://01KYP11WY2ZGX9B2Q5V7PJ8DW1/01KYP8878AZWS2ZWEBD0KQYTXE
    rel: counterpart

Your comments, your quoting, your blank lines and your own key order all survive a rewrite, and values are never reinterpreted: country: NO stays the string NO rather than turning into false.

The one thing that changes about layout is exactly that indentation — the first time anything rewrites the file, Pinakes re-emits a block sequence as - to: at the left margin, which is why the output above is flush. It happens once. MANIFEST lists the full set of bounds, including the pnk://self/… expansion you can see below.

pnk sync records that link, and also reads the other KB's committed sidecars to learn what points back:

$ pnk sync --scan-links
30 indexed, 0 renamed, 0 metadata-only, 0 unchanged, 0 removed
inbound links: museum 6

Then ask what a document connects to:

$ pnk links docs/loans-outward.md
-> related: conservation assessment  [hop 1]
<- governs: 01KYP88789WHHN93TW49AX096C (other KB)  [hop 1]
<-> counterpart: 01KYP8878AZWS2ZWEBD0KQYTXE (other KB)  [hop 1]

-> is a link written by the document the row hangs off — the one you asked about at hop 1, its parent beyond that; <- is one pointing back, learned by scanning the other KB when it lives there; <-> is the same relation written from both ends. A neighbour in another KB shows its ULID rather than a title, because this KB holds the partner's links, not its documents.

Going deeper follows same-KB links only:

$ pnk links docs/loans-outward.md --depth 2
-> related: conservation assessment  [hop 1]
<- governs: 01KYP88789WHHN93TW49AX096C (other KB)  [hop 1]
<-> counterpart: 01KYP8878AZWS2ZWEBD0KQYTXE (other KB)  [hop 1]
-> related: pest management  [hop 2]
-> related: storage environment  [hop 2]

The two cross-KB neighbours are still there and still at hop 1: a neighbour in another KB is terminal. Not because there is nothing beyond it — this index does hold that KB's links pointing back here — but because expanding it would show a partial slice of someone else's graph that you could not tell apart from the whole. To go further, open that KB and ask it.

--json adds frontier (what was found and not expanded, and why), unresolved (links whose target is missing) and truncated (which caps bit).