A local-first research assistant that answers questions about your own documents (PDF, EPUB, HTML, DOCX, Markdown) with inline, page-level citations, and measures whether those answers are any good. Hybrid retrieval (BM25 + vector + cross-encoder rerank) over Chroma and SQLite on your disk; Claude API or fully-local Ollama for generation.
A fluent answer with a confident citation is not the same as a correct one. So every answer carries a provenance record, separates what your sources say from what the model infers, and can be re-graded by a separate reviewer. The RAG techniques here are established ones; what this adds is the integrity layer and the measurement behind it.
- Settings are locked by experiment, not intuition.
TOP_K, parent-child retrieval, chunk sizes and the BM25/vector mix were each chosen by measuring alternatives with the in-repo eval harness. What didn't make the cut is recorded too, indocs/decisions.md. - Benchmarks anyone can re-run. The headline numbers come from a public corpus pinned by arXiv ID
and SHA-256, fetched by a script, reported with variance and caveats (
evals/). - Growth by addition. Every derived layer (citations, figures, tables, keywords, wiki, concept graph) is an idempotent sidecar that never mutates the chunk store. New capability is a new module, not a rewrite.
- Grounded answers with inline citations. Page numbers and sections, every passage inspectable.
- Evidence vs. interpretation. Each answer separates what your sources say from the model's synthesis, with per-claim grounding markers you can accept, reject or edit, so an inference is never mistaken for a fact (how answers work).
- Citation and concept graphs. Resolved reference edges, plus a deterministic concept skeleton (the LLM only annotates existing edges, it never invents structure) with gap detection that surfaces single-source concepts and thin bridges as leads to read next.
- Knowledge-currency markers — built, and currently opt-in. Advisory
contestedandsuperseded trendchips derived from cross-document stance and publication years; they inform, they never gate. They ship off (EPISTEMICS_MARKERS_ENABLED=trueenables them) because the stance pass behind them judges without seeing the document text — see Limitations. - Library workspace. Browsable grid with filters and folders; each document opens as five ordered blocks — metadata, connections, passages, figures, references — with the full bibliography and the figures extracted from the paper, readable at full size. Editable metadata that survives re-ingest, safe delete (OS trash first), selective ingestion, derived corpus wiki.
- A chat history you can keep tidy. Conversations are searchable and renameable, exportable as one markdown file, and removable in bulk — a soft delete that the same control undoes.
- Measurable quality. Eval harness with six scorers (deterministic plus LLM judge), DuckDB result store, per-turn cost tracking.
flowchart LR
subgraph Ingest
A["data/sources/<br/>PDF · EPUB · HTML · DOCX · MD"] --> B["extract to markdown<br/>(cached, page-marked)"]
B --> C["chunk<br/>(parent-child + baseline)"]
C --> D["embed<br/>(bge-base, local)"]
end
D --> E[("Chroma ×2<br/>vector stores")]
C --> F[("SQLite<br/>documents")]
B -. "idempotent sidecar runners:<br/>citations · figures · tables ·<br/>keywords · doc vectors" .-> F
subgraph Query
Q["question"] --> R["hybrid retrieve<br/>(BM25 + vector)"]
R --> RR["cross-encoder rerank"]
RR --> G["LLM synthesis<br/>(Claude API or Ollama)"]
G --> H["answer + inline citations<br/>+ provenance record"]
H -. "flagged" .-> V["reviewer agent<br/>(separate context)"]
end
E --> R
F --> R
bge-base-en-v1.5 embedder and bge-reranker-base cross-encoder, both local and swappable; Chroma
for vectors, SQLite for documents; Tauri + Svelte 5 desktop app over a FastAPI/SSE backend, plus a
CLI. Data flow and module contracts: docs/architecture.md.
Quality is measured, not asserted. The eval harness runs the full pipeline (retrieve, rerank,
generate) over a fixed question set on a public 10-paper arXiv corpus that anyone can rebuild.
5 trials on bge-base, latest run 2026-08-01, reported as mean ± trial-mean std:
| Scorer | Mean (n=5) | Trial-mean std | What it measures |
|---|---|---|---|
citation_overlap (0-1) |
1.000 | 0.000 | retrieval cited the correct source |
contains_all (0-1) |
0.932 | 0.014 | answer surfaces the required facts |
llm_judge (1-5) |
3.694 | 0.258 | reference-graded answer quality |
citation_overlap is 1.000 with zero variance because retrieval depends only on the deterministic
index; the generated-answer scorers wobble run-to-run around stable means. Cases are deliberately
strict, not tuned to score 1.0. Two caveats travel with these numbers: citation_overlap is
saturated on a 10-paper corpus, so it shows no regression at the available resolution rather than
ranking quality — on the 97-document library the same scorer spans 0.877-0.946 and does
discriminate; and this run's llm_judge band is wide enough that only changes larger than about
±0.5 would be visible. Full results, including the embedder comparison, the chunk-size sweep, the
weight sweep and reproduction steps, live in evals/.
Cost is measured separately from quality: launch and per-turn latency, ingest throughput, memory,
disk, and what each of those does as the corpus grows are in
docs/performance.md.
uv sync --extra cu130 --extra dev # or --extra cpu on a GPU-less box
uv run python -m scripts.download_corpus --demo # no corpus yet? 28 papers from arXiv
uv run python -m doc_assistant.ingest
just app # backend + desktop UIThen open Settings → Getting started and pick an answer engine: paste an Anthropic API key
(checked before it is saved, stored on your machine only) or point at a local
Ollama server for a free, fully offline run. Both paths are configurable
in-app, so there is no file to edit; .env still works and takes precedence if you prefer it.
First run, step by step: docs/QUICKSTART.md.
Full install, hardware guidance and Docker: docs/setup.md.
Everyday commands, enrichment passes and tests: docs/usage.md.
Re-read for this release; the full ledger lives in .claude/KNOWN_ISSUES.md.
-
An API key entered in the app is stored in plain text in your data folder — weaker than an OS keychain, which is the recorded upgrade path (ADR-034). Use
.env, which takes precedence, if you would rather manage the key yourself. -
A scanned page with no text layer at all is unreachable. Documents whose text hides behind a page image are now read correctly, but a pure image has nothing to fall back to. Recovering those needs OCR, which is designed and deliberately not built until its quality is measured — text that is wrong is worse than text that is absent, because absence is honest while garbage is retrievable and citable. One document of 97 in the development library.
-
Most reference links into your own library are withheld, on purpose. A document's bibliography is shown in full, but the links from a reference to the copy in your library are re-checked before being offered, and only exact-DOI or title-agreeing matches survive. On the development library that is 4 links where 16 are stored: the matcher resolves on first-author surname and year with no title comparison, and it runs once at ingest, so it is frozen at whatever your library looked like that day. Withholding is the honest half of the fix; the matcher itself is next (KI-45).
-
Validated at ~100 documents, not yet at thousands. Retrieval quality is benchmarked and holds. Memory used to be the limit and no longer is: both search indexes now live on disk, so backend RAM measures flat at about 2 GB regardless of corpus size (ADR-036). What binds now is the first ingest, which is dominated by PDF extraction at roughly 15 seconds per document, single-threaded, and disk at about 6 MB per document. Numbers and projections:
docs/performance.md. The enrichment layer still has its own corpus-linear hot paths and corpus-tuned thresholds, catalogued with a prioritized fix plan in the scale review, so don't bulk-ingest thousands of documents before those land. -
Local-model ceilings are real, and measured. A local model cites far less of what it writes: across 27 questions on a 97-document library — same prompt, same retrieval —
llama3.1:8bcarried inline citations on 36% of its sentences andqwen2.5:7bon 14%, against 81% for Claude Haiku. Answers stay grounded either way; more claims simply show as uncited. Small local models also place documents into a taxonomy at 70-87% precision, and their self-reported confidence carries almost no signal — on one model it was anti-correlated with correctness. Never auto-accept on it. Nothing is gated: the app states this where you choose the engine. -
Document metadata extraction is imperfect. A handful of documents still yield no title, or publisher furniture instead of one, and downstream layers that key on the title inherit that — the reference-link limitation above is the visible consequence.
-
Per-source "epistemic assessment" is off by default. The chips labelling a source contested / corroborated / single-source are withheld: they came from a stance pass that judges without ever seeing the document text and whose verdict moves with list position (one document, identical inputs, position varied alone → four different verdicts). Nothing was deleted —
EPISTEMICS_MARKERS_ENABLED=trueopts back in — and the rebuild is planned. Document year, relevance score and graph freshness are unaffected and still shown. -
Single-user, local-first by design. The FastAPI backend serves one desktop app on localhost; multi-client serving would need threadpool offloading (documented, not built).
-
Tested primarily on Windows plus CI on Linux; macOS (MPS) paths work but are unbenchmarked.
v0.5.0 (2026-08-11) — the library became somewhere to read, not just a list. Phase 6 + 7 in progress. Shipped: core RAG, the eval harness, the document store and library workspace, citation and doc-similarity graphs, the research-integrity layer (provenance, evidence/interpretation split, separate-context reviewer), a provider-agnostic LLM layer with in-app setup and live switching between Claude API and local Ollama, figures and tables, the corpus wiki, and the full concept-graph stack with gap detection. 1,647 tests · ruff / mypy / bandit clean.
Next: keyword quality — the extracted keyword layer is measured and does not yet partition a
corpus (1,376 keywords, 98% of them on a single document) — then opt-in LLM-assisted ingestion.
Release notes: CHANGELOG.md. Full roadmap: docs/ROADMAP.md.
| Quickstart | First run in ~10 minutes: API key or Ollama, then your documents |
| 60-second walkthrough | What to look at first |
| Setup · Usage | Install, hardware, Docker · commands, enrichment, tests |
| Architecture | Data flow and module contracts |
| Decisions | ADR index, and why each non-obvious choice was made |
| How answers work | Evidence/interpretation split, grounding markers |
| Evals | Quality benchmark write-ups and reproduction |
| Performance | Speed, memory, disk, the trade each optimisation made, and what happens at 10x |
Agent-facing coordination lives in AGENTS.md, deliberately separate from this README.
Apache-2.0, see LICENSE.txt.
