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
- Your first KB
- Choosing a backend
- Indexing PDFs
- Searching
- Keeping the index fresh
- Using it from an agent
- Health checks
- Adopting a template change
- Moving, sharing and publishing a KB
- Troubleshooting
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:
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:
| 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¶
notes is the blueprint, and it is the only one shipped. To see what this build has, and the
version each is on:
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.
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 so — init 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:
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¶
[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.yaml — pnk init
scaffolds that file for you, with the entry schema in comments — then run:
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:
[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:
⚠️ 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:
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:
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:
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:
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:
For Claude Code, add it to .mcp.json:
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¶
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.
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:
--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:
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).
Following links between two KBs¶
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).