Carta
Maps, connects, and remembers your documentation.
Carta is a Claude Code plugin that keeps your project docs honest — auditing for contradictions, embedding reference material into a searchable knowledge base, and surfacing the right context exactly when you need it.
The problem (or: how this got built)
Fast-moving projects accumulate documentation debt quietly. You write a spec. An AI agent writes a dozen more files based on it. The spec changes. Three weeks later, four different documents describe the same API endpoint four different ways, and nobody — human or AI — knows which one is right.
This problem gets worse the more you lean on AI agents to help you work. Agents are only as good as the context they can see, and when your docs/ folder is a fog of contradictions and stale frontmatter, you're giving your agent a map that leads off a cliff.
Carta started as a happy accident. While working through a project with a lot of PDFs, datasheets, and fast-changing markdown — the kind of repo where the hardware changes on Thursday and the docs are still describing Wednesday — we built a small structural scanner to flag stale and broken cross-references. Then we added a semantic pass. Then a vector store. Then a /doc-search skill so Claude could query the embedded knowledge directly.
At some point we looked at what we had and realized: this is a thing. It works. It's small, it runs locally, it requires no new services beyond what an LLM-augmented developer already has running. So we generalized it.
What Carta does
Three things, tightly integrated:
1. Audit
A two-pass system that runs on a schedule or on demand:
- Structural scanner (zero LLM calls) — detects stale docs, broken
related:links, homeless markdown files, and orphaned content. Runs fast, runs often. - Semantic audit (Claude) — reads the scanner output and checks changed doc pairs for contradictions: version numbers, API endpoints, config values, whatever matters in your domain. Writes a rolling
docs/AUDIT_REPORT.mdwith stableAUDIT-NNNissue IDs that persist across runs.
2. Embed
Ingests your reference material — PDFs, datasheets, manuals, audio transcripts — into a local Qdrant vector store via Ollama. Generates spec_summary blocks for dense documents so the audit agent can cross-reference them without re-reading 200 pages.
3. Search
Natural language recall over everything that's been embedded. Ask Claude what the docs say about rate limiting, authentication flows, power supply constraints, sample naming conventions — whatever's in your knowledge base — and get cited answers back.
Retrieval quality
Search is hybrid (dense + BM25 with Reciprocal Rank Fusion) by default, with an optional
ColPali visual layer for image-heavy PDF pages. Measured on a real technical-docs corpus
(~160 markdown docs + 214 datasheet PDFs, local models — nomic-embed-text + Qdrant/bm25):
Text retrieval — markdown eval, 20 queries:
| Pipeline | recall@5 | MRR |
|---|---|---|
| Dense only (cosine) | 0.550 | 0.402 |
| Hybrid (BM25 + dense, RRF) | 0.700 | 0.546 |
On an expanded 62-query set over the same corpus (adds datasheet, supplier, and patent
reference docs): hybrid alone scores 0.790 / 0.641, and the LLM reranker (qwen3.5:9b,
candidate pool 40) lifts it to 0.871 / 0.778 — with rerank: applied on 61/62 queries
confirming the reranker actually ran on every scored query but one.
Visual retrieval — datasheet eval, 14 queries:
| Pipeline | recall@5 | MRR |
|---|---|---|
| Text / OCR only | 0.500 | 0.429 |
| + ColPali visual (two-pass) | 0.857 | 0.589 |
The datasheet set includes 6 "visual-only" queries whose answer lives on a diagram, package drawing, or derating curve that text search structurally can't reach — ColPali lifts those from 0/6 to 5/6. Text and visual hits are fused by rank (RRF), so the visual layer never crowds out text results.
These are one project's eval sets, not a public benchmark — they show the delta each layer adds on real technical docs, not an absolute SOTA claim.
When search.rerank.enabled is true, carta eval also prints rerank: applied on N/M queries
— and fails (exit 1) if the reranker ran on zero queries, so a silent fail-open (wrong model
name, Ollama down, reasoning-model misconfig) can never masquerade as a reranked result.
Eval sets that don't saturate. Once recall@5 nears 1.0 it can no longer detect a regression
or measure an improvement, so carta eval also reports recall@1 / recall@3 and supports two
optional per-query fields: reject: — plausible-but-wrong docs (a sibling part's datasheet, a
superseded plan); ranking one above the gold is counted as a hard-negative violation — and
tags:, which break recall@k and MRR down per label (e.g. pdf-deep, vocab-mismatch). --json PATH
writes the full metrics, per-query ranks included, for A/B tooling. See
carta/eval/datasets/example.yaml.
Comparing against public benchmarks. To position Carta against standard suites:
- ViDoRe v1/v2 (nDCG@5) — the visual-document-retrieval benchmark ColPali/ColQwen2 are evaluated on; the most direct check of Carta's visual layer.
- BEIR / MTEB retrieval / RTEB (nDCG@10) — standard text-retrieval generalization.
- FreshStack — hard RAG over technical docs + code; closest in spirit to Carta's use case.
Good fits
Carta shines in projects where:
- Docs outnumber the people who maintain them. Research repos, hardware projects, API platforms — anywhere the documentation surface area is large relative to the team.
- AI agents are generating or editing docs. Agents don't track contradictions between files. Carta does.
- Reference material lives outside version control. PDFs, datasheets, vendor manuals, meeting transcripts — Carta pulls them into the same queryable knowledge base as your markdown.
- The project changes fast. Embedded firmware, evolving APIs, active research — anything where a doc written last Tuesday might already be wrong by Friday.
Less useful for: simple single-repo projects with a handful of docs, or projects where the docs are already the source of truth and rarely change.
Quickstart
Version history: CHANGELOG.md. Install (pipx, venv, PATH): docs/install.md.
Claude Code plugin (recommended)
Add the Carta marketplace to your ~/.claude/settings.json:
{
"extraKnownMarketplaces": {
"carta-cc": {
"source": {
"source": "github",
"repo": "Ian-q/Carta"
}
}
}
}
Then install and enable the carta-cc plugin via /plugins in Claude Code. That's it — hooks and skills are registered automatically. Run /carta-init in any project to bootstrap Carta there.
CLI install (pip / uvx / curl)
For use without the Claude Code plugin, or if you want the carta command available directly:
# One-shot (no install required)
uvx --from carta-cc carta init
# Install as a CLI tool (recommended on macOS)
pipx install carta-cc
carta init
# Install directly (may require --user or a venv on macOS/PEP 668 systems)
python3 -m pip install carta-cc
carta init
# Or via curl
curl -fsSL https://raw.githubusercontent.com/Ian-q/Carta/main/carta/install/install.sh | bash
See docs/install.md for pipx vs venv, PATH, PlatformIO conflicts, and --pip-args syntax.
Setup (5 minutes)
Prerequisites:
# 1. Qdrant — run with persistence so collections survive restarts
docker run -d -p 6333:6333 -v ~/.carta/qdrant_storage:/qdrant/storage --name qdrant qdrant/qdrant
# 2. Ollama — install from ollama.ai, then pull required models
ollama pull nomic-embed-text # text embeddings
ollama pull qwen3.5:0.8b # only if you set proactive_recall.judge_backend: ollama
ollama pull qwen3-vl:8b # vision describer for PDFs with figures/diagrams (needs Ollama >= 0.12.7)
ollama pull glm-ocr # OCR for text/tables on image-heavy PDF pages
Both services are optional if you only want structural audit without embedding or search. See docs/install.md for the full setup walkthrough and carta doctor to verify your environment.
After init:
- Edit
.carta/config.yaml— set yourproject_name,docs_root, andexcluded_paths - Add frontmatter to a few key docs:
---
related:
- CLAUDE.md
- docs/api/endpoints.md
last_reviewed: 2026-03-20
---
- Run your first audit:
/doc-auditin Claude Code (orcarta scan) - Embed your reference PDFs:
/doc-embed(drop files intodocs/reference/) - Query:
/doc-search what does the docs say about authentication?
Skills
| Skill | What it does |
|---|---|
/carta-init |
Bootstrap Carta in a new project (generates .carta/config.yaml) |
/doc-audit |
Structural + semantic audit, generates docs/AUDIT_REPORT.md |
/doc-embed |
Ingest PDFs, manuals, and audio transcripts into Qdrant. Supports .pdf, .md, .csv, .xlsx (xlsx needs the spreadsheet extra: pipx inject carta-cc openpyxl) |
/doc-search |
Natural language search over the embedded knowledge base |
carta focus / carta_focus |
Deep retrieval scoped to one file: page-anchored passages, an outline (omit query), and table/figure pages as images. Two-step partner to /doc-search (locate → go deep) |
carta remember / carta_remember |
Save a curated project note (quirk / bug-note / helpful-note) as a repo markdown file and embed it. --about <file> records which file the note is about |
carta impact / carta_impact |
Reverse lookup: given the files you changed (or a git range), the docs that name them or that git history shows changing with them, ranked and labelled, with the lines to re-read. No model, no Qdrant, no Ollama |
Which audit command?
"Audit" spans several distinct workflows, and it's easy to reach for the wrong one:
| You want to… | Use | Output |
|---|---|---|
Find structural doc issues (stale/broken related:, homeless/orphaned docs) |
carta scan (no LLM) or the /doc-audit skill (structural + semantic) |
.carta/scan-results.json / docs/AUDIT_REPORT.md |
| Check embedded-data integrity (orphaned sidecars, damaged/duplicate points) | carta audit |
JSON report |
| Diagnose the environment (Qdrant/Ollama/models) | carta doctor (+ --fix) |
stdout |
| Measure retrieval quality | carta eval |
scores |
| Find the docs that describe the files you changed | carta impact <path>... · carta impact --diff |
ranked docs + evidence lines (--json) |
| Find docs that name a file which was deleted or renamed | carta scan (missing_path_mention findings) |
.carta/scan-results.json |
| Reconcile CLAUDE.md against the docs (detect superseded sections, draft corrections) | carta claude-md check (report JSON) · carta claude-md record (stamp sync sidecar) · /claude-md-sync skill |
JSON findings |
Configuration
All settings live in .carta/config.yaml (generated by carta init from the template). Key fields:
project_name: my-project # namespaces your Qdrant collections
qdrant_url: http://localhost:6333 # required — where Qdrant is running
docs_root: docs/
stale_threshold_days: 30
contradiction_types:
- version numbers
- API endpoints
- configuration values
# add domain-specific ones: pin numbers, CAN IDs, SQL table names, etc.
anchor_doc: CLAUDE.md # fallback comparison anchor
modules:
doc_embed: true # set false to skip embed layer
doc_search: true # set false to skip search
embed:
ollama_model: nomic-embed-text:latest
Search reranking
Hybrid retrieval (dense + BM25, RRF-fused) is the default. An optional second-stage reranker reorders the candidate pool for better top-k precision:
search:
rerank:
enabled: true
backend: llm # cross-encoder | llm
model: BAAI/bge-reranker-base # backend=cross-encoder (fastembed, local ONNX)
llm_model: qwen3.5:0.8b # backend=llm: one listwise Ollama call per search
llm_timeout_s: 20
candidate_pool: 40 # docs fetched before reranking (default 30; ~40 suits the llm backend)
-
cross-encoder— fastembedbge-reranker-base, no Ollama, fast. -
llm— sends the query + candidate excerpts to a local Ollama model in a single call and reorders by its judgment. Fail-open: any error/timeout falls back to the fused order. Reasoning models are handled (thinkis disabled so the answer lands in the reply, not the thinking stream), and the parser tolerates a JSON array wrapped in stray prose.Model strength matters a lot. On a real technical-docs corpus (62-query eval), a strong reranker (
qwen3.5:9b) lifted recall@5 0.790 → 0.871 / MRR 0.641 → 0.778. The small default (qwen3.5:0.8b) is fast but can degrade ranking on harder corpora — use it for low latency, and a 9b-class model when retrieval quality is the priority (at higher per-query cost).
Reranking applies to explicit searches (carta search, the MCP carta_search tool, carta eval). The proactive-recall hook never reranks (and never loads ColPali) — it fires on
every prompt and blocks submission, so it always uses the fast fused order; its gray-zone judge
handles relevance filtering.
The gray-zone judge is a cross-encoder, not an LLM. When retrieval is neither clearly on
topic nor measurably irrelevant, the hook asks one question — does any of this answer the
prompt — and a cross-encoder answers exactly that question directly. The default
(Xenova/ms-marco-MiniLM-L-6-v2, via the fastembed dependency Carta already ships for
reranking) costs ~10 ms warm and ~0.5 s cold in a fresh process, needs no Ollama, and cannot
be derailed by a reasoning model deciding to think. Measured on 229 labelled passages from an
84-query eval corpus: it separates relevant passages from unrelated chunks at AUC 0.915,
where the binary qwen3.5:2b LLM judge it replaces reached 0.78. At the shipped threshold it
passes 80% of genuinely relevant passages while admitting under 5% of unrelated ones.
proactive_recall.judge_threshold (default -10.4) is a raw, model-specific logit — not a
probability. Re-derive it for a different judge_model, or for your own corpus:
python -m carta.hook.eval.calibrate chunks http://localhost:6333 myproj_doc chunks.jsonl
python -m carta.hook.eval.calibrate judge .carta/eval/myproj.yaml chunks.jsonl
judge_backend defaults to auto: the cross-encoder when fastembed is installed (it ships in
the carta-cc[hybrid] extra), the yes/no LLM judge when it is not — so a plain install still
judges instead of going quiet. Force either with judge_backend: crossenc / ollama (the
latter needs proactive_recall.ollama_model pulled). carta doctor reports whether the judge model is
cached and --fix fetches it, so the hook never downloads one while blocking a prompt.
Search budget. For the same reason, the hook runs under a wall-clock budget —
proactive_recall.search_timeout_s, default 3 s — covering the query embed and the Qdrant
queries together. If it expires the hook stays silent and the prompt proceeds immediately.
This matters most with a remote Qdrant. A dead localhost backend refuses instantly, so the
underlying 60 s embed timeout never binds; a dead remote peer drops packets with no RST, so
without a budget every prompt would stall for the full timeout. Only the hook is bounded —
carta search, MCP and carta eval keep their generous timeouts, since a slow search there is
fine and a slow prompt is not.
Hypothetical answers (HyDE)
A short question and the passage that answers it often share almost no words — especially when the answer is buried deep in a long PDF. Give the search a hypothetical answer and it blends that into the semantic match (the keyword lane still uses the query):
carta search "why do cached pages sometimes show old prices" \
--hypothetical "Cache entries are invalidated on write, but CDN copies expire only after their TTL."
The MCP carta_search tool takes the same hypothetical argument, and its description tells
Claude to write one; the /doc-search skill does too. The passage only has to be shaped like the
answer — it does not need to be correct. On an 84-query eval built not to saturate (deep-PDF,
vocabulary-mismatch and design-rationale questions), Claude-written hypotheticals lifted MRR
0.46 → 0.70 and recall@5 0.54 → 0.77 on the MCP path, and MRR 0.55 → 0.65 on the hybrid
CLI path. Carta never writes the hypothetical itself: a local qwen3.5:9b writer did not help, and
the caller that benefits is already a frontier model. Full measurement in
the design spec.
Graph-aware retrieval (opt-in)
An optional pre-rerank stage walks the related: frontmatter graph (undirected, 1 hop) from the
top hits and promotes graph-adjacent documents into the rerank candidate pool, so a relevant doc
that ranks too deep to be seen can still surface. Fail-open, and off by default:
search:
graph:
enabled: false # opt-in
hops: 1
seed_count: 10 # top fused hits that seed the walk
candidate_depth: 50 # deep-fetch size when enabled
The benefit is realized through the reranker (it never displaces the top fused hits on its own).
It was measured neutral on a corpus where a strong reranker already floats in-pool docs; it is
most likely to help corpora with a rich, well-linked related: graph and relevant docs that rank
deep. The companion related: resolver (id/path normalization) and the carta scan
noncanonical_related check ship regardless and feed link-graph cleanup.
The graph arm: which docs describe this file?
Search answers "which passage is about this?". The graph arm answers the reverse question, the one you have while editing code: "I am changing these files; which docs describe them?"
It works from what docs already say. A doc that writes src/parser/frame.py in backticks, links
to it, or shows it in a code block has declared a relation to that file. Carta extracts each such
mention as an edge (doc, target, line, and the line's text as evidence) and stores the edges
in one SQLite file, .carta/graph/edges.db. No model is involved: no embeddings, no Qdrant,
no Ollama. The same repository always gives the same edges, and the store is a cache you can
delete and rebuild.
carta graph build # incremental by content hash; --force re-extracts everything
carta graph status # counts, digest, and whether the store is stale (never builds)
$ carta graph build
Graph: 3 docs, 10 edges (3 re-extracted, 0 unchanged)
Then ask about paths, or about a git range (carta impact brings the store up to date first, so
graph build is optional):
carta impact src/parser/checksum.py src/store/db.py # these paths
carta impact src/parser/ # everything under a directory
carta impact --diff # <default-branch>...HEAD
carta impact --diff main..HEAD --json # any range, machine-readable
$ carta impact --diff
2 docs mention 4 changed paths (3 docs, 10 edges)
Range: main...HEAD
1. docs/design/parser.md score 5.119789
src/parser/old_frame.py exact, named by 1 doc
L3: The framing rules live in `src/parser/old_frame.py`; see `parse_frame()`.
L5: See [the frame source](../../src/parser/old_frame.py) for the state table.
src/parser/checksum.py exact, named by 1 doc
L4: The checksum is `src/parser/checksum.py`, and the old path was `src/parser/legacy.py`.
src/parser/legacy.py exact, named by 1 doc
L4: The checksum is `src/parser/checksum.py`, and the old path was `src/parser/legacy.py`.
2. docs/overview.md [already in this diff] score 1.654053
src/parser/checksum.py via src/parser/ (dir), named by 1 doc
L3: Everything under `src/parser/` and `src/store/` is covered by the parser design.
src/parser/frame.py via src/parser/ (dir), named by 1 doc
L3: Everything under `src/parser/` and `src/store/` is covered by the parser design.
src/parser/legacy.py via src/parser/ (dir), named by 1 doc
L3: Everything under `src/parser/` and `src/store/` is covered by the parser design.
src/parser/old_frame.py via src/parser/ (dir), named by 1 doc
L3: Everything under `src/parser/` and `src/store/` is covered by the parser design.
Skipped: docs/overview.md (doc)
(The range here renames old_frame.py to frame.py, deletes legacy.py and edits
checksum.py and docs/overview.md.)
Each result carries the lines that made the edge, so you can go straight to the sentence that may
now be wrong. Rarer targets count for more: a file named by 2 docs is more telling than one
named by 60. For any one changed path, a doc that names the exact file scores above one that
names its directory (a directory mention is worth half per level above the file). A directory or
glob mention is counted once per target, growing only as 1 + ln(n) with the n changed files
it covers, so on a large change a doc naming the directory can still pass a doc naming one of
the files. Deleted and renamed paths are found too: when a range deletes a path (or you ask
about a path that is no longer on disk), the docs are read against the path set as it was
before the deletion, so a doc that named the file by a partial path, a bare filename, a glob or
a link still comes back. That is the case where the doc most needs an edit. With --diff,
docs the range already edits are marked [already in this diff], and docs changed in the range
are left out of the query unless you pass --include-docs. Changed paths that no doc names are
listed at the end (No doc mentions: ...). Other flags: --top N, --min-score X, --kinds,
--no-build (reads the store as it is and never writes; carta graph status likewise, and both
report a damaged store instead of replacing it). The same query is available to agents as the carta_impact MCP tool, which returns
the --json object.
carta scan uses the same edges to report a doc that names a path which no longer exists
(missing_path_mention). It fires only when Carta can show the path was real: git history
deleted or renamed it (the finding then names the new path), or it is a markdown link pointing
into the repository. A path that never existed, a URL, a placeholder such as path/to/file.py,
a path git ignores (a build output or local config that was tracked once), anything inside a
fenced block, and anything in a changelog produce no finding.
graph:
enabled: true
doc_globs: ["**/*.md"] # which files are docs (the sources of edges)
identifiers:
enabled: true # store backticked identifiers such as `parse_frame()`
min_length: 3
stoplist: []
patterns: [] # project regexes: [{name, regex, where: all|prose|code}]
impact:
top: 10
min_score: 0.0
kinds: ["mention", "cochange"] # what docs name, and what git history shows
scan:
missing_paths: true
ignore_docs: ["CHANGELOG*", "**/CHANGELOG*"]
history_limit: 5000 # commits of history searched for deletions and renames
dangling_symbols: false # see "Symbol edges" below
ambiguous_symbols: false
This section is separate from search.graph above, which only controls related: expansion
inside retrieval. The top-level excluded_paths decides which docs are read; an excluded doc is
never a source of edges, but any path may still be named.
Symbol edges (optional). Docs name functions and classes more often than files. With a
symbol index, Carta resolves each backticked identifier to the file that defines it and stores a
symbol edge (doc, defining file, the identifier, the doc line). Carta still does not parse
code: it reads rows of (name, kind, file, line) that another tool wrote.
graph:
symbols:
provider: ctags # none (default) | ctags | file
file: "" # for provider: file, a path inside the repository
max_definitions: 1 # a name defined in more files than this makes no edge
provider: ctagsruns universal-ctags over the repository's code files if it is installed. It is recognised by what it can do (--list-featureslistsjson), not by its name: thectagsthat macOS ships is a different program and is passed over. SetCARTA_CTAGSto name a binary. The repository's own ctags option files are not read.provider: filereads a symbols file: JSON lines ({"name": "parse_frame", "kind": "function", "file": "src/frame.py", "line": 12}, optional"scope"and"language"), tab-separatedname kind file line [scope], or savedctags --output-format=jsonoutput. Any code-graph tool can be exported to the first two. The file has to be UTF-8;#lines are comments. A row counts only while its file is in the tree, so adding or deleting a code file updates the edges without the symbols file changing.- No provider, no binary or no file means no symbol edges, and
carta graph statussays which. It is never an error. An index that fails to load (too large, a symbolic link, lines but not one readable row, ctags crashing or running past its time limit) keeps the edges already stored and reports the store stale;carta impactthen says that the symbol matches it printed may be out of date (symbols.stalein the JSON).
A name defined in more than one file is ambiguous. It still resolves when the doc qualifies it
(Store.put, table.init) or mentions one of the defining files or a directory above it;
otherwise it makes no edge. On this repository 96.5% of 3,564 indexed names are defined in a
single file, and raising the limit to 2, 3 or 5 changed the replay score below by less than
0.001, so the default keeps the stricter rule. A qualifier that matches nothing in the
repository makes no edge either: subprocess.check_output is not your check_output, and
nothing in page.get_text says whose page it is (self., this. and cls. are the
exception). Locals, parameters and import aliases are never definitions, and neither is a key
in a data file (JSON, YAML, TOML, XML), a heading, or anything in a doc or under
excluded_paths.
Once a provider is configured, carta impact reads symbol edges too and labels them:
1. docs/parser.md score 0.878890
src/parser/frame.py symbol: the doc names an identifier defined here (1 doc does)
L3: Call `parse_frame()` on each chunk.
carta scan has two findings for identifiers, both off by default
(graph.scan.dangling_symbols, graph.scan.ambiguous_symbols). dangling_symbol reports a doc
that names an identifier which has left the code. Most identifiers no index knows are simply
not yours (a library class, a shell command), so absence is never enough: the name must occur as
a word in no code file today, and git must show the code had it at a commit that changed
that mention in the doc. An external name fails the second test and stays silent. It needs git
and no symbol index. On this repository it produced 10 findings from 103 candidates; by hand, 8
were symbols defined here and since removed, 2 were names the code once used and never
defined, and 6 of the 8 sat in dated plans. That is too small a sample, and too archival, to
switch on for everyone. ambiguous_symbol is informational: a name the index defines in too
many files to resolve (33 here, main and __init__ among them).
What it does not do:
- It does not parse source code. Edges come from doc text, and symbol edges from an index another tool wrote.
- It does not read paths written in plain prose. A path must be in backticks, a markdown link, a fenced block or frontmatter. That is deliberate: "read/write" and "and/or" are not paths.
- Backticked identifiers (
parse_frame(),MAX_RETRIES) are stored, and resolved to the files that define them only when a symbol provider is configured (above). Without one they play no part incarta impact. - It does not follow calls. A symbol edge says where a name is defined, not who uses it.
- It does not follow renames. A doc that still names the old path is a
carta scanfinding, because the text on the page is now wrong. - Its edges do not feed
carta search.
Is it any good on your repository? carta graph replay answers that from git history with no
labelling: for every mainline commit that touched both code and docs, the code it touched is the
query and the docs it edited are the answer. It reports recall@k, precision@k, MRR and any-hit
rate against three baselines scored on the same commits (most-edited docs, basename grep, and
basename grep with the same rarity weighting). Read the population line first: it says how
many commits were scanned, how many were eligible and why the rest were skipped, and a result on
too few commits is labelled inconclusive rather than a pass or a fail. The report also repeats
every table without "hub" docs such as a changelog. When there are hubs, judge by that second
table: a method that always answers "the changelog" scores well on the first one without
reading anything, so the first table mostly measures who predicts the hubs, and the verdict line
is printed under the second only.
carta graph replay # the whole mainline history of this repository
carta graph replay --last 300 --json out.json
carta graph replay --repo ../other-repo --exclude docs/archive/ # needs no Carta config there
carta graph replay --no-config # ignore .carta/config.yaml, use the built-in defaults
The repository being replayed is treated as untrusted: its config is never read through a
symbolic link, and its graph.patterns (regular expressions) are ignored unless you pass
--trust-config. The same holds for the graph arm as a whole: a doc that is a symbolic link, is
not a regular file or is larger than 2 MiB is never read, the edge store refuses to open through
a link, and file names are taken from git byte for byte.
Trees and file contents come from git objects only (never the working tree or the edge store),
so it can be pointed at any repository, and a doc is judged on what it said at the commit's
parent, not on what the commit itself added. The one thing it takes from the working tree is
its settings: graph: and excluded_paths from .carta/config.yaml when that file exists.
Those decide which paths are docs and therefore which commits count, so the report states where
its settings came from and lists the exclusions (Settings: line, settings in the JSON).
On this repository's own history (--range origin/main --no-config, 403 commits scanned, 54
eligible) the mention arm does not beat the baselines: MRR 0.191 against 0.731 for
most-edited docs and 0.226 for basename grep. Without the three hub docs only 21 commits are
left, which is too few to call (0.276 against 0.564 and 0.287). The docs edited alongside code
here (README, changelog) do not name files, and the docs that do name files are dated plans
nobody edits again. Numbers, populations and the reading of them are in
the spec.
Symbol edges can be scored the same way: --arm both=mention+symbol@max --symbols ctags. The replay
may only use what a commit's parent had, so --symbols ctags re-runs universal-ctags on each
parent's code, read from git objects into a scratch directory and cached per file content (the
whole history above takes about 8 s). --symbols tracked:PATH reads a symbols file the
repository tracks, as of each parent. --symbols file:PATH uses one file as it is today for
every commit, which leaks later knowledge; it is refused unless you add
--accept-symbol-leak, and the report then carries a warning. On the same 54 commits
(--range 792b6690), adding symbol edges to mentions moves MRR from 0.191 to 0.201 (paired
difference +0.010, 95% interval -0.000 to +0.021; 25 commits better, 4 worse, 25 tied) and
recall@10 from 0.262 to 0.315; symbol edges alone score 0.147. Without the hub docs (21
commits, too few to call) the two are level, 0.273 against 0.276. So symbol edges help a
little at depth and do not change the verdict. That interval sits on zero and moves with the
range: ending four commits later (58 eligible) the same clean run reads +0.018 (+0.001 to
+0.041), so "significant or not" is not something this history can settle. The leak is small
here: today's index scores the combined arm 0.003 higher than each parent's own (paired over
the 54 commits: 3 better, none worse, 51 tied).
Those numbers score the two kinds in one pool (@max). An arm with no @MODE, and
carta impact itself, fuse kinds with graph.impact.combine (default norm, see co-change
edges below). Under norm the same arm scores 0.185, which cannot be told from mentions alone
(paired -0.006, -0.020 to +0.006). All three kinds in one arm
(--arm all=mention+cochange+symbol) score MRR 0.407 against 0.489 for mentions plus
co-change (paired -0.082, -0.135 to -0.031; 5 commits better, 20 worse, 29 tied); without the
hub docs it is 0.355 against 0.381 (21 commits, too few to call). So on this history symbol
edges do not improve the default pair of kinds. That is reported as measured; no weight was
tuned to change it.
Co-change edges: what git history says moves together
Mention edges only exist where a doc names a file. A second kind of edge covers what nobody
wrote down: carta graph build also walks the first-parent git history and records, for every
pair of files, how often they changed in the same commit. No parsing, no model. One mainline
commit is one unit (one pull request on a squash-merge repository; on a merge-commit repository
the merge carries the whole branch).
$ carta graph build
Graph: 113 docs, 12,931 edges (113 re-extracted, 0 unchanged)
history: 5,419 co-change edges from 430 commits (438 new, 3 skipped as bulk)
carta impact reads both kinds and labels every reason, so "the doc names this file" is
never confused with "these changed together":
$ carta impact carta/cli.py --top 2
Top 2 of 49 docs that are tied to 1 path (113 docs, 12,991 edges)
named: the doc names the path. history: they changed in the same commits; that is correlation, not a claim that the doc describes it.
1. CLAUDE.md score 1.412548
carta/cli.py [history] changed together in 8 of 49 commits (git history; the doc need not name it)
carta/cli.py [named] exact, named by 42 docs
L27: - argparse (stdlib) - CLI argument parsing in `carta/cli.py`
In --json a history match has "kind": "cochange". --kinds mention or --kinds cochange
reads one kind alone. With --diff, a pair whose only shared commits are inside the range is
left out: a change is not evidence about itself.
How it counts, and what it will not do:
- A commit touching more than 30 paths adds nothing (
max_files). A formatting sweep or a vendored drop would otherwise couple everything to everything. - The score is confidence: the share of the changed file's commits that also changed the
other one. Two files that each change constantly, but separately, score low and get no edge
(
min_score0.1). Older co-changes count for less (half_life, in commits). - A deleted file loses its history. A file that is moved without being edited in the same commit keeps it; a move with edits starts again.
- It is incremental: only commits after the last one seen are read. A rebase, a branch switch or an amended commit is noticed and the history is mined again. One pass over 438 commits takes under a second.
- A repository with no commits and a directory that is not a repository give no edges and no
error. A shallow clone is mined as far as its history goes and the report says
shallow: true; depth 1 gives no edges. A history git cannot read (a damaged ref, a missing object) is a failure, never an empty history: the stored edges are kept andcarta graph statusreportscochange_git_failed. - A commit over the limit is counted without its paths ever being collected, and a build with
no new commit does not read the stored counts, so neither a huge commit nor a long history
makes
carta impactslow or large. - A file name is counted only if it is plain printable text: names with control characters, line separators or bidirectional overrides are never stored or printed.
- It claims no causation, and it needs history in proportion to the size of the tree.
graph:
impact:
kinds: ["mention", "cochange"]
combine: norm # how the two rankings are fused: norm | rrf | sum | max
cochange:
enabled: true # `carta graph build --no-cochange` skips it for one run
max_files: 30 # larger commits are bulk changes and are not counted
min_support: 1 # shared commits needed before a pair is an edge
min_score: 0.1 # and the confidence it must reach
measure: confidence # confidence | lift
half_life: 100 # commits after which a co-change counts half; 0 = never
pending_window: 1000 # with min_support > 1: forget a pair that does not recur
follow_renames: true # a pure move (same content) keeps its history
Does it help? Two evals, both from git objects only.
carta graph replay --arm mention=mention --arm cochange=cochange --arm both=mention+cochange
scores each kind and their union on the same commits; the co-change edges for a commit come only
from commits before it. On this repository (54 eligible commits) co-change alone has MRR 0.615
against 0.191 for mentions and 0.731 for "the most-edited docs"; the union scores 0.489. Without
the three hub docs 21 commits are left, too few to call (0.404, 0.276, 0.564; union 0.381). On a
second, larger history (306 eligible commits, 284 without its one hub) co-change alone and the
union each beat all three baselines with intervals clear of zero: MRR 0.499 and 0.526 against
0.255 for most-edited docs and 0.289 for basename grep, and 0.289 for mentions alone. The union
beats co-change alone on recall@5 there (+0.039 [+0.009, +0.073]) and ties it on MRR.
carta graph holdout is the stricter test for the edges themselves: build them from the history
before a cut, then for each later commit give a method one of its files and ask for the
others.
carta graph holdout # cut after the first 80% of commits
carta graph holdout --cut v1.4.0 --cochange lift=measure:lift --cochange base
carta graph holdout --graph imports=imports.tsv # score another tool's file graph too
On this repository (322 commits before the cut, 63 scored after it, 397 seed queries): MRR 0.724
for co-change, 0.401 for "the most-changed files", 0.365 for "the other files in this
directory". --graph NAME=PATH scores an external file-to-file graph (JSON or
src<TAB>dst[<TAB>weight] lines) on the identical queries. Carta cannot check when such a graph
was built, so build it from the tree at the cut.
The holdout does not score a path a commit deletes: the miner counts no co-change for a deletion, so the eval does not ask for one.
The defaults came from these runs: confidence beat lift on every population, a support floor of 2 lost recall on every population, and adding the two kinds' raw scores was no better than mentions alone, which is why they are fused after normalising each kind. That makes every number above in-sample: the defaults were chosen on the same populations the results are quoted from, and no repository or period was held back. In particular the union's recall@5 edge over co-change alone is the best of several fusion settings tried on that one population, with a lower bound of +0.009; treat it as "not worse" until it is confirmed on later history or a third repository. Numbers, populations and what is still open are in the spec.
Capturing notes (quirks, bug notes, helpful notes)
The write side of session memory. When you (or Claude) learn something durable about the project, save it:
carta remember "EZKontrol bench tests silently fail unless motor CAN is powered" \
--type quirk --title "EZKontrol bench power" --tags can,bench
or from Claude via the carta_remember MCP tool. Notes are plain markdown files with
doc_type frontmatter — quirk → docs/quirks/, bug-note/helpful-note → docs/notes/
(configurable via memory.quirks_dir / memory.notes_dir) — embedded into
{project}_notes and retrieved by the same hybrid search, reranker, and proactive-recall
hook as your docs. Search output labels them: [quirk] docs/quirks/2026-06-11-….md.
Notes are knowledge artifacts, not tool state: git-shareable, audited by carta scan
(staleness, links), exported by carta export, and still useful if you remove Carta.
Hand-written files work too — drop a markdown file in docs/quirks/ (or set
doc_type: quirk in frontmatter anywhere) and carta embed routes it correctly.
Visual Embedding (ColPali/ColQwen2)
Carta supports multimodal embedding of visually-rich PDF pages (datasheets, register maps, timing diagrams) using ColPali and ColQwen2 late-interaction retrieval.
Instead of converting visual content to text (lossy), this pathway:
- Embeds each PDF page as 1,024 patch vectors (128-dim) directly into Qdrant's multi-vector collection
- Stores the raw page PNG as a sidecar payload in
.carta/visual_cache/ - Enables visual search that returns actual page images alongside text results
Enable visual embedding:
# Install with visual dependencies
pip install 'carta-cc[visual]'
Then set in .carta/config.yaml:
embed:
colpali_enabled: true # force on; default is null (auto: search _visual when it exists)
colpali_model: "vidore/colqwen2-v1.0-hf" # HF-native variant; or vidore/colpali-v1.3-hf for lower VRAM
colpali_device: "cpu" # "cpu", "cuda", or "mps"
colpali_batch_size: 1 # pages per batch (1 for CPU)
colpali_sidecar_path: ".carta/visual_cache/"
Model Selection:
| Model | VRAM | Speed | Quality | Best For |
|---|---|---|---|---|
vidore/colqwen2-v1.0-hf |
~8GB | Slow | Highest | GPU servers |
vidore/colpali-v1.3-hf |
~6GB | Medium | High | Balanced GPU |
vidore/colSmol-500M |
~3GB | Medium | Good | CPU workstations |
vidore/colSmol-256M |
~2GB | Fast | Fair | CPU-only/laptops |
Notes:
- Visual embedding is additive — existing text embedding pipeline is unchanged
- Pages are classified automatically; only visually-rich pages are embedded
- Visual collections are separate:
{project_name}_visual(multi-vector) vs{project_name}_doc(text) - Search returns both text and visual results; visual hits include base64-encoded PNGs
Scoping heavy visual models
ColPali and the OCR/VLM vision pipeline are expensive. Several config knobs let you control exactly where and how they run.
colpali_scoped_paths — restrict ColPali to specific directories or globs
By default (colpali_scoped_paths: []) ColPali runs on every PDF once colpali_enabled: true. Set a non-empty list to restrict it to the directories or file patterns that actually contain visual-rich content:
embed:
colpali_enabled: true
colpali_scoped_paths:
- "docs/reference/datasheets/" # trailing slash = directory prefix
- "docs/diagrams/**/*.pdf" # ** glob = recursive match
Matching rules:
- Entries ending with
/match any file whose path starts with that directory prefix. - All other entries are glob patterns:
*matches within a single path segment;**matches across segments (including zero). - Empty list (
[], the default) means no restriction — all PDFs receive ColPali embedding.
Files outside the configured scopes are silently skipped for ColPali; the normal text-extraction pipeline still runs on them.
colpali_device: mps — Apple Silicon acceleration
Set colpali_device: "mps" on Apple Silicon Macs to run ColPali on the GPU via Metal. Falls back to cpu automatically if MPS is unavailable.
vision_routing — OCR/VLM routing mode
Controls which model pipeline the smart router uses for non-pure-text pages:
| Mode | Behaviour |
|---|---|
auto (default) |
Heuristic routing: STRUCTURED_TEXT→OCR, TEXT_WITH_IMAGES→VLM, FLATTENED→OCR→VLM fallback |
ocr |
Force every non-pure-text page through OCR only; never call the VLM |
vision |
Force every non-pure-text page through the VLM only; never call OCR |
off |
No model calls at all — every page is treated as text-only |
embed:
vision_routing: "ocr" # good default when glm-ocr handles your content well
vision_call_timeout_s — per-call timeout
The default is 300 seconds (raised from the old 120 s hardcoded value). Dense OCR tables can take several minutes on slower hardware; raise this if you see timeout errors:
embed:
vision_call_timeout_s: 600 # 10 minutes for very dense pages
Recommended ColQwen successor model
vidore/colqwen2.5-v0.2 is the current recommended ColQwen successor. It loads via the same native ColQwen2 transformers path and generally outperforms colqwen2-v1.0-hf on technical documents. Swap it in without any other config changes:
embed:
colpali_model: "vidore/colqwen2.5-v0.2"
Two-pass visual embedding
Running glm-ocr and ColPali inline during a normal carta embed adds minutes per image-heavy page and blocks the fast text-extraction pass. The two-pass workflow separates these concerns:
Pass 1 — fast text:
carta embed
Extracts text from all files as normal. Pages classified as TEXT_WITH_IMAGES or FLATTENED are recorded in the sidecar's visual_pending list instead of being processed inline. At the end of the run, Carta prints a nudge:
Visual queue: 42 page(s) across 18 file(s) await visual embedding. Run carta embed --visual to process them.
Pass 2 — slow, resumable visual processing:
carta embed --visual
Drains the visual queue. For each pending page it runs:
- glm-ocr text extraction → ingested into the hybrid text index
- ColPali page-image embedding → stored in the
_visualcollection
Each page is checkpointed (visual_pending → visual_done) as it completes, so the pass is resumable — interrupt at any time and re-run; only unfinished pages are retried.
Typical workflow:
carta embed # fast text; queues image-heavy pages, prints the nudge
carta embed --visual # slow, resumable: OCR text + ColPali for queued pages
Configuration:
embed:
two_pass_visual: true # default true — set false to revert to inline visual processing
visual_timeout_s: 3600 # per-file timeout for the --visual pass (default: 3600 s)
colpali_enabled: null # null = auto (search _visual when it exists); true = force; false = off
Once a _visual collection has content, carta search includes it automatically (the
default colpali_enabled: null is "auto") and fuses visual hits with text by rank (RRF). The
readiness check runs before the ColPali model loads, so projects with no visual content pay
nothing. Set colpali_enabled: false to opt out entirely.
Requirements for --visual:
The --visual pass requires the optional [visual] extra (torch + transformers for ColPali):
pip install 'carta-cc[visual]'
If the extra is absent, carta embed --visual prints install guidance and exits cleanly — the text corpus is unaffected.
Python version note: torch wheels may not be available for all Python versions. If installation fails, try a Python 3.12 venv:
python3.12 -m venv .venv && source .venv/bin/activate && pip install 'carta-cc[visual]'
Scoping and memory:
Use colpali_scoped_paths (see above) to restrict which directories receive visual treatment. To avoid memory pressure from glm-ocr and ColPali loading simultaneously, set the Ollama concurrency limit before running the visual pass:
export OLLAMA_MAX_LOADED_MODELS=1
carta embed --visual
Corpus integrity
carta doctor includes a corpus-integrity section that checks your embedded knowledge base for
data-quality issues — no Ollama calls required:
- Slug collisions — multiple files with the same filename stem that would have silently overwritten each other's Qdrant points (fixed in the embed pipeline; doctor flags any legacy cases).
- Empty-text points — points stored with an embedding-of-empty-string (common after PDF extraction failures) that are unfindable in practice.
- Chunk-count mismatches — sidecar says N chunks but Qdrant holds a different count, indicating a partial embed.
- Stuck-stale sidecars — files whose sidecar never transitioned back to
embeddedafter a successful re-embed.
Findings are included in carta doctor's JSON output alongside the existing environment checks.
Repairing a corpus:
carta embed --repair
Purges and force re-embeds all files flagged by the integrity checks, and fixes stuck-stale sidecars
in place. The summary reports: repaired / purged / flagged extraction_failed / queued-for-visual /
failed.
Agents: see
docs/field-notes.mdfor hard-won operating notes — running an unreleased build against another repo, the supersession-judge latency /judge_errorsgotcha, the eval-corpus validation loop, and thedoctor → embed --repairflow.
Status-line progress widget
While carta embed runs, it writes .carta/embed-status.json with live progress. The carta statusline command reads that file and prints a compact segment for the Claude Code status line:
⠹ carta 24/47 big.pdf 19m
✓ carta 47 files · 3.2k chunks
✗ carta 24/47 · 2 errors
Auto-wiring during carta init
If your ~/.claude/settings.json has a statusLine.command pointing to a .sh file, carta init will offer to wire the segment in automatically. It writes a .bak backup before editing.
Manual snippet — add this to your status-line script, before the line that prints $parts:
seg=$(command -v carta >/dev/null && carta statusline <<<"$input" 2>/dev/null)
[ -n "$seg" ] && parts="$parts │ $seg"
CLI wiring
carta statusline --install # wire into the script found in settings.json
carta statusline --uninstall # remove the wired block
.carta/embed-status.json is regenerated each run and should be gitignored (Carta does this automatically).
Sharing an embedded project
Embedding is the expensive part — vision models run over every PDF page and ColPali produces multi-vector visual embeddings, which can take hours on a capable GPU. A collaborator working on the same repository doesn't need to repeat that. Once one machine has embedded the project, it can hand the embedded state to another in one command on each side.
On the machine that has embedded the project:
carta export # -> ./carta-<project>-<date>.tar.gz
carta export -o ~/share/carta.tar.gz
carta export --no-visual # skip the _visual collection (smaller bundle)
This snapshots the project's Qdrant collections (_doc, _notes, _session, and
_visual unless --no-visual) and packs them with a copy of config.yaml, the
sidecars/ metadata, and a manifest into a single .tar.gz. The visual collection
is included by default.
On the receiving machine (Qdrant running, same qdrant/qdrant version):
carta import carta-myproject-20260608.tar.gz
carta import bundle.tar.gz --project otherproject # restore under a different name
carta import bundle.tar.gz --force # overwrite existing collections
Import restores each collection, writes config.yaml and any missing sidecars into
.carta/, and then carta search works immediately — no re-embedding. Existing
sidecars are never overwritten; existing collections block the import unless
--force is passed.
Notes
- Querying still needs Ollama (
nomic-embed-text) to embed the search query — lightweight, runs on CPU. Visual search additionally loads ColPali/ColQwen2 at query time, so setcolpali_device: cudaon a machine with an NVIDIA GPU. - Snapshots are Qdrant-version-coupled. Run a matching
qdrant/qdrantversion on both sides; import warns (but proceeds) on a version mismatch. - Keep your repo, Qdrant storage, and Ollama models on a native Linux filesystem
rather than a slow mount (e.g. under WSL, use
/home/..., not/mnt/c).
Issue lifecycle
Carta assigns stable AUDIT-NNN IDs that survive across audit runs:
new → persisting → needs-input → resolved → archived
After needs_input_at_audit_count consecutive audits without resolution, an issue is escalated to needs-input and added to docs/BACKLOG/TRIAGE.md as a DOC-NNN item. The audit report is the single source of truth — no separate state file.
What Carta doesn't do
- It doesn't replace your wiki or CMS.
- It doesn't auto-fix contradictions (it surfaces them; you or your agent decides what to do).
- It doesn't require a cloud service — everything runs locally by default.
- It doesn't add much overhead to projects with simple, stable docs.
Contributing
Issues and PRs welcome. The scanner, embed pipeline, and skill files are all designed to be readable and hackable.
License
MIT. Carta depends on PyMuPDF, which is licensed separately under AGPL-3.0 (or a commercial licence from Artifex); if you distribute software built on Carta, its terms apply to that dependency.
Metadata
Release files for carta-cc 0.18.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| carta_cc-0.18.0.tar.gz | 729.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| carta_cc-0.18.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.5 MB
Release files / carta_cc-0.18.0.tar.gz
| Download URL | carta_cc-0.18.0.tar.gz |
|---|---|
| Size | 729.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cdf91c5d811cf6fb4a639c9687d1749e630f5c80e52b3d243125d6bc522e3bbb
|
|
BLAKE2b-256 checksum How to use checksums |
4eb55a9c800f0f7e3c90687f3e923f937bdb73d7988ab23906ed7fd9cd189175
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.17
|
Release files / carta_cc-0.18.0-py3-none-any.whl
| Download URL | carta_cc-0.18.0-py3-none-any.whl |
|---|---|
| Size | 779.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c1af7609e6ce6fba846d9392c258a7a56df94f368cfb73b9473847a7574ccfe8
|
|
BLAKE2b-256 checksum How to use checksums |
0070719b31fac75deeff8eacc4c0f40b1de99dbff7b96e958ba0cf8f8afbd735
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.17
|