Compare commits

..

No commits in common. "main" and "0.15.0" have entirely different histories.
main ... 0.15.0

740 changed files with 33922 additions and 395478 deletions

View file

@ -1,20 +0,0 @@
{
"name": "haiku-rag",
"interface": {
"displayName": "haiku.rag"
},
"plugins": [
{
"name": "haiku-rag",
"source": {
"source": "local",
"path": "./plugins/haiku-rag"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}

View file

@ -1,14 +0,0 @@
{
"name": "haiku-rag",
"description": "The haiku.rag knowledge base as Claude Code tools and a skill.",
"owner": {
"name": "Yiorgis Gozadinos"
},
"plugins": [
{
"name": "haiku-rag",
"source": "./plugins/haiku-rag",
"description": "Search, read and analyze your haiku.rag knowledge base from Claude Code."
}
]
}

View file

@ -1,295 +0,0 @@
---
name: debug-evals
description: Debug haiku.rag evaluation runs in Logfire. Use when asked to look at Logfire for an eval run, find failing or low-scoring eval cases, compare runs, check citation quality (cited_map) or judge pass rate (answer_equivalent), or explain why an eval case failed. Drives the Logfire MCP against the `evals` service, or the Logfire HTTP query API when the MCP is not loaded. Also covers monitoring a run that is still in flight.
---
# Debug eval runs in Logfire
Eval runs (`evaluations/`) ship spans to Logfire under `service_name = 'evals'`.
This skill finds a run, surfaces its metrics and failures, and drills into a
single case. Read-only.
## How to query
1. Confirm the current schema with `mcp__logfire__query_schema_reference` (spans
and logs share the `records` table).
2. Run SQL with `mcp__logfire__query_run` (`query` + `project: "haiku"` +
`start_timestamp`/`end_timestamp`, max 14 days). The remote MCP is
org-scoped, so `project` is required; eval runs land in project `haiku`.
The same SQL works pasted into Logfire's Explore UI.
3. Read span attributes as JSON: `attributes->>'key'`, nested as
`attributes->'a'->'b'->>'c'`. Cast when needed: `(...)::float`, `(...)::int`.
4. Hand back a clickable trace with
`mcp__logfire__project_logfire_link(trace_id, project="haiku")`.
## When the Logfire MCP is not available
The `mcp__logfire__*` tools are not loaded in every session. The HTTP query API is
the fallback and needs no MCP:
```
POST https://logfire-eu.pydantic.dev/v2/query # EU projects
POST https://logfire-us.pydantic.dev/v2/query # US projects
Authorization: Bearer <api-key>
Content-Type: application/json
{"sql": "...", "min_timestamp": "2026-08-23T00:00:00Z"}
```
- **`min_timestamp` is mandatory** and silently bounds every result. Too recent a
value is indistinguishable from "no data".
- Read tokens are being replaced by **API keys** (`pylf_v2_<region>_...`). Same
`Authorization: Bearer` header; the region is in the prefix.
- Keep the key in `~/.logfire-read-key` (mode 600) and read it from there so it never
lands in a transcript. Helper next to this skill: `.claude/skills/debug-evals/lf-query.sh "<SQL>" [min_ts]`.
- The API is **project-scoped**. A key for the wrong project authenticates fine and
returns zero rows — it does not error. Diagnose in this order:
1. wrong region → `HTTP 401 Invalid read token` on the other host;
2. wrong project → auth succeeds, `count(*)` over months is 0;
3. right project → `SELECT service_name, count(*) ... GROUP BY service_name` shows
`evals`, `haiku-rag`, `haiku-ingester`.
Eval runs live in project **`haiku`**. There is an empty project named `evals`,
which is the natural wrong guess.
- **The API caps returned rows and does not say so.** Aggregate server-side
(`count(*)`, `avg(...)`, `sum(CASE WHEN ...)`) rather than pulling rows and counting
them locally. A pass rate computed from a clipped page is wrong and looks fine.
### JSON access via the HTTP API
`assertions`, `scores`, `metrics` and `case_name` are **keys inside the `attributes`
column, not columns** — `SELECT assertions` fails with `column not found`. Both of
these work:
```sql
attributes->'assertions'->'answer_equivalent' -- returns JSON
json_get_bool(attributes,'assertions','answer_equivalent','value')
json_get_float(attributes,'scores','cited_map','value')
json_get_int(attributes,'attributes','n_searches')
json_get_str(attributes,'attributes','citation_status')
```
Prefer the `json_get_*` form inside aggregates — it yields a typed value, so no cast
is needed and `sum(CASE WHEN ...)` behaves.
## Counting cases, not spans
**One exception appears once per span level.** A single failing case emits the same
`exception_type` on `case: {case_name}`, `execute {task}` and `invoke_agent agent`
(and often `chat {model}`), so a raw count over-reports by 3-4x. Always add
`AND span_name='case: {case_name}'` when counting failures. Cross-check that the
number equals the count of unjudged cases.
## Always report floor as well as judged
`assertion_pass_rate` **excludes unjudged cases from the denominator**, so cases that
died produce no verdict and silently inflate the headline. Report both:
- judged rate = passed / (cases - unjudged)
- floor = passed / cases
A run with 4.5% deaths reads ~3pp better than it is. Quote them together, always.
## Vocabulary
A run is one experiment span; its cases are direct children sharing its
`trace_id`.
- Experiment span: `span_name = 'evaluate {name}'` (scope `pydantic-evals`).
- `attributes->>'name'` — run label (the `--name` arg, or `{dataset}_qa_evaluation` / `{dataset}_retrieval_evaluation`).
- `attributes->>'dataset_name'` — dataset.
- `(attributes->>'assertion_pass_rate')::float` — overall judge pass rate (QA runs).
- `attributes->'logfire.experiment.metadata'->'metadata'` — run config: `target` (`rag-capability`|`analysis-capability`), `qa_model`, `embedder_model`, `chunk_size`, `search_limit`, `rerank_model`, `judge_model`, `qa_max_searches`, etc.
- `trace_id` — scopes the whole run.
- Case span: `span_name = 'case: {case_name}'` (scope `pydantic-evals`).
- `message``case: <id>`.
- `attributes->'assertions'->'answer_equivalent'->>'value'``'true'`/`'false'` (LLM judge verdict). `->>'reason'` — why.
- `attributes->'scores'->'cited_map'->>'value'` — citation average precision (0..1).
- `attributes->'scores'->'number_match'->>'value'` — numeric-answer match (datasets that use it).
- `duration` — task time in seconds.
- Inside each case the capability under test emits agent spans (scope `pydantic-ai`):
`execute {task}`, `invoke_agent agent`, `execute_tool {tool_name}`, `chat {model}`.
The service is `evals` regardless of model, so filter on `service_name = 'evals'`
first. `otel_scope_name` separates the layers (`pydantic-evals` for run/case,
`pydantic-ai` for the agent).
## Canned queries
Recent runs (pick a `trace_id` to drill in):
```sql
SELECT attributes->>'name' AS run,
attributes->>'dataset_name' AS dataset,
service_version,
(attributes->>'assertion_pass_rate')::float AS pass_rate,
start_timestamp, trace_id
FROM records
WHERE service_name='evals' AND span_name='evaluate {name}'
ORDER BY start_timestamp DESC
LIMIT 20;
```
Run summary (pass rate, mean citation score, mean task time):
```sql
SELECT count(*) AS cases,
avg(CASE WHEN attributes->'assertions'->'answer_equivalent'->>'value'='true'
THEN 1.0 ELSE 0.0 END) AS pass_rate,
avg((attributes->'scores'->'cited_map'->>'value')::float) AS mean_cited_map,
avg(duration) AS mean_task_seconds
FROM records
WHERE service_name='evals' AND span_name='case: {case_name}'
AND trace_id='<TRACE_ID>';
```
Failing cases (judge said not equivalent), newest first, with the reason:
```sql
SELECT message AS case_name, duration,
attributes->'assertions'->'answer_equivalent'->>'reason' AS reason
FROM records
WHERE service_name='evals' AND span_name='case: {case_name}'
AND trace_id='<TRACE_ID>'
AND attributes->'assertions'->'answer_equivalent'->>'value'='false'
ORDER BY start_timestamp;
```
Low-citation cases (answer may be right but grounding is weak):
```sql
SELECT message AS case_name,
(attributes->'scores'->'cited_map'->>'value')::float AS cited_map
FROM records
WHERE service_name='evals' AND span_name='case: {case_name}'
AND trace_id='<TRACE_ID>'
AND (attributes->'scores'->'cited_map'->>'value')::float < 0.5
ORDER BY cited_map;
```
Error / null cases in a run (an exception aborted the case):
```sql
SELECT message, span_name, exception_type, exception_message
FROM records
WHERE service_name='evals' AND trace_id='<TRACE_ID>' AND is_exception=true
ORDER BY start_timestamp
LIMIT 50;
```
Slowest cases (task time drives run cost):
```sql
SELECT message AS case_name, duration
FROM records
WHERE service_name='evals' AND span_name='case: {case_name}'
AND trace_id='<TRACE_ID>'
ORDER BY duration DESC
LIMIT 20;
```
Drill into one case's agent activity (all cases share the run `trace_id`, so
bound by the case's own time window):
```sql
SELECT span_name, message, duration, is_exception
FROM records
WHERE service_name='evals' AND trace_id='<TRACE_ID>'
AND otel_scope_name='pydantic-ai'
AND start_timestamp BETWEEN '<CASE_START>' AND '<CASE_END>'
ORDER BY start_timestamp;
```
## Workflow
1. List recent runs, pick the one to inspect by `name` + `start_timestamp`, note
its `trace_id`.
2. Run the summary query for the headline numbers (pass rate, mean_cited_map,
mean_task_seconds — always report task time).
3. Pull failing and low-citation cases, read the judge `reason`.
4. To understand one case, take its start/end from the case query and run the
agent-activity query, then `project_logfire_link(trace_id)` so the user can
expand that case in the UI.
## When a query returns nothing
Span names or attributes may have changed. Probe:
```sql
SELECT DISTINCT otel_scope_name, span_name
FROM records WHERE service_name='evals'
ORDER BY 1,2;
```
## Monitoring a run that is still in flight
An eval prints nothing until it finishes, so a live run's only progress signal is its
case spans. Everything below works mid-run.
Progress and ETA:
```sql
SELECT count(*) AS cases_done,
min(start_timestamp) AS first_case,
max(start_timestamp) AS latest_case,
avg(duration) AS avg_case_s
FROM records
WHERE service_name='evals' AND span_name='case: {case_name}'
AND start_timestamp > '<RUN_LAUNCH_TS>';
```
**Cases run serially**, so `ETA_total = total_cases * avg_case_s`. Verify rather than
assume: wall-clock per case (`latest_case - first_case` over `cases_done`) should equal
`avg_case_s`. If it does, concurrency is 1 and the multiplication is valid. Concurrent
requests seen on the model endpoint (`vllm:num_requests_running` > 1) are parallel
searches *within* one case, not parallel cases.
**Never estimate a run's length from a `--limit N` smoke.** Its "avg task time per
case" is a per-case duration on the easiest N cases of a deterministic prefix; the full
set ran 32% slower (72.7s vs 55.2s) on FRAMES. Smokes validate wiring, not wall-clock.
Live headline, behaviour and failure composition in one pass:
```sql
SELECT count(*) AS cases,
sum(CASE WHEN json_get_bool(attributes,'assertions','answer_equivalent','value')
THEN 1 ELSE 0 END) AS passed,
sum(CASE WHEN json_get(attributes,'assertions','answer_equivalent') IS NULL
THEN 1 ELSE 0 END) AS unjudged,
avg(json_get_float(attributes,'scores','cited_map','value')) AS cited_map,
avg(json_get_int(attributes,'attributes','n_requests')) AS req_per_case,
avg(json_get_int(attributes,'attributes','n_searches')) AS searches,
avg(json_get_int(attributes,'attributes','n_executions')) AS execs,
sum(json_get_int(attributes,'attributes','n_rejected_searches')) AS rejected,
sum(CASE WHEN json_get_str(attributes,'attributes','citation_status')='grounded'
THEN 1 ELSE 0 END) AS grounded
FROM records
WHERE service_name='evals' AND span_name='case: {case_name}'
AND start_timestamp > '<RUN_LAUNCH_TS>';
```
Why a case died, counted correctly:
```sql
SELECT sum(CASE WHEN exception_message LIKE '%token limit%' THEN 1 ELSE 0 END) AS token_limit,
sum(CASE WHEN exception_message NOT LIKE '%token limit%' THEN 1 ELSE 0 END) AS other,
count(*) AS dead_cases
FROM records
WHERE service_name='evals' AND is_exception
AND span_name='case: {case_name}'
AND start_timestamp > '<RUN_LAUNCH_TS>';
```
`ToolFailedError` is mostly **not** a defect — an exhausted search or code budget
reports failure to the model on purpose. `UnexpectedModelBehavior` is the one that
kills a case.
### The per-case diagnostic attributes
`attributes->'attributes'` on a case span carries what the capability actually did:
`n_requests`, `n_searches`, `n_search_calls`, `n_rejected_searches`, `n_failed_tools`,
`n_executions`, `cited_uris`, `cited_chunk_ids`, `searched_uris`, `citation_status`
(`grounded` | `missing` | `ungrounded`). `attributes->'metrics'` carries `requests`,
`input_tokens`, `output_tokens` for the case.
Cite rate comes from `citation_status`, not from `cited_map` — they answer different
questions, and conflating them has produced wrong claims before. And never steer on
raw cite rate: it is confounded by task success, so measure it among *correct* answers.

View file

@ -1,25 +0,0 @@
#!/usr/bin/env bash
# Query the Logfire API. Key is read from ~/.logfire-read-key and never echoed.
# usage: lf-query.sh "<SQL>" [min_timestamp]
set -uo pipefail
KEY_FILE="$HOME/.logfire-read-key"
[ -r "$KEY_FILE" ] || { echo "missing $KEY_FILE"; exit 2; }
SQL="${1:?need SQL}"
MIN="${2:-$(date -u -v-2d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '2 days ago' +%Y-%m-%dT%H:%M:%SZ)}"
python3 - "$SQL" "$MIN" <<'PY'
import json, os, sys, urllib.request, urllib.error
sql, min_ts = sys.argv[1], sys.argv[2]
key = open(os.path.expanduser("~/.logfire-read-key")).read().strip()
# region comes from the key prefix: pylf_v2_<region>_...
parts = key.split("_")
region = parts[2] if len(parts) > 3 and parts[0] == "pylf" else "eu"
req = urllib.request.Request(
f"https://logfire-{region}.pydantic.dev/v2/query",
data=json.dumps({"sql": sql, "min_timestamp": min_ts}).encode(),
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
)
try:
print(json.dumps(json.load(urllib.request.urlopen(req, timeout=120)), indent=2)[:6000])
except urllib.error.HTTPError as e:
print(f"HTTP {e.code}: {e.read().decode()[:600]}")
PY

View file

@ -1,203 +0,0 @@
---
name: debug-ingestion
description: Debug haiku.rag ingestion in Logfire. Use when asked to look at Logfire for ingestion, find failed or dead ingestion jobs, investigate retries or circuit-breaker events, trace a document through convert/chunk/embed/store, find which docling-serve instance served a request, spot slow conversions, or tell concurrent ingesters apart. Drives the Logfire MCP against the `haiku-ingester` service.
---
# Debug ingestion in Logfire
The ingester ships spans to Logfire under `service_name = 'haiku-ingester'` (or a
custom `OTEL_SERVICE_NAME` if set per process). This skill finds failing jobs,
traces a document through the pipeline, and pinpoints the docling-serve instance
that served a request. Read-only.
## How to query
1. Confirm the current schema with `mcp__logfire__query_schema_reference` (spans
and logs share the `records` table).
2. Run SQL with `mcp__logfire__query_run` (`query` + `project: "haiku"` +
`start_timestamp`/`end_timestamp`, max 14 days). The remote MCP is
org-scoped, so `project` is required; the ingester ships to project `haiku`.
The same SQL works pasted into Logfire's Explore UI.
3. Read span attributes as JSON: `attributes->>'key'`, cast when needed
(`(attributes->>'attempt')::int`).
4. Hand back a clickable trace with
`mcp__logfire__project_logfire_link(trace_id, project="haiku")`.
5. For recent exceptions tied to a file,
`mcp__logfire__query_find_exceptions_in_file` accepts
`client/documents.py`, `ingester/workers/pool.py`, or
`ingester/pollers/base.py`.
Adjust `service_name` if the operator set `OTEL_SERVICE_NAME` (e.g. per tenant).
Interactive `haiku-rag` ingests emit the same `document.*` spans under the CLI's
service name (or `unknown_service` for older runs), not `haiku-ingester`.
## Vocabulary
Span tree (all scope `haiku.rag`), each level nests under the one above and
shares its `trace_id`:
- Poller: `ingester.poller.sweep` | `ingester.poller.dry_run` |
`ingester.poller.watch_event`.
- `attributes->>'source_id'`; watch adds `change`, `uri`.
- sweep sets `skipped`, `skip_reason` (`pending_work` / `circuit_open`),
`upsert`, `delete`, `unchanged`, `consecutive_failures`. A failed sweep
records the exception on the span (`exception_type` / `exception_message`).
- Job: `ingester.job``attributes->>'source_id'`, `->>'uri'`, `->>'op'`
(`UPSERT`/`DELETE`), `(->>'attempt')::int`. `is_exception=true` marks a job
that raised.
- Document pipeline: `document.fetch` (`bytes`, `content_hash`),
`document.convert`, `document.chunk` (`chunks_created`), `document.embed`,
`document.store` (`op` `create`/`update`, `document_id`).
- docling-serve: `docling_serve.request``attributes->>'name'` (operation),
`->>'url'` (instance), `(->>'attempt')::int`. A retry emits a new span with a
different `url`, so failover shows as sibling spans.
Failures in Logfire are span-level: sweep and job exceptions sit on the span
(`is_exception`, `exception_type`, `exception_message`, `level >= 17`). A job that
raised `PermanentError` was dead-lettered on that attempt; `attempt >= 2` on an
`ingester.job` span is a retry. The worker circuit breaker opening emits a
dedicated event `span_name = 'ingester.worker breaker opened'` (attributes
`source_id`, `threshold`, `cooldown_s`), fired once per closed->open transition.
The ingester's per-job dead/reschedule narration stays on stderr and the queue
(`GET /jobs?status=dead` on the control-plane API), not Logfire.
## Canned queries
Job outcomes by source:
```sql
SELECT attributes->>'source_id' AS source_id,
attributes->>'op' AS op,
is_exception,
count(*) AS n
FROM records
WHERE service_name='haiku-ingester' AND span_name='ingester.job'
GROUP BY 1,2,3
ORDER BY n DESC;
```
Failed jobs with the exception and trace to drill in:
```sql
SELECT attributes->>'uri' AS uri,
(attributes->>'attempt')::int AS attempt,
exception_type, exception_message, trace_id
FROM records
WHERE service_name='haiku-ingester' AND span_name='ingester.job'
AND is_exception=true
ORDER BY start_timestamp DESC
LIMIT 50;
```
Retried and dead-lettered jobs (a `PermanentError` was dead-lettered on that
attempt; `TransientError` at a high `attempt` was retried):
```sql
SELECT attributes->>'uri' AS uri,
(attributes->>'attempt')::int AS attempt,
attributes->>'op' AS op,
exception_type, trace_id
FROM records
WHERE service_name='haiku-ingester' AND span_name='ingester.job'
AND (is_exception=true OR (attributes->>'attempt')::int > 1)
ORDER BY start_timestamp DESC
LIMIT 50;
```
Trace one document end-to-end (take `trace_id` from a job above):
```sql
SELECT span_name, duration, is_exception,
attributes->>'url' AS docling_url,
attributes->>'op' AS op
FROM records
WHERE trace_id='<TRACE_ID>'
ORDER BY start_timestamp;
```
Worker circuit-breaker trips (a source paused after consecutive transient
failures):
```sql
SELECT start_timestamp,
attributes->>'source_id' AS source_id,
(attributes->>'cooldown_s')::float AS cooldown_s
FROM records
WHERE service_name='haiku-ingester'
AND span_name='ingester.worker breaker opened'
ORDER BY start_timestamp DESC
LIMIT 50;
```
docling-serve instance health and failover:
```sql
SELECT attributes->>'url' AS instance,
(attributes->>'attempt')::int AS attempt,
is_exception,
count(*) AS n
FROM records
WHERE service_name='haiku-ingester' AND span_name='docling_serve.request'
GROUP BY 1,2,3
ORDER BY n DESC;
```
Slowest pipeline stages:
```sql
SELECT span_name,
attributes->>'uri' AS uri,
duration, trace_id
FROM records
WHERE service_name='haiku-ingester'
AND span_name IN ('document.convert','docling_serve.request','document.embed','document.chunk')
ORDER BY duration DESC
LIMIT 20;
```
Per-source sweep summary:
```sql
SELECT attributes->>'source_id' AS source_id,
sum((attributes->>'upsert')::int) AS upserts,
sum((attributes->>'delete')::int) AS deletes,
sum((attributes->>'unchanged')::int) AS unchanged,
max(attributes->>'skip_reason') AS last_skip_reason
FROM records
WHERE service_name='haiku-ingester' AND span_name='ingester.poller.sweep'
GROUP BY 1;
```
Tell concurrent ingesters apart (set distinct `OTEL_SERVICE_NAME` per process; a
single host still separates by `process_pid` / `service_instance_id`):
```sql
SELECT service_name, service_instance_id, process_pid, count(*) AS n
FROM records
WHERE span_name='ingester.job'
GROUP BY 1,2,3
ORDER BY n DESC;
```
## Workflow
1. Job outcomes by source shows where failures cluster.
2. Failed jobs surfaces the exception and each failing `trace_id`.
3. Trace one document end-to-end to see which stage failed and, for a docling
source, which `docling_url` served it (and whether it failed over).
4. Retried/dead-lettered jobs show what the queue kept struggling with; a
`ingester.worker breaker opened` event flags a source the pool paused. The
per-job dead/reschedule narration stays in the ingester console and the queue
API, not Logfire.
5. `project_logfire_link(trace_id)` for a document lets the user expand the
full tree.
## When a query returns nothing
Span names or attributes may have changed. Probe:
```sql
SELECT DISTINCT span_name
FROM records WHERE service_name='haiku-ingester'
ORDER BY 1;
```

View file

@ -3,37 +3,26 @@ on:
push:
branches:
- main
pull_request:
branches:
- main
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages-${{ github.ref }}
cancel-in-progress: false
contents: write
jobs:
build:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v9.0.0
- run: uv sync --group dev
- run: uv run zensical build
- uses: actions/configure-pages@v5
if: github.event_name == 'push'
- uses: actions/upload-pages-artifact@v3
if: github.event_name == 'push'
- name: Configure Git Credentials
run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- uses: actions/setup-python@v5
with:
path: ./site
deploy:
needs: build
if: github.event_name == 'push'
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
python-version: 3.x
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v4
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
- run: pip install mkdocs-material
- run: mkdocs gh-deploy --force

View file

@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v9.0.0
- uses: astral-sh/setup-uv@v4
with:
enable-cache: true
- name: Set up Python

View file

@ -12,7 +12,7 @@ jobs:
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v9.0.0
- uses: astral-sh/setup-uv@v4
with:
enable-cache: true
- name: Set up Python

View file

@ -1,7 +1,7 @@
name: Build & publish Docker slim image
name: Build & publish Docker image
on:
workflow_run:
workflows: ["Build & publish haiku.rag-slim to pypi"]
workflows: ["Build & publish haiku.rag to pypi"]
types:
- completed
workflow_dispatch:
@ -43,13 +43,15 @@ jobs:
VERSION=$(grep -oP '^version = "\K[^"]+' haiku_rag_slim/pyproject.toml)
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Build and push Docker slim image
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.slim
file: docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/ggozad/haiku.rag-slim:${{ steps.version.outputs.version }}
ghcr.io/ggozad/haiku.rag-slim:latest
ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }}
ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -14,9 +14,9 @@ jobs:
contents: read
id-token: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Generate server.json from template
@ -26,11 +26,9 @@ jobs:
mv server.json.tmp server.json
echo "Generated server.json with version: $VERSION"
- name: Install MCP Publisher
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release download --repo modelcontextprotocol/registry \
--pattern 'mcp-publisher_linux_amd64.tar.gz' -O mcp-publisher.tar.gz
LATEST_RELEASE=$(curl -s https://api.github.com/repos/modelcontextprotocol/registry/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
curl -L https://github.com/modelcontextprotocol/registry/releases/download/${LATEST_RELEASE}/mcp-publisher_linux_amd64.tar.gz -o mcp-publisher.tar.gz
tar -xzf mcp-publisher.tar.gz
chmod +x mcp-publisher
sudo mv mcp-publisher /usr/local/bin/

View file

@ -1,105 +0,0 @@
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v9.0.0
with:
enable-cache: true
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Install dependencies
run: uv sync --all-extras
- name: Lint
run: uv run ruff check
- name: Type check
run: uv run ty check
lint-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: app/frontend/pnpm-lock.yaml
- name: Install dependencies
working-directory: app/frontend
run: pnpm install --frozen-lockfile
- name: Lint and format check
working-directory: app/frontend
run: pnpm run check
test-uri-platforms:
name: URI paths (${{ matrix.os }}, py${{ matrix.python }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python: ["3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v9.0.0
# --noconftest: tests/conftest.py imports the project's dependencies,
# which this job deliberately does not install. test_uri.py is stdlib-only.
- name: Test platform URI paths
env:
PYTHONPATH: haiku_rag_slim
run: >
uv run --no-project --python ${{ matrix.python }} --with pytest
pytest tests/test_uri.py -q --noconftest -o addopts=
test:
needs: [lint, lint-frontend]
runs-on: ubuntu-latest
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v9.0.0
with:
enable-cache: true
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Install dependencies
run: uv sync --all-extras
- name: Cache HuggingFace models
id: hf-cache
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: huggingface-${{ runner.os }}-test-models-v2
- name: Pre-download tokenizer
if: steps.hf-cache.outputs.cache-hit != 'true'
run: uv run python -c "from transformers import AutoTokenizer; AutoTokenizer.from_pretrained('Qwen/Qwen3-Embedding-0.6B')"
- name: Pre-download cross-encoder test model
if: steps.hf-cache.outputs.cache-hit != 'true'
run: uv run python -c "from sentence_transformers import CrossEncoder; CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')"
- name: Run tests with coverage
env:
HF_HUB_OFFLINE: ${{ steps.hf-cache.outputs.cache-hit == 'true' && '1' || '0' }}
TRANSFORMERS_OFFLINE: ${{ steps.hf-cache.outputs.cache-hit == 'true' && '1' || '0' }}
run: uv run pytest -m "not integration" --cov --cov-report=xml --cov-report=term-missing:skip-covered
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
fail_ci_if_error: false

14
.gitignore vendored
View file

@ -5,7 +5,6 @@ build/
dist/
wheels/
*.egg-info
**/.DS_Store
# Virtual environments
.venv
@ -13,8 +12,6 @@ wheels/
# tests
.coverage*
evaluations/evaluations/data/
evaluations/scripts/*
!evaluations/scripts/build_t2_submission.py
tests/data/
.pytest_cache/
.ruff_cache/
@ -30,14 +27,5 @@ DEVNOTES.md
.mcpregistry_github_token
.mcpregistry_registry_token
# zensical site directory when doing local docs builds
# MkDocs site directory when doing local docs builds
site/
# Claude Code personal notes
.claude.local.md
# Publish shared Claude skills, keep local Claude settings private
!.claude/
.claude/*
!.claude/skills/
!.claude/skills/**

View file

@ -1,36 +1,22 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
- id: check-toml
- id: debug-statements
- repo: local
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.14.3
hooks:
# Run the linter.
- id: ruff
name: ruff check
entry: uv run ruff check --force-exclude
language: system
types: [python]
# Run the formatter.
- id: ruff-format
name: ruff format
entry: uv run ruff format --force-exclude --check
language: system
types: [python]
- id: ty
name: ty check
entry: uv run ty check
language: system
types: [python]
pass_filenames: false
- repo: local
- repo: https://github.com/RobertCraigie/pyright-python
rev: v1.1.407
hooks:
- id: biome
name: biome check
entry: bash -c 'cd app/frontend && npm run check'
language: system
files: ^app/frontend/
types_or: [javascript, jsx, ts, tsx, json]
- id: pyright

File diff suppressed because it is too large Load diff

216
README.md
View file

@ -1,37 +1,22 @@
# haiku.rag
# Haiku RAG
[![PyPI](https://img.shields.io/pypi/v/haiku.rag)](https://pypi.org/project/haiku.rag/)
[![Python](https://img.shields.io/pypi/pyversions/haiku.rag)](https://pypi.org/project/haiku.rag/)
[![Downloads](https://static.pepy.tech/badge/haiku-rag-slim/month)](https://pepy.tech/projects/haiku-rag-slim)
[![Docs](https://img.shields.io/badge/docs-ggozad.github.io-blue)](https://ggozad.github.io/haiku.rag/)
[![Tests](https://github.com/ggozad/haiku.rag/actions/workflows/test.yml/badge.svg)](https://github.com/ggozad/haiku.rag/actions/workflows/test.yml)
[![codecov](https://codecov.io/gh/ggozad/haiku.rag/graph/badge.svg)](https://codecov.io/gh/ggozad/haiku.rag)
Retrieval-Augmented Generation (RAG) library built on LanceDB.
Agentic RAG that answers questions about your own documents with citations to page numbers and section headings. Runs locally on an embedded database, no server required.
Built on [LanceDB](https://lancedb.com/), [Pydantic AI](https://ai.pydantic.dev/), and [Docling](https://docling-project.github.io/docling/). Full documentation at [ggozad.github.io/haiku.rag](https://ggozad.github.io/haiku.rag/).
`haiku.rag` is a Retrieval-Augmented Generation (RAG) library built to work with LanceDB as a local vector database. It uses LanceDB for storing embeddings and performs semantic (vector) search as well as full-text search combined through native hybrid search with Reciprocal Rank Fusion. Both open-source (Ollama) as well as commercial (OpenAI, VoyageAI) embedding providers are supported.
## Features
- **Hybrid search** — Vector + full-text with Reciprocal Rank Fusion
- **Multimodal & cross-modal search** — Multimodal embedders (vLLM, VoyageAI, Cohere) put picture vectors in the same space as text; supports text-as-query → figure hits and image-as-query
- **Question answering** — RAG capability with citations (page numbers, section headings)
- **Vision QA** — Vision-capable models receive figure bytes alongside chunk text; attach your own images to questions in `ask`, `analyze` and the chat TUI
- **Reranking** — local cross-encoders, Cohere, Zero Entropy, or vLLM
- **Analysis capability** — Complex analytical tasks via sandboxed Python code execution (aggregation, computation, multi-document analysis)
- **Evidence compaction** — Optional capability that replaces earlier questions' search results on the request with the evidence they cited, so long conversations stop resending everything they retrieved
- **Citation policy** — Optional capability that requires every answer to declare what grounds it, including declaring that nothing does
- **Conversational RAG** — Chat TUI and web application for multi-turn conversations with session memory
- **Document structure** — Stores full [DoclingDocument](https://docling-project.github.io/docling/concepts/docling_document/), enabling structure-aware context expansion
- **Multiple providers** — Embeddings: Ollama, OpenAI, VoyageAI, Cohere, LM Studio, vLLM (multimodal via `multimodal: true` on vLLM/VoyageAI/Cohere). QA: any model supported by Pydantic AI
- **Multi-database search** — Search, ask, analyze, or chat across named databases with source attribution on results and citations
- **Local-first** — Embedded LanceDB, no servers required. Also supports S3, GCS, Azure, and LanceDB Cloud
- **CLI & Python API** — Full functionality from command line or code
- **MCP server** — Expose as tools for AI assistants (Claude Desktop, etc.)
- **Visual grounding** — View chunks highlighted on original page images
- **Production ingester** — Long-lived `haiku-ingester` service with persistent SQLite queue, async worker pool with retries and a dead-letter queue, FS / HTTP / S3 / WebDAV source adapters, FastAPI control plane, and a browser dashboard for operators. See [docs/ingester.md](docs/ingester.md).
- **Tags** — Name database states with `haiku-rag tag` and roll back to them
- **Inspector** — TUI for browsing documents, chunks, and search results
- **Local LanceDB**: No external servers required, supports also LanceDB cloud storage, S3, Google Cloud & Azure
- **Multiple embedding providers**: Ollama, VoyageAI, OpenAI, vLLM
- **Multiple QA providers**: Any provider/model supported by Pydantic AI
- **Research graph (multiagent)**: Plan → Search → Evaluate → Synthesize with agentic AI
- **Native hybrid search**: Vector + full-text search with native LanceDB RRF reranking
- **Reranking**: Default search result reranking with MixedBread AI, Cohere, Zero Entropy, or vLLM
- **Question answering**: Built-in QA agents on your documents
- **File monitoring**: Auto-index files when run as server
- **40+ file formats**: PDF, DOCX, HTML, Markdown, code files, URLs
- **MCP server**: Expose as tools for AI assistants
- **CLI & Python API**: Use from command line or Python
## Installation
@ -40,139 +25,146 @@ Built on [LanceDB](https://lancedb.com/), [Pydantic AI](https://ai.pydantic.dev/
### Full Package (Recommended)
```bash
pip install haiku.rag
uv pip install haiku.rag
```
Includes all features: document processing, all embedding providers, and rerankers.
Using [uv](https://docs.astral.sh/uv/)? `uv pip install haiku.rag`
### Slim Package (Minimal Dependencies)
```bash
pip install haiku.rag-slim
uv pip install haiku.rag-slim
```
Install only the extras you need. See the [Installation](https://ggozad.github.io/haiku.rag/installation/) documentation for available options.
Install only the extras you need. See the [Installation](https://ggozad.github.io/haiku.rag/installation/) documentation for available options
## Quick Start
> **Note**: Requires an embedding provider (Ollama, OpenAI, etc.). See the [Tutorial](https://ggozad.github.io/haiku.rag/tutorial/) for setup instructions.
```bash
# Index a PDF
haiku-rag add-src paper.pdf
# Add documents
haiku-rag add "Your content here"
haiku-rag add "Your content here" --meta author=alice --meta topic=notes
haiku-rag add-src document.pdf --meta source=manual
# Search
haiku-rag search "attention mechanism"
haiku-rag search "query"
# Search with filters
haiku-rag search "query" --filter "uri LIKE '%.pdf' AND title LIKE '%paper%'"
# Ask questions
haiku-rag ask "Who is the author of haiku.rag?"
# Ask questions with citations
haiku-rag ask "What datasets were used for evaluation?"
haiku-rag ask "Who is the author of haiku.rag?" --cite
# Ask about an image (vision-capable model)
haiku-rag ask "Does this figure match the spec in the design doc?" --image figure.png
# Deep QA (multi-agent question decomposition)
haiku-rag ask "Who is the author of haiku.rag?" --deep --cite
# Analyze — complex analytical tasks via code execution
haiku-rag analyze "How many documents mention transformers?"
# Deep QA with verbose output
haiku-rag ask "Who is the author of haiku.rag?" --deep --verbose
# Interactive chat — multi-turn conversations with memory
haiku-rag chat
# Multiagent research (iterative plan/search/evaluate)
haiku-rag research \
"What are the main drivers and trends of global temperature anomalies since 1990?" \
--max-iterations 2 \
--confidence-threshold 0.8 \
--max-concurrency 3 \
--verbose
# Continuously ingest from configured sources (FS, HTTP, S3, WebDAV)
haiku-ingester serve
# Rebuild database (re-chunk and re-embed all documents)
haiku-rag rebuild
# Start server with file monitoring
haiku-rag serve --monitor
```
See [Configuration](https://ggozad.github.io/haiku.rag/configuration/) for customization options.
To customize settings, create a `haiku.rag.yaml` config file (see [Configuration](https://ggozad.github.io/haiku.rag/configuration/)).
## Python API
## Python Usage
```python
from haiku.rag.client import HaikuRAG
from haiku.rag.config import Config
from haiku.rag.research import (
ResearchContext,
ResearchDeps,
ResearchState,
build_research_graph,
stream_research_graph,
)
async with HaikuRAG("knowledge.lancedb", create=True) as rag:
# Index documents
await rag.create_document_from_source("paper.pdf")
await rag.create_document_from_source("https://arxiv.org/pdf/1706.03762")
async with HaikuRAG("database.lancedb") as client:
# Add document
doc = await client.create_document("Your content")
# Search — returns chunks with provenance
results = await rag.search("self-attention")
for result in results:
print(f"{result.score:.2f} | p.{result.page_numbers} | {result.content[:100]}")
# Search (reranking enabled by default)
results = await client.search("query")
for chunk, score in results:
print(f"{score:.3f}: {chunk.content}")
# QA with citations
answer, citations = await rag.ask("What is the complexity of self-attention?")
# Ask questions
answer = await client.ask("Who is the author of haiku.rag?")
print(answer)
for cite in citations:
print(f" [{cite.chunk_id}] p.{cite.page_numbers}: {cite.content[:80]}")
```
For direct agent composition, see the [capabilities documentation](https://ggozad.github.io/haiku.rag/capabilities/).
# Ask questions with citations
answer = await client.ask("Who is the author of haiku.rag?", cite=True)
print(answer)
# Multiagent research pipeline (Plan → Search → Evaluate → Synthesize)
# Graph settings (provider, model, max_iterations, etc.) come from config
graph = build_research_graph(config=Config)
question = (
"What are the main drivers and trends of global temperature "
"anomalies since 1990?"
)
context = ResearchContext(original_question=question)
state = ResearchState.from_config(context=context, config=Config)
deps = ResearchDeps(client=client)
# Blocking run (final result only)
report = await graph.run(state=state, deps=deps)
print(report.title)
# Streaming progress (log/report/error events)
async for event in stream_research_graph(graph, state, deps):
if event.type == "log":
iteration = event.state.iterations if event.state else state.iterations
print(f"[{iteration}] {event.message}")
elif event.type == "report":
print("\nResearch complete!\n")
print(event.report.title)
print(event.report.executive_summary)
```
## MCP Server
Use with AI assistants like Claude Code, Codex, and Claude Desktop:
Use with AI assistants like Claude Desktop:
```bash
haiku-rag mcp --stdio
haiku-rag serve --stdio
```
In Claude Code, install the plugin, which registers the server and a skill:
```bash
claude plugin marketplace add ggozad/haiku.rag
claude plugin install haiku-rag
```
In Codex, install the same plugin from its marketplace:
```bash
codex plugin marketplace add ggozad/haiku.rag
codex plugin add haiku-rag@haiku-rag
```
Add to your Claude Desktop configuration:
```json
{
"mcpServers": {
"haiku-rag": {
"command": "haiku-rag",
"args": ["mcp", "--stdio"]
}
}
}
```
Provides search, document reading, and analysis tools directly in your AI assistant.
Provides tools for document management and search directly in your AI assistant.
## Examples
See the [examples directory](examples/) for working examples:
- **[Docker Setup](examples/docker/)** - Complete Docker deployment with continuous ingestion (`haiku-ingester`) and MCP server
- **[Web Application](app/)** - Full-stack conversational RAG with CopilotKit frontend
- **[Interactive Research Assistant](examples/ag-ui-research/)** - Full-stack research assistant with Pydantic AI and AG-UI featuring human-in-the-loop approval and real-time state synchronization
- **[Docker Setup](examples/docker/)** - Complete Docker deployment with file monitoring and MCP server
- **[A2A Server](examples/a2a-server/)** - Self-contained A2A protocol server package with conversational agent interface
## Documentation
Full documentation at: https://ggozad.github.io/haiku.rag/
- [Quickstart](https://ggozad.github.io/haiku.rag/tutorial/) - Provider setup and first ingestion
- [Installation](https://ggozad.github.io/haiku.rag/installation/) - Packages and extras
- [Configuration](https://ggozad.github.io/haiku.rag/configuration/) - YAML reference
- [Installation](https://ggozad.github.io/haiku.rag/installation/) - Provider setup
- [Configuration](https://ggozad.github.io/haiku.rag/configuration/) - YAML configuration
- [CLI](https://ggozad.github.io/haiku.rag/cli/) - Command reference
- [Python API](https://ggozad.github.io/haiku.rag/python/) - Complete API docs
- [Capabilities](https://ggozad.github.io/haiku.rag/capabilities/) - Native Pydantic AI RAG and analysis capabilities
- [Tuning](https://ggozad.github.io/haiku.rag/tuning/) - Retrieval and answer-quality tuning
- [Ingester](https://ggozad.github.io/haiku.rag/ingester/) - Production ingester for continuous indexing from FS, HTTP, S3, and WebDAV
- [MCP](https://ggozad.github.io/haiku.rag/mcp/) - Model Context Protocol integration
- [Remote processing](https://ggozad.github.io/haiku.rag/remote-processing/) - Offload conversion to docling-serve
- [Applications](https://ggozad.github.io/haiku.rag/apps/) - Chat TUI, web app, and inspector
- [Benchmarks](https://ggozad.github.io/haiku.rag/benchmarks/) - Performance benchmarks
- [Changelog](https://ggozad.github.io/haiku.rag/changelog/) - Version history
- [Agents](https://ggozad.github.io/haiku.rag/agents/) - QA agent and multi-agent research
- [MCP Server](https://ggozad.github.io/haiku.rag/mcp/) - Model Context Protocol integration
- [Benchmarks](https://ggozad.github.io/haiku.rag/benchmarks/) - Performance Benchmarks
## License
This project is licensed under the [MIT License](LICENSE).
<!-- mcp-name is used by the MCP registry to identify this server -->
mcp-name: io.github.ggozad/haiku-rag

View file

@ -1,11 +0,0 @@
# API Keys (at least one required for LLM)
ANTHROPIC_API_KEY=your-anthropic-key
OPENAI_API_KEY=your-openai-key
# Host path of the LanceDB database, mounted at /data where haiku.rag.yaml
# places it
DB_VOLUME=./data/haiku.rag.lancedb
# Optional: Ollama base URL (if using local models)
# Use host.docker.internal to reach Ollama running on the host machine
OLLAMA_BASE_URL=http://host.docker.internal:11434

View file

@ -1,110 +0,0 @@
# haiku.rag Chat App
A conversational RAG interface built with [CopilotKit](https://copilotkit.ai/) and [pydantic-ai](https://github.com/pydantic/pydantic-ai)'s AG-UI protocol.
> **Note:** An illustrative example meant as a starting point, with no authentication. The compose files bind the backend to `127.0.0.1`; don't expose it to an untrusted network.
## Prerequisites
- Docker and Docker Compose
- A haiku.rag database (created via the `haiku-rag` CLI)
- An LLM API key (Anthropic, OpenAI, or local Ollama)
## Quick Start
1. **Set up environment variables:**
```bash
cp .env.example .env
# Edit .env with your API keys and database path
```
2. **Configure the LLM and embedding models:**
```bash
cp haiku.rag.yaml.example haiku.rag.yaml
# Edit haiku.rag.yaml to configure your models
```
3. **Start the app:**
```bash
docker compose up -d
```
4. **Open the chat interface:** http://localhost:3000
## Configuration
### Environment Variables
| Variable | Description | Required |
|----------|-------------|----------|
| `DB_VOLUME` | Host path of the LanceDB database the compose files mount at `/data`, where `haiku.rag.yaml` places it (default `./data/haiku.rag.lancedb`) | No |
| `HAIKU_RAG_CONFIG_PATH` | The configuration file; the compose files set it to the mounted `/app/haiku.rag.yaml` | No |
| `ANTHROPIC_API_KEY` | Anthropic API key | One LLM key required |
| `OPENAI_API_KEY` | OpenAI API key | One LLM key required |
| `OLLAMA_BASE_URL` | Ollama server URL (default: `http://host.docker.internal:11434`) | For local models |
| `LOGFIRE_TOKEN` | Pydantic Logfire token for debugging | No |
### haiku.rag.yaml
Configure the LLM, embeddings, and search settings:
```yaml
qa:
model:
provider: anthropic # or openai, ollama
name: claude-sonnet-4-20250514
embeddings:
model:
provider: ollama
name: nomic-embed-text
search:
limit: 10
```
See `haiku.rag.yaml.example` for all options.
## Development
For local development with hot reloading:
```bash
docker compose -f docker-compose.dev.yml up -d --build
```
- Backend code changes reload automatically
- Frontend available at http://localhost:3000
- Backend API at http://localhost:8001
## Architecture
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │────▶│ Backend │────▶│ haiku.rag │
│ (CopilotKit) │ │ (pydantic-ai) │ │ (LanceDB) │
│ localhost:3000 │ │ localhost:8001 │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
### Backend Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/v1/chat/stream` | POST | AG-UI chat streaming |
| `/api/documents` | GET | List documents in database |
| `/api/info` | GET | Database statistics |
| `/api/visualize/{chunk_id}` | GET | Visual grounding for chunks |
| `/health` | GET | Health check |
## Chat Capabilities
The chat can:
- **Search** your documents with hybrid vector + full-text search
- **Answer questions** with citations from your knowledge base
- **Filter by document** when you ask about specific files
- **Show visual grounding** for PDF/image sources

View file

@ -1,36 +0,0 @@
# syntax=docker/dockerfile:1
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
# Install haiku.rag-slim from workspace
COPY pyproject.toml uv.lock ./
COPY haiku_rag_slim/pyproject.toml haiku_rag_slim/README.md haiku_rag_slim/LICENSE haiku_rag_slim/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev --package haiku.rag-slim
COPY haiku_rag_slim haiku_rag_slim/
COPY README.md LICENSE ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-editable --no-dev --package haiku.rag-slim
# Install app backend dependencies
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install starlette uvicorn[standard] anthropic watchfiles
# Final layer
FROM python:3.13-slim
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY app/backend/*.py ./
RUN mkdir -p /data
ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -1,296 +0,0 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Any
from ag_ui.core import EventType, StateSnapshotEvent
from dotenv import find_dotenv, load_dotenv
from pydantic_ai import Agent
from pydantic_ai.ui import SSE_CONTENT_TYPE
from pydantic_ai.ui.ag_ui import AGUIAdapter
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response, StreamingResponse
from starlette.routing import Route
from haiku.rag.capabilities.compaction import (
create_capability as create_compaction,
)
from haiku.rag.capabilities.policy import (
create_capability as create_citation_policy,
)
from haiku.rag.capabilities.rag import AGENT_PREAMBLE, RAGState, create_capability
from haiku.rag.client import HaikuRAG
from haiku.rag.client.scope import DatabaseScope
from haiku.rag.config import get_config
from haiku.rag.telemetry import configure as configure_telemetry
from haiku.rag.utils import get_model
load_dotenv(find_dotenv(usecwd=True))
configure_telemetry(service_name="haiku-rag-app")
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# The configuration places the database. This app serves one.
config = get_config()
scope = DatabaseScope.resolve(config)
if scope.covers_multiple:
raise SystemExit(
f"lancedb.databases names {', '.join(scope.names)}; this app serves one "
"database: configure exactly one entry"
)
[database] = scope.databases
def _database_exists() -> bool:
"""A database behind a URI has no path to check."""
return database.db_path is None or database.db_path.exists()
logger.info(f"Database: {database.name} at {database.location}")
logger.info(f"QA Provider: {config.qa.model.provider}, Model: {config.qa.model.name}")
# Only HaikuRAG client is a singleton (expensive to create)
_client: HaikuRAG | None = None
_client_lock = asyncio.Lock()
async def get_client() -> HaikuRAG:
"""Get or create the cached client.
Guarded by a lock because the first request after startup can race with
itself: two concurrent callers would both pass the None check, each build
and enter a HaikuRAG, and the loser would leak its LanceDB connection.
"""
global _client
if _client is None:
async with _client_lock:
if _client is None:
client = HaikuRAG(config=config, create=True)
await client.__aenter__()
_client = client
return _client
@dataclass
class AppDeps:
state: dict[str, Any] = field(default_factory=dict)
capability = create_capability(config=config, defer_loading=False)
agent = Agent(
get_model(config.qa.model, config),
instructions=AGENT_PREAMBLE,
# Conversations here are multi-turn, so earlier questions are reduced to the
# evidence they cited rather than carried whole, and every answer declares
# what grounds it so the UI can show citations for all of them.
capabilities=[capability, create_compaction(), create_citation_policy()],
deps_type=AppDeps,
)
async def stream_chat(request: Request) -> Response:
"""Chat streaming endpoint with AG-UI protocol."""
body = await request.body()
accept = request.headers.get("accept", SSE_CONTENT_TYPE)
run_input = AGUIAdapter.build_run_input(body)
adapter = AGUIAdapter(agent=agent, run_input=run_input, accept=accept)
incoming_state = run_input.state if isinstance(run_input.state, dict) else {}
incoming_state.setdefault("rag", RAGState().model_dump(mode="json"))
deps = AppDeps(state=incoming_state)
async def event_stream():
async def with_final_state():
async for event in adapter.run_stream(deps=deps):
if getattr(event, "type", None) == EventType.RUN_FINISHED:
yield StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot=deps.state,
)
yield event
async for chunk in adapter.encode_stream(with_final_state()):
yield chunk
return StreamingResponse(
event_stream(),
media_type=accept,
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
async def health_check(_: Request) -> JSONResponse:
"""Health check endpoint."""
return JSONResponse(
{
"status": "healthy",
"qa_provider": config.qa.model.provider,
"qa_model": config.qa.model.name,
"db_path": str(database.location),
"db_exists": _database_exists(),
}
)
async def list_documents(_: Request) -> JSONResponse:
"""List all documents in the database."""
if not _database_exists():
return JSONResponse({"documents": [], "error": "Database not found"})
client = await get_client()
docs = await client.document_repository.list_all()
return JSONResponse(
{
"documents": [
{"id": doc.id, "title": doc.title, "uri": doc.uri} for doc in docs
]
}
)
async def db_info(_: Request) -> JSONResponse:
"""Get database info and statistics."""
if not _database_exists():
return JSONResponse(
{
"exists": False,
"path": str(database.location),
"documents": 0,
"chunks": 0,
}
)
from haiku.rag.store.info import get_database_stats
client = await get_client()
stats = await get_database_stats(client.store.db)
return JSONResponse(
{
"exists": True,
"path": str(database.location),
"documents": stats["documents"].get("num_rows", 0),
"chunks": stats["chunks"].get("num_rows", 0),
"documents_bytes": stats["documents"].get("total_bytes", 0),
"chunks_bytes": stats["chunks"].get("total_bytes", 0),
"has_vector_index": stats["chunks"].get("has_vector_index", False),
}
)
async def visualize_chunk(request: Request) -> JSONResponse:
"""Return visual grounding images for one or more chunks as base64.
The path param accepts comma-separated chunk ids (a merged citation's
constituent chunks). The optional ``refs`` query param is a JSON-encoded
list of the citation's ``doc_item_refs`` — the exact items the model saw —
so the highlight matches the cited content instead of re-expanding.
"""
import base64
import json
from io import BytesIO
chunk_id = request.path_params["chunk_id"]
refs: list[str] | None = None
refs_param = request.query_params.get("refs")
if refs_param:
try:
parsed = json.loads(refs_param)
except ValueError:
parsed = None
if isinstance(parsed, list):
refs = [str(x) for x in parsed]
if not _database_exists():
return JSONResponse({"error": "Database not found"}, status_code=404)
client = await get_client()
chunks = []
for cid in chunk_id.split(","):
chunk = await client.chunk_repository.get_by_id(cid)
if chunk:
chunks.append(chunk)
if not chunks:
return JSONResponse({"error": "Chunk not found"}, status_code=404)
images = await client.visualize_chunk(chunks, refs)
if not images:
return JSONResponse({"images": [], "message": "No visual grounding available"})
base64_images = []
for img in images:
buffer = BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
base64_images.append(base64.b64encode(buffer.read()).decode("utf-8"))
return JSONResponse(
{
"images": base64_images,
"chunk_id": chunk_id,
"document_uri": chunks[0].document_uri,
}
)
@asynccontextmanager
async def lifespan(_app: Starlette):
"""Shut down the cached HaikuRAG client cleanly on app exit.
Awaits any in-flight background vacuum tasks and closes the LanceDB
connection. Without this, vacuum tasks are cancelled abruptly and the
connection is never closed on process shutdown.
"""
yield
global _client
if _client is not None:
await _client.__aexit__(None, None, None)
_client = None
# Create Starlette app
app = Starlette(
routes=[
Route("/v1/chat/stream", stream_chat, methods=["POST"]),
Route("/api/documents", list_documents, methods=["GET"]),
Route("/api/info", db_info, methods=["GET"]),
Route("/api/visualize/{chunk_id}", visualize_chunk, methods=["GET"]),
Route("/health", health_check, methods=["GET"]),
],
middleware=[
Middleware(
CORSMiddleware, # type: ignore[invalid-argument-type]
allow_origins=["http://localhost:3000", "http://frontend:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
],
lifespan=lifespan,
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=True,
)

View file

@ -1,26 +0,0 @@
[project]
name = "haiku-rag-app"
version = "0.1.0"
description = "Conversational RAG application with haiku.rag"
requires-python = ">=3.12"
dependencies = [
"starlette>=0.50.0",
"uvicorn[standard]>=0.40.0",
"pydantic-ai-slim[ag-ui,anthropic,openai]>=2.18.0,<3.0.0",
"python-dotenv>=1.2.1",
"haiku.rag-slim>=0.82.1",
"logfire[pydantic-ai]>=3.17.0",
]
[dependency-groups]
dev = ["ty>=0.0.28", "ruff>=0.14.10"]
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["."]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

View file

@ -1,46 +0,0 @@
# Local development with hot reloading
# Usage: docker compose -f docker-compose.dev.yml up --build
services:
backend:
build:
context: ..
dockerfile: app/backend/Dockerfile
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
working_dir: /app/src
# No authentication; bound to loopback. The frontend reaches it over the compose network.
ports:
- "127.0.0.1:8001:8000"
environment:
- HAIKU_RAG_CONFIG_PATH=/app/haiku.rag.yaml
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
- LOGFIRE_TOKEN=${LOGFIRE_TOKEN:-}
volumes:
# haiku.rag.yaml places the database at /data.
- ${DB_VOLUME:-./data/haiku.rag.lancedb}:/data
- ./backend:/app/src:ro
- ./haiku.rag.yaml:/app/haiku.rag.yaml:ro
extra_hosts:
- "host.docker.internal:host-gateway"
frontend:
build:
context: frontend
dockerfile: Dockerfile
target: base
command: sh -c "pnpm install && pnpm dev"
working_dir: /app
ports:
- "3000:3000"
environment:
- BACKEND_URL=http://backend:8000
- HOSTNAME=0.0.0.0
volumes:
- ./frontend:/app
- frontend_node_modules:/app/node_modules
depends_on:
- backend
volumes:
frontend_node_modules:

View file

@ -1,31 +0,0 @@
services:
backend:
build:
context: ..
dockerfile: app/backend/Dockerfile
# No authentication; bound to loopback. The frontend reaches it over the compose network.
ports:
- "127.0.0.1:8001:8000"
environment:
- HAIKU_RAG_CONFIG_PATH=/app/haiku.rag.yaml
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
- LOGFIRE_TOKEN=${LOGFIRE_TOKEN:-}
volumes:
# haiku.rag.yaml places the database at /data.
- ${DB_VOLUME:-./data/haiku.rag.lancedb}:/data
- ./haiku.rag.yaml:/app/haiku.rag.yaml:ro
extra_hosts:
- "host.docker.internal:host-gateway"
frontend:
build:
context: frontend
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- BACKEND_URL=http://backend:8000
depends_on:
- backend

View file

@ -1,4 +0,0 @@
node_modules
.next
.git
*.log

View file

@ -1,30 +0,0 @@
# Dependencies
node_modules/
.pnpm-store/
# Next.js build
.next/
out/
# Production
build/
# Misc
.DS_Store
*.pem
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Env files
.env*.local
# Vercel
.vercel
# TypeScript
*.tsbuildinfo
next-env.d.ts

View file

@ -1,35 +0,0 @@
FROM node:22-alpine AS base
RUN corepack enable && corepack prepare pnpm@10.15.1 --activate
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View file

@ -1,16 +0,0 @@
import { NextResponse } from "next/server";
export async function GET() {
const backendUrl = process.env.BACKEND_URL || "http://backend:8000";
try {
const response = await fetch(`${backendUrl}/api/documents`);
const data = await response.json();
return NextResponse.json(data);
} catch {
return NextResponse.json(
{ documents: [], error: "Backend unavailable" },
{ status: 503 },
);
}
}

View file

@ -1,16 +0,0 @@
import { NextResponse } from "next/server";
export async function GET() {
const backendUrl = process.env.BACKEND_URL || "http://backend:8000";
try {
const response = await fetch(`${backendUrl}/api/info`);
const data = await response.json();
return NextResponse.json(data);
} catch {
return NextResponse.json(
{ exists: false, error: "Backend unavailable" },
{ status: 503 },
);
}
}

View file

@ -1,26 +0,0 @@
import { NextResponse } from "next/server";
export async function GET(
request: Request,
{ params }: { params: Promise<{ chunk_id: string }> },
) {
const { chunk_id } = await params;
const backendUrl = process.env.BACKEND_URL || "http://backend:8000";
const refs = new URL(request.url).searchParams.get("refs");
const query = refs ? `?refs=${encodeURIComponent(refs)}` : "";
try {
const response = await fetch(
`${backendUrl}/api/visualize/${chunk_id}${query}`,
);
const data = await response.json();
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch {
return NextResponse.json({ error: "Backend unavailable" }, { status: 503 });
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +0,0 @@
import Chat from "@/components/Chat";
export default function Home() {
return <Chat />;
}

View file

@ -1,482 +0,0 @@
"use client";
import {
CopilotChatMessageView,
CopilotChatView,
CopilotKitProvider,
defineToolCallRenderer,
UseAgentUpdate,
useAgent,
useCopilotKit,
} from "@copilotkit/react-core/v2";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { FilterIcon } from "../lib/icons";
import type { RAGState } from "../lib/sessionStorage";
import {
AGUI_STATE_KEY,
agentStateOf,
createSession,
getActiveSessionId,
getLatestCitations,
getSession,
normalizeRAGState,
updateSessionMessages,
} from "../lib/sessionStorage";
import CitationBlock from "./CitationBlock";
import DbInfo from "./DbInfo";
import DocumentFilter from "./DocumentFilter";
import SessionManager from "./SessionManager";
// AG-UI state is namespaced under AGUI_STATE_KEY (see sessionStorage).
interface AgentState {
[AGUI_STATE_KEY]?: RAGState;
}
// biome-ignore lint/suspicious/noExplicitAny: CopilotKit message objects vary at runtime
function serializeMessages(messages: any[]): any[] {
return JSON.parse(JSON.stringify(messages));
}
function SpinnerIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="tool-spinner"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
);
}
function CheckIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
);
}
function SearchIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
);
}
function MessageIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
</svg>
);
}
function ToolCallIndicator({
toolName,
status,
args,
}: {
toolName: string;
status: string;
args: Record<string, unknown>;
}) {
const isComplete = status === "complete";
const getToolIcon = () => {
switch (toolName) {
case "rag_search":
return <SearchIcon />;
case "rag_cite":
return <MessageIcon />;
default:
return <SearchIcon />;
}
};
const getToolLabel = () => {
switch (toolName) {
case "rag_search":
return "Search";
case "rag_cite":
return "Cite";
default:
return toolName;
}
};
const getDescription = () => {
switch (toolName) {
case "rag_search": {
const query = args.query as string;
return <span className="tool-query">{query}</span>;
}
case "rag_cite":
return <span className="tool-query">Registering citations</span>;
default:
return <span>Processing...</span>;
}
};
return (
<div className={`tool-call-card ${isComplete ? "complete" : "loading"}`}>
<div className="tool-status-icon">
{isComplete ? <CheckIcon /> : <SpinnerIcon />}
</div>
<div className="tool-content">
<div className="tool-header">
<span className="tool-badge">
{getToolIcon()}
{getToolLabel()}
</span>
<span className="tool-status-text">
{isComplete ? "Done" : "Working..."}
</span>
</div>
<div className="tool-description">{getDescription()}</div>
</div>
</div>
);
}
// Context for sharing chat state with the message view
const ChatStateContext = createContext<RAGState | null>(null);
// Wildcard tool call renderer for all server-side tools
const toolCallRenderers = [
defineToolCallRenderer({
name: "*",
render: ({ name, args, result }) => (
<ToolCallIndicator
toolName={name}
status={result !== undefined ? "complete" : "loading"}
args={(args ?? {}) as Record<string, unknown>}
/>
),
}),
];
// Custom message view that injects CitationBlocks after assistant responses.
// Uses CopilotChatMessageView's children render prop to post-process the
// rendered message elements and inject citations at the right positions.
function MessageViewWithCitations({
messages = [],
isRunning = false,
}: {
// biome-ignore lint/suspicious/noExplicitAny: AG-UI Message type is a broad union
messages?: any[];
isRunning?: boolean;
}) {
const ragState = useContext(ChatStateContext);
const latestCitations = ragState ? getLatestCitations(ragState) : [];
const cursor = isRunning ? (
<div key="cursor" className="streaming-cursor">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
) : null;
// CopilotChatMessageView renders one element per user/assistant message.
// Inject CitationBlocks after assistant responses that
// followed tool calls.
return (
<CopilotChatMessageView messages={messages} isRunning={isRunning}>
{({ messageElements }) => {
const result: React.ReactNode[] = [];
let elemIdx = 0;
let seenToolCalls = false;
for (const msg of messages) {
if (msg.role === "user") {
seenToolCalls = false;
}
if (
msg.role === "assistant" &&
Array.isArray(msg.toolCalls) &&
msg.toolCalls.length > 0
) {
seenToolCalls = true;
}
if (msg.role !== "user" && msg.role !== "assistant") continue;
if (elemIdx < messageElements.length) {
result.push(messageElements[elemIdx]);
elemIdx++;
}
// After an assistant text response that followed tool calls,
// show citations from the latest turn
if (msg.role === "assistant" && msg.content && seenToolCalls) {
if (latestCitations.length > 0) {
result.push(
<CitationBlock
key={`citations-${msg.id}`}
citations={latestCitations}
/>,
);
}
seenToolCalls = false;
}
}
while (elemIdx < messageElements.length) {
result.push(messageElements[elemIdx]);
elemIdx++;
}
return (
<>
{result}
{cursor}
</>
);
}}
</CopilotChatMessageView>
);
}
MessageViewWithCitations.Cursor = CopilotChatMessageView.Cursor;
function ChatContentInner({
sessionId,
onSessionChange,
}: {
sessionId: string;
onSessionChange: (id: string) => void;
}) {
const [filterOpen, setFilterOpen] = useState(false);
// Track selected document names locally (frontend-only)
const [selectedDocuments, setSelectedDocuments] = useState<string[]>([]);
const { agent } = useAgent({
agentId: "chat_agent",
updates: [
UseAgentUpdate.OnMessagesChanged,
UseAgentUpdate.OnStateChanged,
UseAgentUpdate.OnRunStatusChanged,
],
});
const { copilotkit: ck } = useCopilotKit();
// Set threadId (CopilotChat normally does this in its connect effect)
useEffect(() => {
agent.threadId = sessionId;
}, [agent, sessionId]);
const ragState = normalizeRAGState(
(agent.state as AgentState)?.[AGUI_STATE_KEY],
);
// Restore session from localStorage when agent reference changes.
// useAgent returns a provisional agent initially, then the real agent
// after runtime connects — re-run restore each time so messages stick.
useEffect(() => {
if (agent.messages.length > 0) return;
const session = getSession(sessionId);
// Seed state for the capabilities; the backend replaces it after each run.
// The whole namespace map goes back, not just the fields this UI reads:
// compaction and the citation policy read what earlier questions recorded.
agent.setState({ ...agent.state, ...agentStateOf(session ?? undefined) });
if (session && session.messages.length > 0) {
// biome-ignore lint/suspicious/noExplicitAny: AG-UI Message type is a broad union
agent.setMessages(session.messages as any[]);
}
}, [agent, sessionId]);
// Persist messages and state to localStorage.
// Read ragState from agent.state at effect time (not render time) so that
// restore and persist effects in the same commit see consistent state.
// biome-ignore lint/correctness/useExhaustiveDependencies: JSON.stringify tracks content changes
useEffect(() => {
if (sessionId && agent.messages.length > 0) {
updateSessionMessages(
sessionId,
serializeMessages(agent.messages),
(agent.state ?? {}) as Record<string, unknown>,
);
}
}, [JSON.stringify(agent.messages), ragState, sessionId]);
// Deduplicate messages by id to avoid React duplicate key warnings.
// biome-ignore lint/correctness/useExhaustiveDependencies: stable identity via agent ref
const messages = useMemo(() => {
const seen = new Map<string, number>();
const msgs = agent.messages;
for (let i = 0; i < msgs.length; i++) {
const id = msgs[i].id;
if (id) seen.set(id, i);
}
return msgs.filter((msg, i) => !msg.id || seen.get(msg.id) === i);
}, [JSON.stringify(agent.messages)]);
const onSubmitMessage = useCallback(
async (text: string) => {
agent.addMessage({
id: crypto.randomUUID(),
role: "user",
content: text,
});
try {
await ck.runAgent({ agent });
} catch (error) {
console.error("runAgent failed", error);
}
},
[agent, ck],
);
const onStop = useCallback(() => {
try {
ck.stopAgent({ agent });
} catch {
agent.abortRun();
}
}, [agent, ck]);
const handleFilterApply = (selected: string[]) => {
setSelectedDocuments(selected);
// Convert selected document names to SQL filter for the backend
const filter =
selected.length > 0
? selected
.map(
(name) =>
`(title LIKE '%${name.replace(/'/g, "''")}%' OR uri LIKE '%${name.replace(/'/g, "''")}%')`,
)
.join(" OR ")
: null;
agent.setState({
...agent.state,
[AGUI_STATE_KEY]: {
...ragState,
document_filter: filter,
},
});
};
return (
<ChatStateContext.Provider value={ragState}>
<div className="chat-wrapper">
<div className="chat-container">
<div className="chat-header">
<SessionManager
activeSessionId={sessionId}
onSessionChange={onSessionChange}
/>
<button
type="button"
className={`header-btn ${selectedDocuments.length > 0 ? "has-content" : ""}`}
onClick={() => setFilterOpen(true)}
title={
selectedDocuments.length > 0
? `Filtering: ${selectedDocuments.length} document(s)`
: "Filter documents"
}
>
<FilterIcon />
{selectedDocuments.length > 0
? `Filter (${selectedDocuments.length})`
: "Filter"}
</button>
</div>
<div className="chat-content">
<CopilotChatView
messageView={MessageViewWithCitations}
messages={messages}
isRunning={agent.isRunning}
onSubmitMessage={onSubmitMessage}
onStop={onStop}
>
{({ scrollView, input }) => (
<div className="chat-layout">
<div className="chat-scroll-area">{scrollView}</div>
<div className="chat-input-area">{input}</div>
</div>
)}
</CopilotChatView>
</div>
<DbInfo />
</div>
</div>
<DocumentFilter
isOpen={filterOpen}
onClose={() => setFilterOpen(false)}
selected={selectedDocuments}
onApply={handleFilterApply}
/>
</ChatStateContext.Provider>
);
}
export default function Chat() {
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
useEffect(() => {
let id = getActiveSessionId();
if (!id) {
id = createSession().id;
}
setActiveSessionId(id);
}, []);
if (!activeSessionId) return null;
return (
<CopilotKitProvider
key={activeSessionId}
runtimeUrl="/api/copilotkit"
useSingleEndpoint
renderToolCalls={toolCallRenderers}
>
<ChatContentInner
sessionId={activeSessionId}
onSessionChange={setActiveSessionId}
/>
</CopilotKitProvider>
);
}

View file

@ -1,226 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Citation } from "../lib/sessionStorage";
interface CitationBlockProps {
citations: Citation[];
}
interface VisualGroundingState {
isOpen: boolean;
chunkId: string | null;
images: string[];
loading: boolean;
error: string | null;
}
function CitationItem({
citation,
onViewInDocument,
}: {
citation: Citation;
onViewInDocument: (chunkId: string, refs?: string[]) => void;
}) {
const [expanded, setExpanded] = useState(false);
const title = citation.document_title || citation.document_uri || "Unknown";
const pageInfo =
citation.page_numbers.length > 0
? `p. ${citation.page_numbers.join(", ")}`
: null;
return (
<div className="citation-item">
<button
type="button"
className="citation-header"
onClick={() => setExpanded(!expanded)}
>
<span className="citation-index">[{citation.index}]</span>
<span className="citation-title">{title}</span>
{pageInfo && <span className="citation-page">{pageInfo}</span>}
<span className={`citation-chevron ${expanded ? "expanded" : ""}`}>
{expanded ? "▼" : "▶"}
</span>
</button>
{expanded && (
<div className="citation-content">
{citation.headings && citation.headings.length > 0 && (
<div className="citation-headings">
{citation.headings.join(" ")}
</div>
)}
<div className="citation-text">{citation.content}</div>
<button
type="button"
className="citation-view-btn"
onClick={() =>
onViewInDocument(
citation.chunk_ids?.length
? citation.chunk_ids.join(",")
: citation.chunk_id,
citation.doc_item_refs,
)
}
>
View in Document
</button>
</div>
)}
</div>
);
}
export default function CitationBlock({ citations }: CitationBlockProps) {
const [visualGrounding, setVisualGrounding] = useState<VisualGroundingState>({
isOpen: false,
chunkId: null,
images: [],
loading: false,
error: null,
});
// AbortController for the in-flight visualize fetch so a rapid close/reopen
// doesn't let a stale response overwrite the new request's state.
const abortRef = useRef<AbortController | null>(null);
// Abort any in-flight request on unmount.
useEffect(() => {
return () => abortRef.current?.abort();
}, []);
const fetchVisualGrounding = useCallback(
async (chunkId: string, refs?: string[]) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setVisualGrounding({
isOpen: true,
chunkId,
images: [],
loading: true,
error: null,
});
const query = refs?.length
? `?refs=${encodeURIComponent(JSON.stringify(refs))}`
: "";
try {
const response = await fetch(
`/api/visualize/${encodeURIComponent(chunkId)}${query}`,
{
signal: controller.signal,
},
);
const data = await response.json();
if (controller.signal.aborted) return;
if (!response.ok) {
throw new Error(data.error || "Failed to fetch visual grounding");
}
setVisualGrounding((prev) => ({
...prev,
images: data.images || [],
loading: false,
error: data.images?.length === 0 ? data.message : null,
}));
} catch (err) {
if (controller.signal.aborted) return;
setVisualGrounding((prev) => ({
...prev,
loading: false,
error: err instanceof Error ? err.message : "Unknown error",
}));
}
},
[],
);
const closeVisualGrounding = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setVisualGrounding({
isOpen: false,
chunkId: null,
images: [],
loading: false,
error: null,
});
}, []);
if (!citations || citations.length === 0) {
return null;
}
return (
<>
<div className="citation-block">
<div className="citation-block-header">
Sources ({citations.length})
</div>
{citations.map((citation) => (
<CitationItem
key={citation.chunk_id}
citation={citation}
onViewInDocument={fetchVisualGrounding}
/>
))}
</div>
{visualGrounding.isOpen && (
<div
className="visual-modal-overlay"
onClick={closeVisualGrounding}
onKeyDown={(e) => e.key === "Escape" && closeVisualGrounding()}
role="dialog"
aria-modal="true"
aria-label="Visual grounding"
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: modal content wrapper */}
<div
className="visual-modal"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<button
type="button"
className="visual-modal-close"
onClick={closeVisualGrounding}
>
</button>
<h3 className="visual-modal-title">Visual Grounding</h3>
{visualGrounding.loading && (
<div className="visual-modal-loading">Loading...</div>
)}
{visualGrounding.error && (
<div className="visual-modal-error">{visualGrounding.error}</div>
)}
{!visualGrounding.loading &&
!visualGrounding.error &&
visualGrounding.images.length > 0 && (
<div className="visual-modal-images">
{visualGrounding.images.map((img, idx) => (
// biome-ignore lint/suspicious/noArrayIndexKey: images have no stable id
<div key={idx}>
<div className="visual-modal-page-label">
Page {idx + 1} of {visualGrounding.images.length}
</div>
{/* biome-ignore lint/performance/noImgElement: base64 data URLs require img element */}
<img
src={`data:image/png;base64,${img}`}
alt={`Page ${idx + 1}`}
className="visual-modal-image"
/>
</div>
))}
</div>
)}
</div>
</div>
)}
</>
);
}

View file

@ -1,83 +0,0 @@
"use client";
import { useEffect, useState } from "react";
interface DbInfoData {
exists: boolean;
path: string;
documents: number;
chunks: number;
documents_bytes: number;
chunks_bytes: number;
has_vector_index: boolean;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
}
export default function DbInfo() {
const [info, setInfo] = useState<DbInfoData | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/info")
.then((res) => res.json())
.then(setInfo)
.catch((err) => setError(err.message));
}, []);
if (error) {
return (
<div className="db-info db-info-error">
<span>Database unavailable</span>
</div>
);
}
if (!info) {
return (
<div className="db-info db-info-loading">
<span>Loading...</span>
</div>
);
}
if (!info.exists) {
return (
<div className="db-info db-info-empty">
<span>No database found</span>
</div>
);
}
return (
<div className="db-info">
<div className="db-stat">
<span className="db-stat-value">{info.documents}</span>
<span className="db-stat-label">documents</span>
</div>
<div className="db-stat">
<span className="db-stat-value">{info.chunks}</span>
<span className="db-stat-label">chunks</span>
</div>
<div className="db-stat">
<span className="db-stat-value">
{formatBytes(info.documents_bytes + info.chunks_bytes)}
</span>
<span className="db-stat-label">total</span>
</div>
<div className="db-stat">
<span
className={`db-index-badge ${info.has_vector_index ? "indexed" : "not-indexed"}`}
>
{info.has_vector_index ? "indexed" : "no index"}
</span>
</div>
</div>
);
}

View file

@ -1,205 +0,0 @@
"use client";
import { useCallback, useEffect, useId, useState } from "react";
import { FilterIcon } from "../lib/icons";
interface Document {
id: string;
title: string | null;
uri: string | null;
}
interface DocumentFilterProps {
isOpen: boolean;
onClose: () => void;
selected: string[];
onApply: (selected: string[]) => void;
}
const getDisplayName = (doc: Document) => doc.title || doc.uri || doc.id;
export default function DocumentFilter({
isOpen,
onClose,
selected,
onApply,
}: DocumentFilterProps) {
const titleId = useId();
const [documents, setDocuments] = useState<Document[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
// Track selection by document id — two docs can share a title, but ids
// are unique. Display names are only used for rendering and for the
// filter string returned to the parent.
const [localSelected, setLocalSelected] = useState<Set<string>>(new Set());
// Refetch on every open so newly-added or deleted documents show up.
useEffect(() => {
if (!isOpen) return;
setLoading(true);
fetch("/api/documents")
.then((res) => res.json())
.then((data) => {
setDocuments(data.documents || []);
setLoading(false);
})
.catch(() => {
setLoading(false);
});
}, [isOpen]);
// Seed local selection from the parent's display-name list once documents
// are available. Any doc whose display name is in `selected` starts checked.
useEffect(() => {
if (!isOpen) return;
const selectedNames = new Set(selected);
setLocalSelected(
new Set(
documents
.filter((d) => selectedNames.has(getDisplayName(d)))
.map((d) => d.id),
),
);
setSearchTerm("");
}, [isOpen, selected, documents]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
},
[onClose],
);
const toggleDocument = (docId: string) => {
setLocalSelected((prev) => {
const next = new Set(prev);
if (next.has(docId)) {
next.delete(docId);
} else {
next.add(docId);
}
return next;
});
};
const handleApply = () => {
const names = documents
.filter((d) => localSelected.has(d.id))
.map(getDisplayName);
// Dedupe: two selected docs sharing a title collapse to one filter term.
onApply(Array.from(new Set(names)));
onClose();
};
const handleClearAll = () => {
setLocalSelected(new Set());
};
const filteredDocuments = documents.filter((doc) => {
if (!searchTerm) return true;
const displayName = getDisplayName(doc).toLowerCase();
return displayName.includes(searchTerm.toLowerCase());
});
if (!isOpen) {
return null;
}
return (
<div
className="filter-modal-overlay"
onClick={onClose}
onKeyDown={handleKeyDown}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: modal content wrapper */}
<div
className="filter-modal"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className="filter-modal-header">
<div className="filter-modal-icon">
<FilterIcon size={24} strokeWidth={1.5} />
</div>
<h2 id={titleId} className="filter-modal-title">
Filter Documents
</h2>
</div>
<p className="filter-modal-description">
Select documents to restrict searches. When active, only selected
documents will be searched.
</p>
<input
type="text"
className="filter-search"
placeholder="Search documents..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="filter-list">
{loading ? (
<div className="filter-loading">Loading documents...</div>
) : filteredDocuments.length === 0 ? (
<div className="filter-empty">
{searchTerm ? "No matching documents" : "No documents found"}
</div>
) : (
filteredDocuments.map((doc) => {
const displayName = getDisplayName(doc);
return (
<label key={doc.id} className="filter-item">
<input
type="checkbox"
checked={localSelected.has(doc.id)}
onChange={() => toggleDocument(doc.id)}
/>
<span className="filter-item-label">{displayName}</span>
</label>
);
})
)}
</div>
<div className="filter-footer">
<div className="filter-count">
{localSelected.size > 0 ? (
<>
<strong>{localSelected.size}</strong> document
{localSelected.size === 1 ? "" : "s"} selected
<button
type="button"
className="filter-btn filter-btn-clear"
onClick={handleClearAll}
>
Clear all
</button>
</>
) : (
"No filter (all documents)"
)}
</div>
<div className="filter-buttons">
<button
type="button"
className="filter-btn filter-btn-secondary"
onClick={onClose}
>
Cancel
</button>
<button
type="button"
className="filter-btn filter-btn-primary"
onClick={handleApply}
>
Apply
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,268 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { formatRelativeTime } from "../lib/format";
import {
createSession,
deleteSession,
exportSessionToMarkdown,
getAllSessions,
type StoredSession,
setActiveSessionId,
} from "../lib/sessionStorage";
interface SessionManagerProps {
activeSessionId: string | null;
onSessionChange: (sessionId: string) => void;
}
function HistoryIcon() {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M12 7v5l4 2" />
</svg>
);
}
function PlusIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 5v14" />
<path d="M5 12h14" />
</svg>
);
}
function DownloadIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
);
}
function TrashIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
);
}
export default function SessionManager({
activeSessionId,
onSessionChange,
}: SessionManagerProps) {
const [isOpen, setIsOpen] = useState(false);
const [sessions, setSessions] = useState<StoredSession[]>([]);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) setSessions(getAllSessions());
}, [isOpen]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
) {
setIsOpen(false);
setConfirmDelete(null);
}
}
if (isOpen) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
const handleNewSession = () => {
const session = createSession();
setSessions(getAllSessions());
setIsOpen(false);
onSessionChange(session.id);
};
const handleSelectSession = (id: string) => {
setActiveSessionId(id);
setIsOpen(false);
onSessionChange(id);
};
const handleDelete = (id: string) => {
deleteSession(id);
const remaining = getAllSessions();
setSessions(remaining);
setConfirmDelete(null);
if (id === activeSessionId) {
if (remaining.length > 0) {
setActiveSessionId(remaining[0].id);
onSessionChange(remaining[0].id);
} else {
const session = createSession();
setSessions(getAllSessions());
onSessionChange(session.id);
}
}
};
const handleExport = (session: StoredSession) => {
exportSessionToMarkdown(session);
};
const activeTitle =
sessions.find((s) => s.id === activeSessionId)?.title ?? "Sessions";
return (
<div ref={dropdownRef} style={{ position: "relative" }}>
<button
type="button"
className="header-btn"
onClick={() => setIsOpen(!isOpen)}
title="Session history"
>
<HistoryIcon />
<span
style={{
maxWidth: 120,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{activeTitle}
</span>
</button>
{isOpen && (
<div className="session-dropdown">
<div className="session-dropdown-header">
<span>Sessions</span>
<button
type="button"
className="new-session-btn"
onClick={handleNewSession}
>
<PlusIcon />
New
</button>
</div>
<div className="session-list">
{sessions.length === 0 && (
<div
style={{
padding: "16px",
textAlign: "center",
color: "#94a3b8",
fontSize: "13px",
}}
>
No sessions yet
</div>
)}
{sessions.map((session) => (
<div
key={session.id}
className={`session-item ${session.id === activeSessionId ? "active" : ""}`}
>
<button
type="button"
className="session-item-content"
onClick={() => handleSelectSession(session.id)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSelectSession(session.id);
}}
>
<div className="session-item-title">{session.title}</div>
<div className="session-item-meta">
<span>{session.messages.length} messages</span>
<span>{formatRelativeTime(session.updatedAt, true)}</span>
</div>
</button>
{confirmDelete === session.id ? (
<div className="confirm-delete">
<button
type="button"
className="confirm-yes"
onClick={() => handleDelete(session.id)}
>
Delete
</button>
<button
type="button"
className="confirm-no"
onClick={() => setConfirmDelete(null)}
>
Cancel
</button>
</div>
) : (
<div className="session-actions">
<button
type="button"
className="session-action-btn"
onClick={() => handleExport(session)}
title="Export to markdown"
>
<DownloadIcon />
</button>
<button
type="button"
className="session-action-btn danger"
onClick={() => setConfirmDelete(session.id)}
title="Delete session"
>
<TrashIcon />
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -1,18 +0,0 @@
export function formatRelativeTime(dateStr: string, compact = false): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const seconds = Math.floor((now - then) / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return compact
? `${minutes}m ago`
: `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24)
return compact
? `${hours}h ago`
: `${hours} hour${hours === 1 ? "" : "s"} ago`;
const days = Math.floor(hours / 24);
return compact ? `${days}d ago` : new Date(dateStr).toLocaleDateString();
}

View file

@ -1,46 +0,0 @@
interface IconProps {
size?: number;
strokeWidth?: number;
}
export function BrainIcon({ size = 18, strokeWidth = 2 }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" />
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z" />
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4" />
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375" />
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" />
<path d="M3.477 10.896a4 4 0 0 1 .585-.396" />
<path d="M19.938 10.5a4 4 0 0 1 .585.396" />
<path d="M6 18a4 4 0 0 1-1.967-.516" />
<path d="M19.967 17.484A4 4 0 0 1 18 18" />
</svg>
);
}
export function FilterIcon({ size = 18, strokeWidth = 2 }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
);
}

View file

@ -1,191 +0,0 @@
export interface Citation {
index: number;
document_id: string;
chunk_id: string;
chunk_ids?: string[];
document_uri: string;
document_title: string | null;
page_numbers: number[];
headings: string[] | null;
content: string;
doc_item_refs?: string[];
}
// Matches RAGState from the backend capability. The fields named here are the
// ones this UI reads; the capability owns the rest of its namespace, including
// the evidence record that compaction builds its capsule from, so the state has
// to round-trip whole rather than be rebuilt from known keys.
export interface RAGState {
citation_index: Record<string, Citation>;
citations: string[];
document_filter: string | null;
searches: Record<string, unknown[]>;
[key: string]: unknown;
}
export interface StoredMessage {
id: string;
role?: string;
content?: string;
[key: string]: unknown;
}
export interface StoredSession {
id: string;
title: string;
messages: StoredMessage[];
// The whole AG-UI state. The rag namespace is not the only one a capability
// writes: the citation policy records violations beside it.
agentState: AgentState;
// Sessions stored before agentState existed.
ragState?: RAGState;
createdAt: string;
updatedAt: string;
}
export const AGUI_STATE_KEY = "rag";
export type AgentState = Record<string, unknown>;
// Reads the rag namespace out of a stored session, whichever way it was stored.
export function ragStateOf(session?: StoredSession): RAGState {
const namespaced = session?.agentState?.[AGUI_STATE_KEY] as
| Partial<RAGState>
| undefined;
return normalizeRAGState(namespaced ?? session?.ragState);
}
// The state to seed an agent with when a session is resumed.
export function agentStateOf(session?: StoredSession): AgentState {
return session?.agentState ?? { [AGUI_STATE_KEY]: ragStateOf(session) };
}
const SESSIONS_KEY = "haiku.rag.sessions";
const ACTIVE_SESSION_KEY = "haiku.rag.activeSession";
export function normalizeRAGState(state?: Partial<RAGState>): RAGState {
return {
...state,
citation_index: state?.citation_index ?? {},
citations: state?.citations ?? [],
document_filter: state?.document_filter ?? null,
searches: state?.searches ?? {},
};
}
export function getLatestCitations(state: RAGState): Citation[] {
return state.citations
.map((id) => state.citation_index[id])
.filter((c): c is Citation => c !== undefined);
}
export function getAllSessions(): StoredSession[] {
const raw = localStorage.getItem(SESSIONS_KEY);
if (!raw) return [];
try {
return JSON.parse(raw) as StoredSession[];
} catch {
return [];
}
}
export function getSession(id: string): StoredSession | null {
return getAllSessions().find((s) => s.id === id) ?? null;
}
export function getActiveSessionId(): string | null {
return localStorage.getItem(ACTIVE_SESSION_KEY);
}
export function setActiveSessionId(id: string): void {
localStorage.setItem(ACTIVE_SESSION_KEY, id);
}
export function createSession(): StoredSession {
const now = new Date().toISOString();
const session: StoredSession = {
id: crypto.randomUUID(),
title: "New Session",
messages: [],
agentState: { [AGUI_STATE_KEY]: normalizeRAGState() },
createdAt: now,
updatedAt: now,
};
const sessions = getAllSessions();
sessions.unshift(session);
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions));
setActiveSessionId(session.id);
return session;
}
export function saveSession(session: StoredSession): void {
const sessions = getAllSessions();
const idx = sessions.findIndex((s) => s.id === session.id);
if (idx >= 0) {
sessions[idx] = session;
} else {
sessions.unshift(session);
}
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions));
}
export function updateSessionMessages(
id: string,
messages: StoredMessage[],
agentState: AgentState,
): void {
const sessions = getAllSessions();
const idx = sessions.findIndex((s) => s.id === id);
if (idx < 0) return;
const session = sessions[idx];
session.messages = messages;
session.agentState = agentState;
session.updatedAt = new Date().toISOString();
// Derive title from first user message
if (session.title === "New Session") {
const firstUserMsg = messages.find(
(m) =>
m.content &&
typeof m.role === "string" &&
m.role.toLowerCase() === "user",
);
if (firstUserMsg?.content) {
session.title =
firstUserMsg.content.length > 60
? `${firstUserMsg.content.slice(0, 57)}...`
: firstUserMsg.content;
}
}
sessions[idx] = session;
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions));
}
export function deleteSession(id: string): void {
const sessions = getAllSessions().filter((s) => s.id !== id);
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions));
if (getActiveSessionId() === id) {
localStorage.removeItem(ACTIVE_SESSION_KEY);
}
}
export function exportSessionToMarkdown(session: StoredSession): void {
const lines: string[] = [`# ${session.title}`, ""];
for (const msg of session.messages) {
const role = typeof msg.role === "string" ? msg.role.toLowerCase() : "";
if (role === "user" && msg.content) {
lines.push(`**User:** ${msg.content}`, "");
} else if (role === "assistant" && msg.content) {
lines.push(`**Assistant:** ${msg.content}`, "");
}
}
const blob = new Blob([lines.join("\n")], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${session.title.replace(/[^a-zA-Z0-9]/g, "_")}.md`;
a.click();
URL.revokeObjectURL(url);
}

View file

@ -1,30 +0,0 @@
{
"name": "haiku-rag-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"check": "biome check app components lib",
"format": "biome check --write app components lib"
},
"dependencies": {
"@ag-ui/client": "^0.0.57",
"@copilotkit/react-core": "^1.61.1",
"@copilotkit/runtime": "^1.61.1",
"next": "^16.2.11",
"openai": "^6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.2",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"typescript": "^5"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +0,0 @@
allowBuilds:
'@scarf/scarf': false
sharp: true
overrides:
sharp: '>=0.35.0'

View file

@ -1,41 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View file

@ -1,47 +0,0 @@
# haiku.rag configuration for the chat app
# Copy to haiku.rag.yaml and customize as needed
# The database. The compose files mount DB_VOLUME (default
# ./data/haiku.rag.lancedb) at /data.
lancedb:
databases:
haiku.rag: /data
# QA model configuration
qa:
model:
provider: ollama
name: gpt-oss
# For Anthropic:
# provider: anthropic
# name: claude-sonnet-4-20250514
# For OpenAI:
# provider: openai
# name: gpt-4o
# Embedding configuration
embeddings:
model:
provider: ollama
name: qwen3-embedding:4b
vector_dim: 2560
# For OpenAI:
# provider: openai
# name: text-embedding-3-small
# vector_dim: 1536
# Optional reranking
# reranking:
# model:
# provider: cohere
# name: rerank-v3.5
# Search settings
search:
limit: 5
# Provider settings
providers:
ollama:
# Use host.docker.internal to reach Ollama running on the host machine
base_url: http://host.docker.internal:11434

View file

@ -13,13 +13,13 @@ ENV UV_COMPILE_BYTECODE=1 \
COPY pyproject.toml uv.lock ./
COPY haiku_rag_slim/pyproject.toml haiku_rag_slim/README.md haiku_rag_slim/LICENSE haiku_rag_slim/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev --extra ingester
uv sync --frozen --no-install-project --no-dev
# Install the project itself (with the ingester extra so haiku-ingester is on PATH)
# Install the project itself
COPY haiku_rag_slim haiku_rag_slim/
COPY README.md LICENSE ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-editable --no-dev --extra ingester
uv sync --frozen --no-editable --no-dev
# Final layer
FROM python:3.13-slim
@ -33,11 +33,8 @@ ENV DEFAULT_DATA_DIR=/data
ENV PATH="/app/.venv/bin:$PATH"
# Expose MCP server (8001) and ingester control plane (8765) ports.
# docker-compose overrides this image's default command to run either the
# MCP server (read-only) or the ingester service.
EXPOSE 8001 8765
# Expose port for MCP server
EXPOSE 8001
# Default command: read-only MCP server. The companion ingester service is
# launched via docker-compose against the same image.
CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "mcp", "--port", "8001"]
# Run all services (monitoring, MCP)
CMD ["python", "-m", "haiku.rag.cli", "serve", "--monitor", "--mcp", "--mcp-port", "8001", "--db", "/data/haiku.rag.lancedb"]

View file

@ -1,42 +0,0 @@
# syntax=docker/dockerfile:1
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
WORKDIR /app
# Enable bytecode compilation for faster startup
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
# Install dependencies into a venv
# Install only haiku.rag-slim (no docling extra - use docling-serve instead)
COPY pyproject.toml uv.lock ./
COPY haiku_rag_slim/pyproject.toml haiku_rag_slim/README.md haiku_rag_slim/LICENSE haiku_rag_slim/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev --extra ingester --package haiku.rag-slim
# Install the project itself (with the ingester extra so haiku-ingester is on PATH)
COPY haiku_rag_slim haiku_rag_slim/
COPY README.md LICENSE ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-editable --no-dev --extra ingester --package haiku.rag-slim
# Final layer
FROM python:3.13-slim
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app /app
# Set default data directory
RUN mkdir -p /data
ENV DEFAULT_DATA_DIR=/data
ENV PATH="/app/.venv/bin:$PATH"
# Expose MCP server (8001) and ingester control plane (8765) ports.
# docker-compose overrides this image's default command to run either the
# MCP server (read-only) or the ingester service.
EXPOSE 8001 8765
# Default command: read-only MCP server. The companion ingester service is
# launched via docker-compose against the same image.
CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "mcp", "--port", "8001"]

View file

@ -1,21 +1,13 @@
# haiku.rag Docker Image
The full haiku.rag Docker image includes all features and extras (docling, voyageai, cross-encoder). You can build it locally using the provided Dockerfile.
Pre-built images are available at `ghcr.io/ggozad/haiku.rag` with all extras (voyageai, mxbai).
## Building the Image
Build the full image with all features:
## Using Pre-built Image
```bash
docker build -f docker/Dockerfile -t haiku-rag .
docker pull ghcr.io/ggozad/haiku.rag:latest
```
This creates an image with:
- All document processing capabilities (Docling)
- VoyageAI embeddings
- MixedBread AI reranking
- Full feature set
## Configuration
Create a configuration file `haiku.rag.yaml`:
@ -25,15 +17,13 @@ Create a configuration file `haiku.rag.yaml`:
environment: production
embeddings:
model:
provider: ollama
name: nomic-embed-text
vector_dim: 768
provider: ollama
model: nomic-embed-text
vector_dim: 768
qa:
model:
provider: ollama
name: qwen3
provider: ollama
model: qwen3
```
See [Configuration docs](https://ggozad.github.io/haiku.rag/configuration/) for all available options.
@ -44,50 +34,27 @@ Mount your config file and data directory:
```bash
docker run -p 8001:8001 \
-v /path/to/haiku.rag.yaml:/app/haiku.rag.yaml \
-v /path/to/data:/data \
haiku-rag
-v $(pwd)/haiku.rag.yaml:/app/haiku.rag.yaml \
-v $(pwd)/data:/data \
ghcr.io/ggozad/haiku.rag:latest
```
For continuous ingestion of a watched directory, run `haiku-ingester` in a
separate container against the same data volume:
```bash
docker run \
-v /path/to/haiku.rag.yaml:/app/haiku.rag.yaml \
-v /path/to/data:/data \
-v /path/to/docs:/docs \
-p 8765:8765 \
haiku-rag haiku-ingester --config /app/haiku.rag.yaml serve
```
Configure the watched directory in `haiku.rag.yaml` using the **container
path**:
```yaml
ingester:
queue:
path: /data/ingester.db # persist queue in the data volume
sources:
- type: fs
id: docs
root: /docs # container path, not host path
delete_orphans: true
```
The MCP server running in the first container must be started with
`--read-only` when an ingester is writing to the same database — LanceDB
allows one writer and N readers per URI. See
`examples/docker/docker-compose.yml` for a working two-service setup.
The container will automatically use the mounted `haiku.rag.yaml` configuration file.
For API keys (OpenAI, Anthropic, etc.), pass them as environment variables:
```bash
docker run -p 8001:8001 \
-v /path/to/haiku.rag.yaml:/app/haiku.rag.yaml \
-v /path/to/data:/data \
-v $(pwd)/haiku.rag.yaml:/app/haiku.rag.yaml \
-v $(pwd)/data:/data \
-e OPENAI_API_KEY=your-key-here \
haiku-rag
ghcr.io/ggozad/haiku.rag:latest
```
## Building Locally
```bash
docker build -f docker/Dockerfile -t haiku-rag .
```
## Docker Compose

282
docs/agents.md Normal file
View file

@ -0,0 +1,282 @@
## Agents
Three agentic flows are provided by haiku.rag:
- Simple QA Agent — a focused question answering agent
- Deep QA Agent — multi-agent question decomposition for complex questions
- Research MultiAgent — a multistep, analyzable research workflow
For an interactive example using Pydantic AI and AG-UI, see the [Interactive Research Assistant](https://github.com/ggozad/haiku.rag/tree/main/examples/ag-ui-research) example ([demo video](https://vimeo.com/1128874386)). The demo uses a knowledge base containing haiku.rag's code and documentation.
### Simple QA Agent
The simple QA agent answers a single question using the knowledge base. It retrieves relevant chunks, optionally expands context around them, and asks the model to answer strictly based on that context.
Key points:
- Uses a single `search_documents` tool to fetch relevant chunks
- Can be run with or without inline citations in the prompt (citations prefer
document titles when present, otherwise URIs)
- Returns a plain string answer
Python usage:
```python
from haiku.rag.client import HaikuRAG
from haiku.rag.qa.agent import QuestionAnswerAgent
async with HaikuRAG(path_to_db) as client:
# Choose a provider and model (see Configuration for env defaults)
agent = QuestionAnswerAgent(
client=client,
provider="openai", # or "ollama", "vllm", etc.
model="gpt-4o-mini",
use_citations=False, # set True to bias prompt towards citing sources
)
answer = await agent.answer("What is climate change?")
print(answer)
```
### Deep QA Agent
Deep QA is a multi-agent system that decomposes complex questions into sub-questions, answers them in batches, evaluates sufficiency, and iterates if needed before synthesizing a final answer. It's lighter than the full research workflow but more powerful than the simple QA agent.
```mermaid
---
title: Deep QA graph
---
stateDiagram-v2
[*] --> plan
plan --> get_batch
get_batch --> search_one: Has questions (map)
get_batch --> synthesize: No questions
search_one --> collect_answers
collect_answers --> decide
decide --> get_batch: Continue QA
decide --> synthesize: Done with QA
synthesize --> [*]
```
Key nodes:
- **plan**: Decomposes the question into focused sub-questions using a presearch tool
- **get_batch**: Retrieves remaining sub-questions for the current iteration
- **search_one**: Answers a single sub-question using the knowledge base (mapped in parallel)
- **collect_answers**: Aggregates search results from parallel executions
- **decide**: Evaluates if sufficient information has been gathered or if more iterations are needed
- **synthesize**: Generates the final comprehensive answer from all gathered information
Key differences from Research:
- **Simpler evaluation**: Uses sufficiency check (not confidence + insight analysis)
- **Direct answers**: Returns just the answer (not a full research report)
- **Question-focused**: Optimized for answering specific questions, not open-ended research
- **Supports citations**: Can include inline source citations like `[document.md]`
- **Configurable iterations**: Control max_iterations (default: 2) and max_concurrency (default: 1)
Note on parallel execution:
- The `search_one` node is mapped over all questions in a batch
- Parallelism is controlled via `max_concurrency`
- All questions in an iteration are processed before evaluation
CLI usage:
```bash
# Deep QA without citations
haiku-rag ask "What are the main features of haiku.rag?" --deep
# Deep QA with citations
haiku-rag ask "What are the main features of haiku.rag?" --deep --cite
```
Python usage:
```python
from haiku.rag.client import HaikuRAG
from haiku.rag.config import Config
from haiku.rag.qa.deep.dependencies import DeepQAContext
from haiku.rag.qa.deep.graph import build_deep_qa_graph
from haiku.rag.qa.deep.state import DeepQADeps, DeepQAState
async with HaikuRAG(path_to_db) as client:
# Use global config (recommended)
graph = build_deep_qa_graph(config=Config)
context = DeepQAContext(
original_question="What are the main features of haiku.rag?",
use_citations=True
)
state = DeepQAState.from_config(context=context, config=Config)
deps = DeepQADeps(client=client)
result = await graph.run(
state=state,
deps=deps
)
print(result.answer)
print(result.sources)
```
Alternative usage with custom config:
```python
# Create a custom config with different settings
from haiku.rag.config.models import AppConfig, QAConfig
custom_config = AppConfig(
qa=QAConfig(
provider="openai",
model="gpt-4o-mini",
max_sub_questions=5,
max_iterations=3,
max_concurrency=2,
)
)
graph = build_deep_qa_graph(config=custom_config)
context = DeepQAContext(
original_question="What are the main features of haiku.rag?",
use_citations=True
)
state = DeepQAState.from_config(context=context, config=custom_config)
deps = DeepQADeps(client=client)
result = await graph.run(state=state, deps=deps)
```
### Research Graph
The research workflow is implemented as a typed pydanticgraph. It plans, searches (in parallel batches), evaluates, and synthesizes into a final report — with clear stop conditions and shared state.
```mermaid
---
title: Research graph
---
stateDiagram-v2
[*] --> plan
plan --> get_batch
get_batch --> search_one: Has questions (map)
get_batch --> synthesize: No questions
search_one --> collect_answers
collect_answers --> analyze_insights
analyze_insights --> decide
decide --> get_batch: Continue research
decide --> synthesize: Done researching
synthesize --> [*]
```
Key nodes:
- **plan**: Builds up to 3 standalone subquestions (uses an internal presearch tool)
- **get_batch**: Retrieves remaining subquestions for the current iteration
- **search_one**: Answers a single subquestion using the KB with minimal, verbatim context (mapped in parallel)
- **collect_answers**: Aggregates search results from parallel executions
- **analyze_insights**: Synthesizes fresh insights, updates gaps, and suggests new sub-questions
- **decide**: Checks sufficiency/confidence thresholds and determines whether to continue research
- **synthesize**: Generates a final structured research report
Primary models:
- `SearchAnswer` — one per subquestion (query, answer, context, sources)
- `InsightRecord` / `GapRecord` — structured tracking of findings and open issues
- `InsightAnalysis` — output of the analysis stage (insights, gaps, commentary)
- `EvaluationResult` — insights, new questions, sufficiency, confidence
- `ResearchReport` — final report (title, executive summary, findings, conclusions, …)
Note on parallel execution:
- The `search_one` node is mapped over all questions in a batch
- Parallelism is controlled via `max_concurrency`
- Analysis and decision nodes process results after each batch completes
CLI usage:
```bash
# Basic usage (uses config from file or defaults)
haiku-rag research "How does haiku.rag organize and query documents?" --verbose
# With custom config file
haiku-rag --config my-research-config.yaml research "How does haiku.rag organize and query documents?" --verbose
```
Python usage (blocking result):
```python
from haiku.rag.client import HaikuRAG
from haiku.rag.config import Config
from haiku.rag.research.dependencies import ResearchContext
from haiku.rag.research.graph import build_research_graph
from haiku.rag.research.state import ResearchDeps, ResearchState
async with HaikuRAG(path_to_db) as client:
# Use global config (recommended)
graph = build_research_graph(config=Config)
question = "What are the main drivers and trends of global temperature anomalies since 1990?"
context = ResearchContext(original_question=question)
state = ResearchState.from_config(context=context, config=Config)
deps = ResearchDeps(client=client)
result = await graph.run(
state=state,
deps=deps,
)
report = result
print(report.title)
print(report.executive_summary)
```
Alternative usage with custom config:
```python
from haiku.rag.config.models import AppConfig, ResearchConfig
custom_config = AppConfig(
research=ResearchConfig(
provider="openai",
model="gpt-4o-mini",
max_iterations=5,
confidence_threshold=0.85,
max_concurrency=3,
)
)
graph = build_research_graph(config=custom_config)
context = ResearchContext(original_question=question)
state = ResearchState.from_config(context=context, config=custom_config)
deps = ResearchDeps(client=client)
result = await graph.run(state=state, deps=deps)
```
Python usage (streamed events):
```python
from haiku.rag.client import HaikuRAG
from haiku.rag.config import Config
from haiku.rag.research.dependencies import ResearchContext
from haiku.rag.research.graph import build_research_graph
from haiku.rag.research.state import ResearchDeps, ResearchState
from haiku.rag.research.stream import stream_research_graph
async with HaikuRAG(path_to_db) as client:
graph = build_research_graph(config=Config)
question = "What are the main drivers and trends of global temperature anomalies since 1990?"
context = ResearchContext(original_question=question)
state = ResearchState.from_config(context=context, config=Config)
deps = ResearchDeps(client=client)
async for event in stream_research_graph(
graph,
state,
deps,
):
if event.type == "log":
iteration = event.state.iterations if event.state else state.iterations
print(f"[{iteration}] {event.message}")
elif event.type == "report":
print("\nResearch complete!\n")
print(event.report.title)
print(event.report.executive_summary)
```

View file

@ -1,87 +0,0 @@
# Web application
A browser-based reference implementation of conversational RAG, built on a Starlette backend with pydantic-ai's `AGUIAdapter` and a Next.js / CopilotKit frontend. It lives in the `app/` directory of the haiku.rag repository.
This is a starting point for your own deployments, not the canonical haiku.rag UX. For the day-to-day terminal experience see [Chat](chat.md).
!!! warning "No authentication"
An illustrative example meant as a starting point, with no authentication. The compose files bind the backend to `127.0.0.1`; don't expose it to an untrusted network.
## Features
- Streaming chat with real-time tool execution visibility.
- Expandable citations with source documents, pages, and headings.
- Visual grounding to view chunk source locations in documents.
- Document filter to restrict searches to selected documents.
- Session state view for inspecting citations and search results.
## Quick start
```bash
cd app
docker compose -f docker-compose.dev.yml up -d --build
```
- Frontend: `http://localhost:3000`
- Backend: `http://localhost:8001`
## Architecture
- **Backend**: Starlette server with pydantic-ai `AGUIAdapter`.
- **Frontend**: Next.js with CopilotKit.
- **Protocol**: AG-UI for streaming chat.
## Configuration
Create a `.env` file in the `app/` directory:
```bash
# API Keys (at least one required)
ANTHROPIC_API_KEY=your-anthropic-key
OPENAI_API_KEY=your-openai-key
# Host path of the LanceDB database, mounted at /data
DB_VOLUME=/path/to/your/haiku.rag.lancedb
# Optional: Ollama base URL (if using local models)
OLLAMA_BASE_URL=http://localhost:11434
# Optional: Logfire for observability
LOGFIRE_TOKEN=your-logfire-token
```
The mounted `haiku.rag.yaml` places the database at `/data` and configures the models; the compose files point `HAIKU_RAG_CONFIG_PATH` at it:
```yaml
# app/haiku.rag.yaml
lancedb:
databases:
haiku.rag: /data
qa:
model:
provider: anthropic
name: claude-sonnet-4-20250514
```
Outside compose, the backend loads its configuration like the CLI: `HAIKU_RAG_CONFIG_PATH`, then `./haiku.rag.yaml`, then the platform directory.
## API endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/v1/chat/stream` | POST | AG-UI chat streaming |
| `/api/documents` | GET | List all documents |
| `/api/info` | GET | Database statistics |
| `/api/visualize/{chunk_id}` | GET | Visual grounding images (base64) |
| `/health` | GET | Health check |
## Development
The backend reloads automatically on file changes. For frontend changes:
```bash
docker compose -f docker-compose.dev.yml up -d --build frontend
```
If `LOGFIRE_TOKEN` is set, LLM calls are traced and available in the Logfire dashboard.

View file

@ -1,291 +1,97 @@
# Benchmarks
We evaluate `haiku.rag` on a small set of datasets that exercise different parts of the pipeline. OpenRAG Bench (ORB), T²-RAGBench, HotpotQA, FRAMES, and MTRAG are the datasets we currently track. Retrieval, QA accuracy, and citation retrieval are scored end-to-end through the RAG and analysis capabilities.
We use the [repliqa](https://huggingface.co/datasets/ServiceNow/repliqa) dataset for the evaluation of `haiku.rag`.
## Current results
You can perform your own evaluations with the Typer CLI in
`evaluations/evaluations/benchmark.py`, for example `python -m evaluations.benchmark repliqa`.
The evaluation flow is orchestrated with
[`pydantic-evals`](https://github.com/pydantic/pydantic-ai/tree/main/libs/pydantic-evals),
which we leverage for dataset management, scoring, and report generation.
Numbers below were measured on a recent `haiku.rag` version. Most rows were judged by `Qwen3.6-35B-A3B-NVFP4`; the `Qwen3.8-27B` rows were judged by the currently pinned `qwen3.8`, as their footnote states. Rows are not re-judged when the pinned judge changes, so compare rows judged by the same judge and treat cross-judge differences as unmeasured.
## Configuration
No benchmark database carries a vector index, so every number below reflects exact brute-force kNN rather than approximate search. A vector index is never built automatically. `haiku-rag create-index` builds one, and `haiku-rag doctor` reports whether a database has it. For the measured effect of indexing on retrieval, see [Vector Indexing](configuration/storage.md#vector-indexing).
### OpenRAG Bench (ORB)
[OpenRAG Bench](https://huggingface.co/datasets/vectara/open_ragbench) contains ArXiv research papers with multimodal question-answering pairs. Queries include both text-based and image-based questions, testing retrieval and reasoning over visual content like figures, charts, and diagrams. Each query maps to one relevant document.
Two approaches are benchmarked separately:
- **Multimodal embedder** (`Qwen/Qwen3-VL-Embedding-8B`, served via vLLM): picture bytes and text live in a shared vector space, no VLM is run at ingest.
- **Text embedder + VLM picture descriptions** (`qwen3-embedding:4b` + `ollama/ministral-3`): pictures are described at ingest and the descriptions are woven into chunk text. Retrieval runs over text only. See [Picture handling configuration](configuration/processing.md#picture-handling).
#### Multimodal embedder
##### Retrieval (MAP)
| Embedding Model | Reranker | Cases | MAP |
|------------------------------------------|------------------------------------------------------|------:|-------:|
| `Qwen/Qwen3-VL-Embedding-8B` | none | 3045 | 0.9774 |
| `nvidia/llama-nemotron-embed-vl-1b-v2` | none | 3045 | 0.9798 |
| `nvidia/llama-nemotron-embed-vl-1b-v2` | `nvidia/llama-nemotron-rerank-vl-1b-v2` (multimodal) | 3045 | 0.9913 |
*The nemotron row without a reranker is measured on this release. The reranked row uses `reranking.multimodal: true`: picture chunks reach the vision reranker as images alongside their description text, measured on haiku.rag main post-v0.67.3.*
##### QA accuracy + citation retrieval
| Embedding Model | Target | Capability model | Cases | QA accuracy | Mean `cited_map` |
|------------------------------------------|-----------------|-----------------------------------|------:|-------------|------------------|
| `Qwen/Qwen3-VL-Embedding-8B` | `rag-capability` | `vllm:Gemma-4-26B-A4B-NVFP4` | 1409 | 0.89 | — |
| `nvidia/llama-nemotron-embed-vl-1b-v2` | `rag-capability` | `vllm:Gemma-4-26B-A4B-NVFP4` | 3039 | 0.9263 | 0.9761 |
| `nvidia/llama-nemotron-embed-vl-1b-v2` | `analysis-capability`| `vllm:Gemma-4-26B-A4B-NVFP4` | 3040 | 0.9362 | 0.9343 |
| `nvidia/llama-nemotron-embed-vl-1b-v2` | `analysis-capability`| `vllm:Qwen3.6-35B-A3B-NVFP4` | 3045 | 0.95 | 0.93 |
| `nvidia/llama-nemotron-embed-vl-1b-v2` | `rag-capability` | `vllm:Muse-Glimmer-30B-NVFP4` | 3045 | 0.9494 | 0.9771 |
| `nvidia/llama-nemotron-embed-vl-1b-v2` | `analysis-capability`| `vllm:Muse-Glimmer-30B-NVFP4` | 3017 | 0.9718 | 0.9837 |
| `nvidia/llama-nemotron-embed-vl-1b-v2` | `rag-capability` | `vllm:Qwen3.8-27B-NVFP4` | 3045 | 0.9514 | 0.9817 |
| `nvidia/llama-nemotron-embed-vl-1b-v2` | `analysis-capability`| `vllm:Qwen3.8-27B-NVFP4` | 3042 | 0.9629 | 0.9835 |
*The `Muse-Glimmer-30B` rows run at `chat_template_kwargs.reasoning_strength: high`, no reranker, same judge.*
*The `Qwen3.8-27B` rows run at `chat_template_kwargs.reasoning_effort: low`, no reranker, and are **judged by `Qwen3.8-27B` itself** — the pinned judge, and the same model that produced the answers. A 120-case cross-check by an independent judge agreed on 95% and was never stricter, but the incumbent `Qwen3.6` judge is no longer hosted, so the older rows cannot be re-judged for a like-for-like comparison. `rag-capability` cites on 99.34% of cases with 1.09 citations each; `analysis-capability` on 99.70% with 1.12, at 0.13 code executions per case. Case counts exclude 0 and 3 provider errors respectively.*
*Both nemotron `Gemma-4` rows are measured on this release, no reranker, judged by `vllm:Qwen3.6-35B-A3B-NVFP4` with thinking on, and exclude the cases that errored (6 of 3045 for `rag-capability`, 5 for `analysis-capability`). The `rag-capability` row cites at 99.64% with a mean of 1.08 citations per case, at a median 4.7s per case against 5.0s for `analysis-capability`. Citation coverage is what moved on this release: 4.9% of analysis cases register no citation, against 26.3% before, at unchanged searches and code executions per case. The remaining rows are from haiku.rag v0.52.0, where Qwen3-VL covered 1409 / 3045 cases.*
#### Text embedder + VLM picture descriptions
##### Retrieval (MAP)
| Embedding Model | VLM | Reranker | Cases | MAP |
|------------------------------------------|----------------------|------------------------|------:|-------:|
| `qwen3-embedding:4b` | Ollama / ministral-3 | `mxbai-rerank-base-v2` | 3045 | 0.9834 |
| `nvidia/llama-nemotron-embed-vl-1b-v2` | Ollama / ministral-3 | `mxbai-rerank-base-v2` | 3045 | 0.9863 |
*Measured on haiku.rag v0.50.0.*
##### QA accuracy + citation retrieval
| Embedding Model | VLM | Capability model | Cases | QA accuracy | Mean `cited_map` |
|------------------------------------------|----------------------|------------------------------|------:|-------------|------------------|
| `qwen3-embedding:4b` | Ollama / ministral-3 | `vllm:Gemma-4-26B-A4B-NVFP4` | 3045 | 0.92 | 0.80 |
| `nvidia/llama-nemotron-embed-vl-1b-v2` | Ollama / ministral-3 | `vllm:Gemma-4-26B-A4B-NVFP4` | 2836 | 0.96 | 0.81 |
*Measured on haiku.rag v0.50.0 with `mxbai-rerank-base-v2`, judged by `vllm:Qwen3.6-35B-A3B-NVFP4`. Nemotron covered 2836 / 3045 cases.*
### T²-RAGBench (FinQA)
[T²-RAGBench](https://huggingface.co/datasets/G4KMU/t2-ragbench) reformulates financial-report QA into context-independent questions with short numeric answers and a 1:1 gold document mapping. The FinQA subset is 2,789 single-page PDFs / 8,281 questions, ingested via docling. Unlike the other datasets, QA is scored deterministically with `NumberMatchEvaluator` (relative tolerance 0.01) instead of an LLM judge, so QA accuracy here is exact numeric match rather than a judged fraction.
##### QA accuracy + citation retrieval
| Embedding Model | Reranker | Target | Capability model | Cases | QA accuracy | Mean `cited_map` |
|----------------------|------------------------|------------------|------------------------------|------:|-------------|------------------|
| `qwen3-embedding:4b` | `mxbai-rerank-base-v2` | `analysis-capability` | `vllm:Qwen3.6-35B-A3B-NVFP4` | 7939 | 0.77 | 0.78 |
*Measured on haiku.rag v0.55.0, deterministic Number-Match scoring (ε=0.01), 2560-dim `qwen3-embedding:4b` (vLLM) with `mxbai-rerank-base-v2`. 341 / 8281 cases excluded as nulls (analysis spirals from the request limit and in-generation loops). Accuracy and `cited_map` are over the 7939 scored cases. Mean 16.0s/case.*
### HotpotQA
[HotpotQA](https://huggingface.co/datasets/hotpotqa/hotpot_qa) is multi-hop question answering over Wikipedia: each question requires combining facts from two supporting paragraphs, with distractor paragraphs in the corpus. We use the distractor validation split: 7,405 questions over ~66k unique paragraphs, each question mapping to two gold documents.
##### Retrieval (MAP)
| Embedding Model | Reranker | Cases | MAP |
|----------------------|---------------------|------:|-------:|
| `qwen3-embedding:4b` | `Qwen3-Reranker-4B` | 7405 | 0.8202 |
| `qwen3-embedding:4b` | none | 7405 | 0.6995 |
The reranker's contribution is larger here than on the single-doc datasets: hybrid search usually surfaces the first-hop document at rank 1, while the second-hop document often needs the reranker to climb into the result window.
##### QA accuracy + citation retrieval
| Skill model | Reranker | QA accuracy | Mean `cited_map` |
|------------------------------|---------------------|-------------|------------------|
| `vllm:Gemma-4-26B-A4B-NVFP4` | `Qwen3-Reranker-4B` | 0.85 | 0.80 |
| `vllm:Gemma-4-26B-A4B-NVFP4` | none | 0.83 | 0.75 |
*Measured on haiku.rag v0.66.0 with `qwen3-embedding:4b` (vLLM, dim 2560), judged by `vllm:Qwen3.6-35B-A3B-NVFP4`, 7,405 cases. The reranker lifts QA accuracy +2.7pts and `cited_map` +4.6pts. Without a reranker, `cited_map` (0.75) still exceeds the no-reranker retrieval MAP (0.70): the skill reformulates queries across search calls, partially recovering second-hop documents that a single query misses.*
### FRAMES
[FRAMES](https://huggingface.co/datasets/google/frames-benchmark) is Google's multi-hop QA benchmark: 824 questions, each grounded in 223 Wikipedia articles, exercising temporal, numerical, and tabular reasoning across documents. We evaluate 822 questions (2 excluded: a linked article was deleted from Wikipedia) over a fixed corpus of the 2,521 linked articles fetched at current revision. There is no official FRAMES evaluation setup; our protocol — fixed corpus, agentic retrieval, judged accuracy — corresponds to the paper's *multi-step retrieval* setting, where [the paper](https://arxiv.org/abs/2409.12941) reports 0.66 with Gemini-Pro-1.5 (0.729 in its oracle setting, with gold articles provided). Answers were authored against ~2024 revisions and may have drifted with article content.
##### Retrieval (MAP)
| Embedding Model | Reranker | Cases | MAP |
|----------------------|---------------------|------:|-------:|
| `qwen3-embedding:4b` | `Qwen3-Reranker-4B` | 822 | 0.5631 |
*Single-query retrieval is capped by FRAMES' indirection: in the zero-MAP queries the gold article's subject is never named in the question ("the year the Titanic sank" → `1912_Summer_Olympics`). The agentic targets recover these through iterative search, passing 55% of the very cases single-shot retrieval scores zero on.*
##### QA accuracy + citation retrieval
| Capability model | Target | QA accuracy | Mean `cited_map` |
|------------------|--------|-------------|------------------|
| `vllm:Muse-Glimmer-30B-NVFP4` | `analysis-capability` | 0.7506 | 0.5852 |
| `vllm:Qwen3.8-27B-NVFP4` | `analysis-capability` | 0.8095 | 0.6847 |
*Both rows use `qwen3-embedding:4b` (vLLM, dim 2560), `Qwen3-Reranker-4B`, and are judged by `Qwen3.8-27B`. QA accuracy is over judged cases; counting unanswered cases as failures, the floors are 0.7397 (`Muse-Glimmer`, 1.5% lost to provider errors) and 0.7701 (`Qwen3.8`, 4.87% lost to answers truncated at `max_tokens: 16384`). The `Qwen3.8` row is self-judged — a 100-case paired cross-judge (Glimmer as judge, difference-in-differences) measured the self-preference at +1.0pp with 99% judge agreement. Cite rates are 90.1% (`Muse-Glimmer`) and 99.3% (`Qwen3.8`); `cited_map` is structurally capped below 1 on FRAMES because gold sets span 223 articles while answering typically uses a subset. `Qwen3.8` reaches its score on substantially less tool traffic than `Muse-Glimmer` (4.0 searches and 3.9 code executions per case vs 7.0 and 9.5, measured identically from tool spans).*
### MTRAG (ClapNQ)
[MTRAG](https://github.com/IBM/mt-rag-benchmark) is IBM's multi-turn RAG benchmark (TACL 2025, SemEval-2026 Task 8): human-authored conversations with per-turn answerability labels and binary relevance judgments. We evaluate the ClapNQ (Wikipedia) domain: 183,408 passages, 29 conversations, 224 turns, 208 retrieval queries.
Four dataset keys share one database. `mtrag_clapnq` retrieves with the raw last user turn and runs QA by replaying each task's reference conversation prefix as message history. `mtrag_clapnq_rewrite` retrieves with the human standalone rewrites. `mtrag_clapnq_live` replays whole conversations through a single capability session, carrying the model's own answers, tool history and capability state across turns, with `EvidenceCompactionCapability` registered. `mtrag_clapnq_live_uncompacted` is the same replay without compaction, isolating what compaction contributes. This is the only multi-turn evaluation, so it is the only one where compaction acts at all.
##### Retrieval (Recall@k / nDCG@k)
Directly comparable with [IBM's published results](https://github.com/IBM/mt-rag-benchmark/tree/main/mtrag-human/retrieval_tasks). Elser is IBM's strongest reported retriever.
| Retriever | Queries | R@5 | R@10 | nDCG@5 | nDCG@10 |
|-----------|---------|----:|-----:|-------:|--------:|
| Elser (IBM) | lastturn | 0.49 | 0.58 | 0.45 | 0.49 |
| `haiku.rag` | lastturn | 0.501 | 0.600 | 0.455 | 0.497 |
| Elser (IBM) | rewrite | 0.52 | 0.64 | 0.48 | 0.54 |
| `haiku.rag` | rewrite | 0.548 | 0.668 | 0.503 | 0.556 |
##### QA accuracy + citation retrieval
| Mode | Capability model | Turns | QA accuracy | Mean `cited_map` |
|------|------------------|------:|-------------|------------------|
| Gold-prefix (`mtrag_clapnq`) | `vllm:Muse-Glimmer-30B-NVFP4` | 224 | 0.76 | 0.35 |
| Live compacted (`mtrag_clapnq_live`) | `vllm:Muse-Glimmer-30B-NVFP4` | 224/224 scored | 0.83 micro / 0.84 macro | 0.42 |
| Live uncompacted (`mtrag_clapnq_live_uncompacted`) | `vllm:Muse-Glimmer-30B-NVFP4` | 224/224 scored | 0.78 micro / 0.79 macro | 0.42 |
*Measured on haiku.rag v0.74.0 with `qwen3-embedding:4b` (vLLM, dim 2560), `Qwen3-Reranker-4B`, stock capability instructions with `reasoning_strength: high`, judged by the pinned `vllm:Qwen3.6-35B-A3B-NVFP4` (temperature 0.6, thinking). QA numbers are internal (our judge and rubric) and not comparable with IBM's published generation metrics. Gold-prefix and live rates answer different judge questions and are not comparable with each other. The dataset is text-only (ClapNQ passages), so it exercises no multimodal paths.*
The two live arms replay the same 29 conversations (224 turns) and differ only in registering `EvidenceCompactionCapability`, so they are compared as paired observations:
- Input tokens per model request, computed as total input tokens divided by model requests across the whole arm: 7,461 compacted (5,207,627 tokens over 698 requests) vs 13,539 uncompacted (9,707,530 over 717 requests). The uncompacted arm used 1.81x as many tokens per request, a 44.9% reduction under compaction.
- Answer pass rate: 185/224 vs 175/224 turns. Of the 18 turns where the arms disagree, 14 pass only compacted and 4 only uncompacted. McNemar exact two-sided p = 0.031. The paired difference is +4.5pp with a Wald 95% CI of +0.8 to +8.1pp, so the honest claim is an improvement of roughly 1 to 8 points, not the point estimate.
- Citation MAP, macro-averaged over conversations with 208 of 224 turns eligible (turns with gold passages) in each arm: 0.4174 compacted vs 0.4230 uncompacted. The gold-prefix 0.35 is over 208 of 224 eligible cases.
- Refusal precision and recall against the answerability labels (16 UNANSWERABLE turns per arm): compacted 0.33 precision and 0.44 recall (21 refusals), uncompacted 0.23 and 0.31 (22 refusals). Gold-prefix: 0.24 and 0.44 (29 refusals).
## Methodology
### Retrieval Metrics
**Mean Average Precision (MAP)** scores ranked retrieval results against the gold `expected_uris`.
- For each relevant document at position k, calculate precision@k = (relevant docs in top k) / k
- Average Precision (AP) = sum of these precision values / total relevant documents
- MAP is the mean of AP scores across all queries
- Range: 0 to 1. Rewards ranking relevant documents higher
- For single-doc queries this collapses to `1/rank` (i.e. reciprocal rank)
### QA Accuracy
`pydantic-evals` coordinates an LLM judge to determine whether the capability's answer is correct. The default judge is `ollama:qwen3.8`, pinned so changes to the capability model don't change the judge underneath. Set `evaluations.judge` in `haiku.rag.yaml` to override (including a custom `base_url` for any OpenAI-compatible endpoint). Accuracy is the fraction of correctly answered questions.
A dataset that brings its own deterministic evaluator is scored by that evaluator instead, and no judge runs. T²-RAGBench is the only such dataset today, scored by `NumberMatchEvaluator`.
`qwen3.8` replaced `qwen3.6` after a 120-case calibration on ORB, stratified 60 pass / 60 fail: agreement 0.950, Cohen's κ 0.900, and in all 6 disagreements it matched or beat `qwen3.6` (4 were `qwen3.6` failing answers that were equivalent in different notation). It emits no reasoning content, so it avoids the thinking spirals that made `qwen3.6` exceed its output budget and drop verdicts. `reasoning_effort` changes its verdicts in 1 case per 120, so the cheaper `low` is pinned.
Before that, we picked `qwen3.6` over the previously-pinned `gpt-oss` after a 4-cell calibration (gpt-oss / qwen3.6 as both answerer and judge, with Claude Opus 4.7 as a reference). `qwen3.6` had κ ≥ 0.66 vs the reference on both same-family and cross-family answerers (vs ~0.390.55 for `gpt-oss`) and showed no measurable self-preference bias, while `gpt-oss` was ~10 pp more lenient on its own outputs.
### Citation Retrieval
Alongside QA accuracy, a second metric scores the URIs the capability registered via the `cite` tool against each dataset's gold `expected_uris`, using the same MAP math as raw retrieval. The score key is `cited_map`. Console output also includes the cite rate (% of cases with at least one citation) and the mean number of citations per case.
This is computed alongside QA accuracy from the same capability run, no extra invocations. The signal complements raw retrieval: where raw retrieval measures whether the retriever surfaced the gold document at any rank, citation retrieval measures whether the capability grounded its answer on it.
## Running Evaluations
You can run evaluations with the `evaluations` CLI:
The benchmark script accepts a `--config` option to specify a custom `haiku.rag.yaml` configuration file:
```bash
evaluations run hotpotqa
evaluations run orb_text
python -m evaluations.benchmark repliqa --config /path/to/haiku.rag.yaml
```
The evaluation flow is orchestrated with [`pydantic-evals`](https://github.com/pydantic/pydantic-ai/tree/main/libs/pydantic-evals), which we leverage for dataset management, scoring, and report generation.
If no config file is specified, the script will search for a config file in the standard locations:
1. `./haiku.rag.yaml` (current directory)
2. User config directory
3. Falls back to default configuration
### Pre-built Databases
Building evaluation databases from scratch can take a long time, especially for large datasets like OpenRAG Bench. Pre-built databases are available on HuggingFace:
```bash
# Download a specific dataset
evaluations download hotpotqa
# Download all datasets
evaluations download all
# Force re-download (overwrite existing)
evaluations download hotpotqa --force
```
Active datasets:
| Dataset | Size |
|---------|------|
| `orb_text` — OpenRAG Bench, text embedder (`qwen3-embedding:4b`) with VLM picture descriptions baked into chunk content | ~18 GB |
| `orb_multimodal` — OpenRAG Bench, multimodal embedder (`qwen3-vl-embedding-8b`); picture vectors live in the same space as text for cross-modal retrieval | ~16 GB |
| `orb_multimodal_nemotron` — OpenRAG Bench, multimodal embedder (`nvidia/llama-nemotron-embed-vl-1b-v2`), the embedder behind the published headline results | ~16 GB |
| `t2_finqa` — T²-RAGBench (FinQA) financial QA, text embedder (`qwen3-embedding:4b`); scored by exact numeric match, run with `--target analysis-capability` | ~2 GB |
| `hotpotqa` — HotpotQA multi-hop QA over Wikipedia paragraphs, text embedder (`qwen3-embedding:4b`) | ~1.5 GB |
| `mtrag_clapnq` — MTRAG multi-turn RAG, ClapNQ (Wikipedia) passages, text embedder (`qwen3-embedding:4b`); also serves the `mtrag_clapnq_rewrite`, `mtrag_clapnq_live` and `mtrag_clapnq_live_uncompacted` keys | ~2.8 GB |
After downloading, run benchmarks with `--skip-db`. Each database is built with a specific embedder, so pass its reference config from `evaluations/configs/` (a database only opens against a config whose embedder matches):
```bash
evaluations run orb_multimodal_nemotron --skip-db --config configs/orb_multimodal_nemotron.yaml
```
The configs use `vllm` as the model host. Point `base_url` at your own OpenAI-compatible endpoints to reproduce the numbers.
### Configuration
The benchmark script accepts several options:
```bash
evaluations run hotpotqa --config /path/to/haiku.rag.yaml --db /path/to/custom.lancedb
```
**Options:**
- `--config PATH` - Specify a custom `haiku.rag.yaml` configuration file
- `--db PATH` - Override the database path (default: platform-specific user data directory)
You can also use command-line options:
- `--skip-db` - Skip updating the evaluation database
- `--skip-retrieval` - Skip retrieval benchmark
- `--skip-qa` - Skip QA benchmark
- `--limit N` - Limit number of test cases
- `--name NAME` - Override the evaluation name
- `--target {rag-capability,analysis-capability}` - Choose which [capability](capabilities/index.md) to benchmark end-to-end (default: `rag-capability`). The target names remain stable dataset identifiers.
- `--capability-model PROVIDER:NAME` - Override the capability model independently from the judge (default: `config.qa.model`, or `config.analysis.model` when set for `--target analysis-capability`).
- `--filter CLAUSE` / `-f CLAUSE` - Restrict every benchmark search to a subset of the database (see [Restricting the corpus](#restricting-the-corpus)).
- `--qa-limit N` - Limit number of QA cases to evaluate
If no config file is specified, the script searches standard locations: `./haiku.rag.yaml`, user config directory, then falls back to defaults.
## Recall
To pin the LLM judge in YAML (rather than the default `ollama:qwen3.8`). These are the recommended settings:
In order to calculate recall, we load the `News Stories` from `repliqa_3` (1035 documents) and index them. Subsequently, we run a search over the `question` field for each row of the dataset and check whether we match the document that answers the question. Questions for which the answer cannot be found in the documents are ignored.
```yaml
evaluations:
judge:
provider: openai
name: Inferact/Qwen3.8-27B-NVFP4
base_url: http://localhost:8000/v1 # optional, for OpenAI-compatible servers (vLLM, LM Studio, etc.)
temperature: 0.6
max_tokens: 16384
extra_body:
top_p: 0.95
top_k: 20
min_p: 0
chat_template_kwargs:
reasoning_effort: low # qwen3.8: low | medium | xhigh (default)
```
### Restricting the corpus
The recall obtained is ~0.79 for matching in the top result, raising to ~0.91 for the top 3 results with the "bare" default settings (Ollama `qwen3`, `mxbai-embed-large` embeddings, no reranking).
When a database holds documents from several corpora — only some of which a dataset's questions are drawn from — `--filter` restricts every benchmark search to a subset. It takes the same SQL `WHERE` clause as `haiku-rag search --filter`, over document columns (`id`, `uri`, `title`, `created_at`, `updated_at`, `metadata`). Each dataset writes its own URIs: `orb_text` uses bare arXiv ids such as `2407.01528v3`, `hotpotqa` uses page titles.
| Embedding Model | Document in top 1 | Document in top 3 | Reranker |
|---------------------------------------|-------------------|-------------------|------------------------|
| Ollama / `qwen3-embedding` | 0.81 | 0.95 | None |
| Ollama / `qwen3-embedding` | 0.91 | 0.98 | `mxbai-rerank-base-v2` |
| Ollama / `mxbai-embed-large` | 0.79 | 0.91 | None |
| Ollama / `mxbai-embed-large` | 0.90 | 0.95 | `mxbai-rerank-base-v2` |
| Ollama / `nomic-embed-text-v1.5` | 0.74 | 0.90 | None |
```bash
evaluations run orb_text --skip-db --config haiku.rag.s3.yaml \
--filter "uri LIKE '2407%'"
```
## Question/Answer evaluation
If the corpora are distinguished by a tag rather than by URI, attach it at ingest time as document metadata and match it with `LIKE`. `metadata` is stored as a `json.dumps` string, so there is no JSON subfield access — match the serialized key/value, including the space after the colon:
Again using the same dataset, we use a QA agent to answer the question.
`pydantic-evals` runs each case and coordinates an LLM judge (Ollama `qwen3`) to
determine whether the answer is correct. The obtained accuracy is as follows:
```bash
evaluations run orb_text --skip-db --filter "metadata LIKE '%\"corpus\": \"orb_text\"%'"
```
| Embedding Model | QA Model | Accuracy | Reranker |
|------------------------------------|-----------------------------------|-----------|------------------------|
| Ollama / `qwen3-embedding. ` | Ollama / `gpt-oss` | 0.93 | None |
| Ollama / `mxbai-embed-large` | Ollama / `qwen3` | 0.85 | None |
| Ollama / `mxbai-embed-large` | Ollama / `qwen3` | 0.87 | `mxbai-rerank-base-v2` |
| Ollama / `mxbai-embed-large` | Ollama / `qwen3:0.6b` | 0.28 | None |
The clause applies to both benchmark phases — the retrieval benchmark's searches and every search the capability runs during QA — so the two score the same subset. It is recorded as `document_filter` in the run's experiment metadata, so a filtered run is never mistaken for an unfiltered one when comparing results.
Note the significant degradation when very small models are used such as `qwen3:0.6b`.
Filtering affects searches only — a run without `--skip-db` still populates the database with the dataset's full corpus.
## Wix dataset
We also track retrieval performance on [WixQA](https://huggingface.co/datasets/Wix/WixQA),
a dataset of real customer support questions paired with curated answers from
Wix. The benchmark follows the evaluation protocol described in the
[WixQA paper](https://arxiv.org/abs/2505.08643) and gives us a view into how the
system handles conversational, product-specific support queries.
For retrieval evaluation, we index the reference answer passages shipped with the dataset and
run retrieval against each user question. Each sample supplies one or more
relevant passage URIs. We track two complementary metrics:
- **Recall@K**: Fraction of relevant documents retrieved in top K results. Measures coverage.
- **Success@K**: Fraction of queries with at least one relevant document in top K. Most relevant for RAG, where finding one good document is often sufficient.
### Recall@K Results
| Embedding Model | Recall@1 | Recall@3 | Recall@5 | Reranker |
|----------------------------|----------|----------|----------|------------------------|
| `qwen3-embedding` | 0.31 | 0.48 | 0.54 | None |
| `qwen3-embedding` | 0.36 | 0.57 | 0.68 | `mxbai-rerank-base-v2` |
| `qwen3-embedding` | 0.36 | 0.58 | 0.67 | `zeroentropy` |
### Success@K Results
| Embedding Model | Success@1 | Success@3 | Success@5 | Reranker |
|----------------------------|-----------|-----------|-----------|------------------------|
| `qwen3-embedding` | 0.36 | 0.54 | 0.62 | None |
| `qwen3-embedding` | 0.42 | 0.66 | 0.76 | `mxbai-rerank-base-v2` |
| `qwen3-embedding` | 0.41 | 0.66 | 0.76 | `zeroentropy` |
## QA Accuracy
And for QA accuracy,
| Embedding Model | QA Model | Accuracy | Reranker |
|----------------------------|-----------|----------|------------------------|
| `qwen3-embedding` | `gpt-oss` | 0.75 | `mxbai-rerank-base-v2` |

View file

@ -1,59 +0,0 @@
# Analysis Capability
`AnalysisCapability` adds search, citations, and sandboxed Python computation over the document corpus. Use it for counts, aggregation, comparison, structural traversal, and section-scoped reading.
It is deferred by default, keeping its substantial instructions and tool schemas out of context until the model chooses to load it.
The default request limit is 30 model requests per question. Override it with `create_capability(request_limit=...)`, or set `request_limit=None` to disable it. As with the RAG capability, `create_capability(vision=...)` overrides the image-attachment gate, defaulting to the configured analysis model's `vision` flag. At the limit, `analysis_search` and `analysis_execute_code` are removed while `analysis_cite` remains for two further requests that call an analysis tool, so the model can register citations before answering from gathered evidence. Requests spent on other capabilities do not count against that window. Other agent and capability tools remain available, and the budget resets for every agent run.
When `qa.max_searches` or `analysis.max_executions` runs out, the exhausted tool keeps failing rather than disappearing, and the instructions name it on every following request. Searching from inside `analysis_execute_code` does not count against `qa.max_searches`.
## Tools
| Tool | Purpose |
|---|---|
| `analysis_search(query, limit?)` | Search the corpus for evidence. |
| `analysis_execute_code(code)` | Run Python against the virtual document filesystem. |
| `analysis_cite(chunk_ids)` | Register retrieved or filesystem-derived chunk IDs. |
The sandbox exposes documents under `/documents/{document_id}/` with `metadata.json`, `content.txt`, `items.jsonl`, `chunks.jsonl` (chunk ids with their metadata) and `toc.json`. In code, `await search()` results carry `chunk_meta` and `await list_documents()` rows carry `metadata`. The interpreter's limits and the per-call budgets are listed under [MCP, Code](../mcp.md#code).
## Compose an agent
Register it on its own, not alongside `RAGCapability`: it already searches and cites,
and the two together give the model duplicate tools and separate budgets. See
[Capabilities](index.md#compose-an-agent).
```python
from pydantic_ai import Agent
from haiku.rag.capabilities.analysis import create_capability as analysis
from haiku.rag.capabilities.compaction import create_capability as compaction
from haiku.rag.capabilities.policy import create_capability as citation_policy
agent = Agent(
"openai:gpt-5",
capabilities=[
analysis(db_path="my.lancedb"),
compaction(),
citation_policy(),
],
)
```
For the high-level convenience API:
```python
from haiku.rag.client import HaikuRAG
async with HaikuRAG("my.lancedb") as client:
result = await client.analyze("Which quarter had the highest revenue?")
print(result.answer)
```
## State
When dependencies expose a state dictionary, `AnalysisState` is stored under `"analysis"`. It contains the document filter, code execution log, searches, citations, and the `evidence` record of what was retrieved and cited per question. Searches and executions are cleared when a new question starts, and a resumed question keeps them; the filter, citation index and evidence record persist.
This capability does not alter the message history either. Register the [compaction capability](compaction.md) to compact earlier questions.
The capability lazily opens both LanceDB and the sandbox only after it is loaded and a tool requires them. Resources close at the end of the agent run.

View file

@ -1,50 +0,0 @@
# Evidence compaction capability
`EvidenceCompactionCapability` keeps a multi-turn conversation from carrying every
search result it ever produced. Every question adds its evidence to the history, so
requests grow turn after turn, which degrades answers and can exceed a provider's
limits.
Register it alongside an evidence capability:
```python
from pydantic_ai import Agent
from haiku.rag.capabilities.compaction import create_capability as compaction
from haiku.rag.capabilities.rag import create_capability as rag
agent = Agent(
"openai:gpt-5",
capabilities=[rag(db_path="my.lancedb"), compaction()],
)
```
It exposes no tools and takes no configuration. Registering it is the only switch:
leave it out and the transcript reaches the model untouched.
The host must carry the capability state between runs, alongside the message
history: the capsule is built from what earlier questions recorded there. Given
only a message history, every run starts from an empty record, and compaction
refuses rather than replace evidence it cannot retain. See
[Compose an agent](index.md#compose-an-agent) for the shape.
## What it does
On each request, evidence from earlier questions is replaced by the evidence those
questions actually cited. Cited text and cited page images are kept in full, grouped by
the question that cited them, and stay citable by the same chunk ids. Evidence spanning
more than one collection carries a `Collection:` line naming the one it came from. Every
other earlier evidence return becomes a short receipt. The current question is untouched.
Compaction rewrites the request, never the stored history, so `all_messages()` still
holds everything the run gathered.
This reduces what a request carries. It does not bound it: retained evidence still
grows with the conversation. A host that needs more aggressive pruning can compact its
own requests further, on the wire only.
## Resuming a question
Resuming a question (deferred tool results, an interruption, a suspension) requires the
host to carry the capability state from the run being resumed, alongside the message
history. Without it the identity of the question in progress is unknowable, and the run
fails rather than silently treating it as a new question.

View file

@ -1,150 +0,0 @@
# Capabilities
haiku.rag provides native [Pydantic AI capabilities](https://ai.pydantic.dev/capabilities/):
| Capability | Use it for |
|---|---|
| [`RAGCapability`](rag.md) | Grounded document search and citations. |
| [`AnalysisCapability`](analysis.md) | Corpus computation and structural analysis with sandboxed Python. |
| [`EvidenceCompactionCapability`](compaction.md) | Optional. Shrinking a conversation's history to the evidence that was cited. |
| [`CitationPolicyCapability`](policy.md) | Optional. Requiring every answer to declare what grounds it. |
The two evidence capabilities are deferred by default. An agent initially sees only their descriptions and the standard `load_capability` tool. Instructions and tools enter the model context only when the model loads a capability.
## Compose an agent
Pick one evidence capability, and add both optional capabilities to it:
```python
from dataclasses import dataclass, field
from typing import Any
from pydantic_ai import Agent
from pydantic_ai.messages import ModelMessage
from haiku.rag.capabilities.compaction import create_capability as compaction
from haiku.rag.capabilities.policy import create_capability as citation_policy
from haiku.rag.capabilities.rag import create_capability as rag
@dataclass
class Deps:
state: dict[str, Any] = field(default_factory=dict)
agent = Agent(
"openai:gpt-5",
capabilities=[
rag(db_path="my.lancedb"),
compaction(),
citation_policy(),
],
deps_type=Deps,
)
# One Deps and one history for the conversation: the capabilities read both.
deps = Deps()
history: list[ModelMessage] = []
result = await agent.run("What does the knowledge base say about X?", deps=deps, message_history=history)
history = list(result.all_messages())
print(result.output)
```
!!! warning "Both optional capabilities need the host to carry state"
They read what earlier questions retrieved and cited from the capability's
state, so the host must expose a `state` dict on its agent dependencies and
hand the same dict back on every run of a conversation, alongside the message
history. With only the message history, every run starts from an empty record:
compaction refuses rather than replace evidence it cannot retain, and the
citation policy cannot enforce a follow-up about evidence cited earlier.
Swap `rag` for `analysis` for an analysis agent. Both optional capabilities work the
same way with either one, and neither exposes tools or takes configuration.
!!! note "Register one evidence capability, not both"
`RAGCapability` and `AnalysisCapability` overlap. Both search the same corpus and
both register citations, so an agent holding both must choose between two
near-identical search tools, and its citations land in whichever capability it
happened to call. Each also carries its own request limit and its own search
budget, so registering both doubles what a question may spend.
Choose by what the questions need. `RAGCapability` answers questions from retrieved
passages. `AnalysisCapability` adds a Python sandbox and a document filesystem, for
questions that compute over many documents or read their structure, and it can
search too. If you need computation, register the analysis capability alone rather
than adding it to the RAG one.
## Agent specs
The capabilities can be declared in a Pydantic AI [agent spec](https://ai.pydantic.dev/agent-spec/):
```yaml title="agent.yaml"
model: openai:gpt-5
instructions: You are a research assistant with access to a document knowledge base.
capabilities:
- RAGCapability:
db_path: /data/kb.lancedb
defer_loading: false
- EvidenceCompactionCapability
- CitationPolicyCapability
```
Pydantic AI does not discover third-party capabilities, so the caller names the classes:
```python
from pydantic_ai import Agent
from haiku.rag.capabilities.compaction import EvidenceCompactionCapability
from haiku.rag.capabilities.policy import CitationPolicyCapability
from haiku.rag.capabilities.rag import RAGCapability
agent = Agent.from_file(
"agent.yaml",
deps_type=Deps,
custom_capability_types=[
RAGCapability,
EvidenceCompactionCapability,
CitationPolicyCapability,
],
)
```
`deps_type` stays a Python argument, since the capabilities read and write their state
through `deps.state` (see [State](#state)). `Agent.from_file` reads YAML, which needs
`pydantic-ai-slim[spec]`; `Agent.from_spec` takes a dict and needs no YAML parser.
Set `defer_loading: false` when the agent registers a single evidence capability, so its
tools are visible immediately. Leave it at the default when the model should route among
multiple capabilities.
A `config:` block accepts a whole `AppConfig`, for agents in one process that need
different databases or embedding models:
```yaml
capabilities:
- RAGCapability:
db_path: /data/kb.lancedb
config:
embeddings:
model: {provider: ollama, name: embeddinggemma, vector_dim: 2048}
```
The block is read like a `haiku.rag.yaml` file: keys it omits take `AppConfig` defaults
rather than values from the configuration file on disk. The embedding model must match the
database; a mismatch may prevent opening it or produce invalid retrieval. Write the block in
full or omit it and let the [configuration file](../configuration/index.md) apply.
## State
Capabilities use a plain `state: dict[str, Any]` attribute on agent dependencies when one is available. RAG state lives under `"rag"`; analysis state lives under `"analysis"`. This keeps state independent of any transport or UI protocol.
Applications serving AG-UI should adapt the agent with Pydantic AI's `AGUIAdapter`. Native model and tool events require no haiku.rag-specific bridge.
## Database Selection
RAG and analysis capabilities cover the databases the configuration places: [`lancedb.databases`](../configuration/storage.md#multiple-databases), or with nothing configured the default database `haiku.rag` under `storage.data_dir`. The `db_path` argument places one database where the configuration places none; beside `lancedb.databases` it raises `AmbiguousDatabaseError`.
Passing a client through `rag=` bypasses this selection. The capability uses the databases covered by that client and does not close it.

View file

@ -1,54 +0,0 @@
# Citation policy capability
`CitationPolicyCapability` requires every answer to declare what grounds it. Citing is
always available and always recorded without it, but nothing makes the model do it.
Register it alongside an evidence capability:
```python
from pydantic_ai import Agent
from haiku.rag.capabilities.policy import create_capability as citation_policy
from haiku.rag.capabilities.rag import create_capability as rag
agent = Agent(
"openai:gpt-5",
capabilities=[rag(db_path="my.lancedb"), citation_policy()],
)
```
It exposes no tools and takes no configuration. Exactly one policy capability makes
the decision, however many evidence capabilities are registered, so two of them
cannot each demand a citation for one answer.
The host must carry the capability state between runs, alongside the message
history. Enforcement reads what the conversation has already cited, so without it
a follow-up about evidence cited earlier goes unenforced. See
[Compose an agent](index.md#compose-an-agent) for the shape.
## Declaring nothing is a valid answer
A model that finds nothing relevant calls the cite tool with an empty list. That records
the answer as *ungrounded*, which is distinct from an answer that declared nothing at
all (*missing*). The distinction is what makes a declaration requirable without forcing
the model to invent grounding.
## What happens when a question ends undeclared
The model is asked once to record what grounded the answer it already gave. It is not
asked to change the answer. If the cite tool is no longer available by then, or the
question finishes undeclared anyway, the question is recorded in
`CitationPolicyState.violations` under the `"citation_policy"` state key. Pointing a
model at a tool that is gone costs it retries, so the capability records the failure
instead.
## Which answers are enforced
Every answer in a conversation that has something to declare: either this question
retrieved evidence, or the conversation has already cited something, which stays
available to later answers. A follow-up about evidence cited earlier is enforced even
though it searched nothing, which is the case the capability exists for.
Once anything has been cited, later turns are enforced too, a greeting included. The
model satisfies the policy by citing an empty list, at the cost of one extra request. A
conversation with neither a current-question evidence outcome nor any earlier citation
is not enforced.

View file

@ -1,69 +0,0 @@
# RAG Capability
`RAGCapability` adds grounded document search and citations to a Pydantic AI agent. It is deferred by default, so its instructions and tools do not consume model context until loaded.
## Tools
| Tool | Purpose |
|---|---|
| `rag_search(query, limit?)` | Hybrid vector and full-text search with context expansion. |
| `rag_cite(chunk_ids)` | Register exact result chunk IDs as answer citations. |
The distinct `rag_` prefix lets this capability coexist with analysis and other search providers.
## Create and compose
Register it on its own rather than alongside `AnalysisCapability`, which searches and
cites as well. See [Capabilities](index.md#compose-an-agent).
```python
from pydantic_ai import Agent
from haiku.rag.capabilities.compaction import create_capability as compaction
from haiku.rag.capabilities.policy import create_capability as citation_policy
from haiku.rag.capabilities.rag import create_capability as rag
agent = Agent(
"openai:gpt-5",
capabilities=[
rag(db_path="my.lancedb"),
compaction(),
citation_policy(),
],
)
result = await agent.run("What safety equipment does the manual require?")
print(result.output)
```
`create_capability` accepts `db_path`, `config`, `defer_loading`, `request_limit`, and `vision`. Set `defer_loading=False` for a dedicated RAG agent where routing is unnecessary. The default request limit is 20 model requests per question; set `request_limit=None` to disable it. `vision` controls whether picture results are attached to search returns as images and should reflect the model the hosting agent runs; it defaults to the configured QA model's `vision` flag.
When the limit is reached, `rag_search` is removed while `rag_cite` remains for two further requests that call a RAG tool, so the model can register citations before answering from evidence already gathered. Requests spent on other capabilities do not count against that window. Unrelated agent and capability tools remain available. A new agent run starts a fresh limit, so multi-turn chat does not consume one shared budget.
## State
When agent dependencies expose a `state` dictionary, the capability maintains a `RAGState` under `"rag"`:
```python
class RAGState(BaseModel):
citation_index: dict[str, Citation]
citations: list[str]
document_filter: str | None
evidence: CapabilityEvidenceRecord
searches: dict[str, list[SearchResult]]
```
`document_filter`, `citation_index` and `evidence` persist across runs. Citations and searches are cleared when a new question starts; a run that resumes a question keeps the evidence it is still answering from.
`evidence` records which chunks this capability retrieved and cited, and in which question. `haiku.rag.capabilities.ledger.citation_status(records, question=...)` derives `missing`, `grounded` or `ungrounded` from it, across capabilities.
State is ordinary application state; the capability does not depend on AG-UI. An AG-UI application can expose it using Pydantic AI's standard adapter.
## Context management
This capability does not alter the message history. To stop long conversations resending old retrieved content, register the [compaction capability](compaction.md) alongside it.
## Domain context and vision
`prompts.domain_preamble` is prepended to the packaged capability instructions. When the capability's `vision` gate is on (by default, when the configured QA model has `vision: true`), picture results are attached to search returns as `BinaryContent`.
See [Search and question answering](../configuration/qa.md) and [picture processing](../configuration/processing.md#picture-handling).

View file

@ -1 +0,0 @@
--8<-- "CHANGELOG.md"

View file

@ -1,87 +0,0 @@
# Chat
The chat TUI runs conversational RAG against your database from the terminal. Streaming responses, expandable citations with visual grounding, multi-turn sessions, and a command palette for filtering and inspection.
!!! note
Requires the `tui` extra: `pip install haiku.rag-slim[tui]` (included in the full `haiku.rag` package).
## Run it
```bash
haiku-rag chat
haiku-rag chat --db /path/to/database.lancedb
haiku-rag chat --model openai:gpt-4o
```
![Chat TUI session with the analysis capability](img/chat-qa.png)
## How it works
The chat is a Pydantic AI agent with the [RAG capability](capabilities/rag.md) attached by default. A single capability loads eagerly; when both RAG and analysis are enabled, they remain deferred until the model chooses which one to load. Each turn the agent decides which tool to call next, runs hybrid search against your documents, expands context around the hits, may issue further searches, and answers with citations. You see streaming text and native tool events directly from Pydantic AI.
The session is in-memory for the lifetime of the TUI. Conversation history is kept across turns so follow-up questions reuse prior context. Citations are tracked per turn and inspectable via the command palette. Clearing the chat resets the session and the agent's memory.
## Citations and visual grounding
Each answer cites the chunks the agent used, with source document, page numbers, and section headings. Citations are expandable inline. Picture citations render the figure directly underneath the text snippet.
![Expanded citation with an inline figure](img/chat-citation-figure.png)
For visual grounding of a text chunk (the chunk highlighted on its source page image), open the command palette and pick "Show visual grounding". This requires:
- Documents processed via Docling with page images (default for PDFs).
- A terminal that supports inline images (iTerm2, WezTerm, Kitty).
- A stored DoclingDocument on the document. Plain text added via `haiku-rag add` doesn't have it.
You can also render visual grounding from the CLI without launching the TUI:
```bash
haiku-rag visualize <chunk_id>
```
## Attaching images
Press `Ctrl+I` to open the image picker: a directory tree filtered to image files with a live preview. Selecting an image inserts an `[Image #N]` token at the cursor and attaches the image to your next message. Tokens delete as a unit with backspace or delete, and you can place them anywhere in the text to control where each image appears relative to your words.
Retrieval stays text-based; the images are sent to the model alongside your message, so the driving model needs `vision: true` in its configuration.
## Command palette
`Ctrl+P` opens the palette.
| Command | What it does |
|---------|--------------|
| Clear chat | Reset session memory |
| Filter documents | Restrict searches to selected documents |
| Show visual grounding | Visual grounding for a citation |
| Database info | Document and chunk counts, storage stats |
## Capabilities
The default capability is `rag`. Enable `analysis` when the question needs computation, aggregation, comparison across documents, or section-scoped reading that a single search can't deliver:
```bash
# analysis instead of rag
haiku-rag chat -c analysis
# both, which gives the model duplicate search and cite tools
haiku-rag chat -c rag -c analysis
```
Prefer one. `analysis` searches and cites as well as computing, so pairing it with `rag`
duplicates tools and budgets. See [Capabilities](capabilities/index.md#compose-an-agent).
The `analysis` capability mounts every document as a virtual filesystem at `/documents/{id}/` (with `metadata.json`, `content.txt`, `items.jsonl`, and `toc.json`) and runs Python in a sandboxed interpreter with `search` and `list_documents` as awaitable functions. It's the right choice for questions like:
- "How many of these documents mention X?"
- "Summarize Section 5 of paper Y."
- "Compare the experimental sections across these three reports."
- "Which section discusses the proof of Theorem 4.10?"
For everyday Q&A, RAG alone is faster and cheaper. Attaching both lets the agent pick. See [Analysis capability](capabilities/analysis.md).
## Document filter
Run "Filter documents" from the command palette to restrict searches to a subset. The filter applies to every search the agent runs for the rest of the session.
Chat also honors the global `--read-only` flag. See the [CLI reference](cli.md) for details.

View file

@ -6,37 +6,46 @@ The `haiku-rag` CLI provides complete document management functionality.
Global options (must be specified before the command):
- `--config` - Specify custom configuration file
- `--read-only` - Open database in read-only mode (blocks writes, skips upgrades)
- `--db-name` - Name of a database from `lancedb.databases` to work on
- `--version` / `-v` - Show version and exit
Per-command options:
- `--db` - Open the database at this path, named by its stem, whatever the configuration places
- `--db` - Specify custom database path
- `-h` - Show help for specific command
Example:
```bash
haiku-rag --config /path/to/config.yaml list
haiku-rag --config /path/to/config.yaml list --db /path/to/custom.db
haiku-rag --read-only search "query"
haiku-rag --db-name papers list
haiku-rag add -h
```
With `lancedb.databases` configured, `search`, `ask`, `analyze`, `chat`, and `mcp` use the full set by default. Select one database for other commands with `--db-name` or `--db`. `settings`, `init-config`, and `download-models` do not open a database. See [Multiple Databases](configuration/storage.md#multiple-databases).
## Document Management
### List Documents
```bash
haiku-rag list
```
Filter documents by properties:
```bash
# Filter by URI pattern
haiku-rag list --filter "uri LIKE '%arxiv%'"
# Filter by exact title
haiku-rag list --filter "title = 'My Document'"
# Combine multiple conditions
haiku-rag list --filter "uri LIKE '%.pdf' AND title LIKE '%paper%'"
```
### Add Documents
From text:
```bash
haiku-rag add "Your document content here"
# Set a title
haiku-rag add "Your document content here" --title "My Document"
# Attach metadata (repeat --meta for multiple entries)
haiku-rag add "Your document content here" --meta author=alice --meta topic=notes
```
@ -59,19 +68,8 @@ From directory (recursively adds all supported files):
haiku-rag add-src /path/to/documents/
```
From an S3 bucket (requires the `[s3]` extra, see the [ingester docs](ingester.md) for continuous S3 polling):
```bash
# AWS S3 with credentials in the default chain (env vars, IAM role, AWS profile)
haiku-rag add-src s3://my-bucket/path/to/document.pdf
# S3-compatible endpoint (SeaweedFS, MinIO, Cloudflare R2, etc.)
AWS_ACCESS_KEY_ID=key AWS_SECRET_ACCESS_KEY=secret AWS_REGION=us-east-1 \
AWS_ENDPOINT_URL=http://localhost:8333 \
haiku-rag add-src s3://my-bucket/path/to/document.pdf
```
!!! note
When adding a directory, the converter's supported extensions filter applies. For pattern-based ignore/include filtering (e.g. `**/.git/**`), use the [ingester](ingester.md) with a filesystem source.
When adding a directory, the same content filters configured for [file monitoring](configuration.md#filtering-monitored-files) are applied. This means `ignore_patterns` and `include_patterns` from your configuration will be used to filter which files are added.
!!! note
As you add documents to `haiku.rag` the database keeps growing. By default, LanceDB supports versioning
@ -79,24 +77,6 @@ AWS_ACCESS_KEY_ID=key AWS_SECRET_ACCESS_KEY=secret AWS_REGION=us-east-1 \
the database rolls back to the preoperation snapshot using LanceDB table versioning. You can optimize and
compact the database by running the [vacuum](#vacuum-optimize-and-cleanup) command.
### List Documents
```bash
haiku-rag list
```
Filter documents by properties:
```bash
# Filter by URI pattern (--filter or -f)
haiku-rag list --filter "uri LIKE '%arxiv%'"
# Filter by exact title
haiku-rag list --filter "title = 'My Document'"
# Combine multiple conditions
haiku-rag list --filter "uri LIKE '%.pdf' AND title LIKE '%paper%'"
```
### Get Document
```bash
@ -110,6 +90,8 @@ haiku-rag delete 3f4a... # document ID
haiku-rag rm 3f4a... # alias
```
Use this when you want to change things like the embedding model or chunk size for example.
## Search
Basic search:
@ -119,22 +101,10 @@ haiku-rag search "machine learning"
With options:
```bash
haiku-rag search "python programming" --limit 10 # or -l 10
haiku-rag search "python programming" --limit 10
```
With search type:
```bash
# Hybrid search (the default)
haiku-rag search "python programming" --search-type hybrid # or -s hybrid
# Full-text search only
haiku-rag search "python programming" --search-type fts # or -s fts
# Vector search only
haiku-rag search "python programming" --search-type vector # or -s vector
```
With filters (filter by document properties, use `--filter` or `-f`):
With filters (filter by document properties):
```bash
# Filter by URI pattern
haiku-rag search "neural networks" --filter "uri LIKE '%arxiv%'"
@ -146,13 +116,6 @@ haiku-rag search "transformers" --filter "title = 'Deep Learning Guide'"
haiku-rag search "AI" --filter "uri LIKE '%.pdf' AND title LIKE '%paper%'"
```
Image-as-query (requires a multimodal embedder):
```bash
haiku-rag search --image path/to/figure.png --limit 5
```
When `--image` is used, the positional query is omitted. Pass one or the other, not both.
## Question Answering
Ask questions about your documents:
@ -160,130 +123,78 @@ Ask questions about your documents:
haiku-rag ask "Who is the author of haiku.rag?"
```
Filter to specific documents:
Ask questions with citations showing source documents:
```bash
haiku-rag ask "What are the main findings?" --filter "uri LIKE '%paper%'"
haiku-rag ask "Who is the author of haiku.rag?" --cite
```
Attach images to the question, for example to check an image against indexed documents:
Use deep QA for complex questions (multi-agent decomposition):
```bash
haiku-rag ask "Does this photo satisfy the spec in the design document?" --image photo.jpg
haiku-rag ask "What are the main features and architecture of haiku.rag?" --deep --cite
```
`ask` runs the [RAG capability](capabilities/rag.md) and always renders citations under the answer. When available, citations use the document title, otherwise they fall back to the URI.
Citation text is truncated to a 300-character preview. To read the whole passage the model saw:
Show verbose output with deep QA:
```bash
haiku-rag ask "What are the main findings?" --full-citations
haiku-rag ask "What are the main features and architecture of haiku.rag?" --deep --verbose
```
The QA agent will search your documents for relevant information and provide a comprehensive answer. With `--cite`, responses include citations showing which documents were used. With `--deep`, the question is decomposed into sub-questions that are answered in parallel before synthesizing a final answer. With `--verbose` (only with `--deep`), you'll see the planning, searching, evaluation, and synthesis steps as they happen.
When available, citations use the document title; otherwise they fall back to the URI.
## Research
Run the multi-step research graph:
```bash
haiku-rag research "How does haiku.rag organize and query documents?" \
--max-iterations 2 \
--confidence-threshold 0.8 \
--max-concurrency 3 \
--verbose
```
Flags:
- `--max-iterations, -n`: maximum search/evaluate cycles (default: 3)
- `--confidence-threshold`: stop once evaluation confidence meets/exceeds this (default: 0.8)
- `--max-concurrency`: number of sub-questions searched in parallel each iteration (default: 3)
- `--verbose`: show planning, searching previews, evaluation summary, and stop reason
- `--filter` / `-f`: Restrict searches to documents matching the filter (see [Filtering Search Results](python.md#filtering-search-results))
- `--image`: Path to an image attached to the question (repeatable). Retrieval stays text-based; the model must have `vision: true` configured.
- `--full-citations`: Show the full text of each citation instead of a truncated preview
When `--verbose` is set the CLI also consumes the internal research stream, printing every `log` event as agents progress through planning, search, evaluation, and synthesis. If you build your own integration, call `stream_research_graph` to access the same `log`, `report`, and `error` events and render them however you like while the graph is running.
## Analyze
Answer complex analytical questions via code execution:
## Server
Start services (requires at least one flag):
```bash
haiku-rag analyze "How many documents mention security?"
# MCP server only (HTTP transport)
haiku-rag serve --mcp
# MCP server (stdio transport)
haiku-rag serve --mcp --stdio
# File monitoring only
haiku-rag serve --monitor
# Both services
haiku-rag serve --monitor --mcp
# Custom port
haiku-rag serve --mcp --mcp-port 9000
```
Filter to specific documents:
See [Server Mode](server.md) for details on available services.
## Settings
View current configuration settings:
```bash
haiku-rag analyze "What is the total revenue?" --filter "title LIKE '%Financial%'"
haiku-rag settings
```
Flags:
## Maintenance
- `--filter` / `-f`: SQL WHERE clause to restrict document access
- `--image`: Path to an image attached to the question (repeatable). Requires `vision: true` on the analysis model.
- `--full-citations`: Show the full text of each citation instead of a truncated preview
### Info (Read-only)
See [Analysis capability](capabilities/analysis.md) for details and configuration.
## Chat
Launch an interactive chat session for multi-turn conversations:
```bash
haiku-rag chat
haiku-rag chat --db /path/to/database.lancedb
# Enable the analysis capability (code execution)
haiku-rag chat -c rag -c analysis
```
!!! note
Requires the `tui` extra: `pip install haiku.rag-slim[tui]` (included in full `haiku.rag` package)
Flags:
- `--capability` / `-c`: Capabilities to enable. `rag` (default), `analysis`. Can be repeated.
The chat interface provides:
- Streaming responses with real-time tool execution
- Expandable citations with source metadata
- Session memory for context-aware follow-up questions
- Visual grounding to inspect chunk source locations
See [Chat](chat.md) for keyboard shortcuts and features.
## Inspect
Launch the interactive inspector TUI for browsing documents and chunks:
```bash
haiku-rag inspect
haiku-rag inspect --db /path/to/database.lancedb
```
!!! note
Requires the `tui` extra: `pip install haiku.rag-slim[tui]` (included in full `haiku.rag` package)
The inspector provides:
- Browse all documents in the database
- View document metadata and content
- Explore individual chunks
- Search and filter results
See [Tuning: Inspector](tuning.md#inspector) for the full keybindings and modal flows.
## Visualize Chunk
Display visual grounding for a chunk - shows page images with highlighted bounding boxes:
```bash
haiku-rag visualize <chunk_id>
```
This renders the source document pages with the chunk's location highlighted. The chunk itself draws in a strong highlight, while surrounding context swept in by expansion draws fainter. Useful for verifying chunk boundaries and understanding document structure.
Pass `--no-expand` to highlight only the chunk itself, without its expanded context.
!!! note
Requires a terminal with image support (iTerm2, Kitty, WezTerm, etc.) and documents processed with docling that have page images stored.
## Database lifecycle
### Initialize Database
Create a new database:
```bash
haiku-rag init [--db /path/to/your.lancedb]
```
This creates the database with the configured settings. **All other commands require an existing database** - they will fail with an informative error if the database doesn't exist.
### Info
Display database metadata:
Display database metadata without upgrading or modifying it:
```bash
haiku-rag info [--db /path/to/your.lancedb]
@ -293,166 +204,14 @@ Shows:
- path to the database
- stored haiku.rag version (from settings)
- embeddings provider/model and vector dimension
- per-table row counts and storage sizes (documents, document_meta, chunks, document_items)
- vector index status (exists/not created, indexed/unindexed chunks)
- table versions per table (documents, document_meta, chunks)
- number of documents
- table versions per table (documents, chunks)
At the end, a separate "Versions" section lists runtime package versions:
At the end, a separate “Versions” section lists runtime package versions:
- haiku.rag
- lancedb
- docling
### Doctor
Check the database for consistency problems and print a pass/warn/fail report:
```bash
haiku-rag doctor [--db /path/to/your.lancedb] [--duplicates-out groups.yaml]
```
While it runs, doctor shows a spinner naming the check currently in progress.
`--duplicates-out PATH` additionally writes the near-duplicate document groups to a YAML file (one block per group with `keep` and a list of `documents`, each carrying `document_id`, `document`, `chunks`, `similarity`, and `keep_suggested`) for offline review.
Checks include:
- required tables are present
- `documents` and `document_meta` are in 1:1 correspondence
- chunks and document items reference documents that exist
- documents with text content produced chunks (empty and heading/furniture-only documents are not flagged; image-only documents are flagged according to whether the embedder can index images)
- chunked documents have document items (empty documents are not flagged)
- chunk `doc_item_refs` resolve to existing document items
- chunk vector size matches the stored embedding dimension
- chunks are embedded (no all-zero vectors)
- pictures in image/PDF documents carry their image data (external image references in text documents are not flagged)
- exactly one settings row is present
- the configured embedding identity matches the stored settings
- no database migrations are pending
- the vector index covers all chunks
- the full-text index covers the chunks it searches
- near-identical documents (by embedding-centroid similarity) are grouped and reported, with the largest member flagged as the likely one to keep (advisory only, never deleted, tuned via `doctor.duplicates` in config)
- API keys are set for configured providers
It also probes the external endpoints the config uses and reports them under a Providers section:
- Ollama is reachable and the configured models are installed (`{base_url}/api/tags`)
- docling-serve is reachable when used as the converter or chunker (`{base_url}/health`)
- custom OpenAI-compatible and vLLM endpoints respond (`{base_url}/models`)
SaaS providers (OpenAI, Anthropic, Cohere, Jina, ZeroEntropy, Voyage) are covered by the API-key check rather than a network probe. In-process local models (sentence-transformers, cross-encoder, jina-local) have no endpoint and are reported as such.
Each failure prints the command that fixes it (`rebuild`, `create-index`, `vacuum`, `migrate`, `rebuild --set-embedder`). `doctor` makes no changes. It exits with status 1 when any check fails, so it can gate CI or monitoring.
### Migrate Database
Apply pending database migrations:
```bash
haiku-rag migrate [--db /path/to/your.lancedb]
```
When you upgrade haiku.rag to a new version that includes schema changes, the database requires migration. Opening a database with pending migrations will display an error:
```
Error: Database requires migration from 0.19.0 to 0.26.5. 3 migration(s) pending. Run 'haiku-rag migrate' to upgrade.
```
Run `haiku-rag migrate` to apply the pending migrations. The command shows which migrations were applied:
```
Applied 4 migration(s):
- 0.20.0: Add 'docling_document_json' and 'docling_version' columns
- 0.23.1: Add content_fts column for contextualized FTS search
- 0.25.0: Compress docling_document with gzip
- 0.38.0: Split docling_document pages into separate column and re-compress with zstd
Migration completed successfully.
```
!!! tip
Back up your database before running migrations. While migrations are designed to be safe, having a backup provides peace of mind for production databases.
### Download Models
Download required runtime models:
```bash
haiku-rag download-models
```
This command downloads:
- Docling OCR/conversion models
- HuggingFace tokenizer (for chunking)
- Ollama models referenced in your configuration (embeddings, QA, rerank)
Progress is displayed in real-time with download status and progress bars for Ollama model pulls.
## Maintenance
### Create Vector Index
Create a vector index on the chunks table for fast approximate nearest neighbor search:
```bash
haiku-rag create-index [--db /path/to/your.lancedb]
```
**Requirements:**
- Minimum 256 chunks required for index creation (LanceDB training data requirement)
- Creates an IVF_PQ index using the configured `search.vector_index_metric` (cosine/l2)
**When to use:**
- On a collection over 100,000 chunks (below that, brute-force kNN is exact and fast enough)
- After substantial corpus growth, to retrain the centroids
- Use `haiku-rag info` to check index status and see how many chunks are indexed/unindexed, or `haiku-rag doctor` for the same as a health check
See [Vector Indexing](configuration/storage.md#vector-indexing) for the measured accuracy and build cost.
**Search behavior:**
- Without index: Brute-force kNN search (exact nearest neighbors, slower for large datasets)
- With index: ANN (approximate nearest neighbors) using IVF_PQ, tuned by `search.vector_nprobes`
- Between a write and the next `optimize()`: LanceDB combines ANN over indexed rows with brute-force kNN over the remainder
### Rebuild Database
Rebuild the database by re-indexing documents. Useful when switching embeddings provider/model or changing chunking settings:
```bash
# Full rebuild (default) - re-converts from source files, re-chunks, re-embeds
haiku-rag rebuild
# Re-chunk from stored content (no source file access)
haiku-rag rebuild --rechunk
# Only regenerate embeddings (fastest, keeps existing chunks)
haiku-rag rebuild --embed-only
# Only generate titles for untitled documents
haiku-rag rebuild --title-only
# Run the VLM over already-stored picture bytes and patch descriptions
# into the docling blob. Skips the docling parse entirely.
haiku-rag rebuild --descriptions
# Adopt the current embedder identity without re-embedding (same vector dimension)
haiku-rag rebuild --set-embedder
```
**Rebuild modes:**
| Mode | Flag | Use case |
|------|------|----------|
| Full | (default) | Changed converter, source files updated |
| Rechunk | `--rechunk` | Changed chunking strategy or chunk size |
| Embed only | `--embed-only` | Changed embedding model or vector dimensions |
| Title only | `--title-only` | Generate titles for documents without one |
| Descriptions | `--descriptions` | Add VLM picture descriptions to an existing database |
| Set embedder | `--set-embedder` | Same model, different serving stack (e.g. Ollama to vLLM); vector dimension unchanged |
**`--set-embedder` mode** updates the stored embedding provider/name to match the current config without re-embedding, valid only when the vector dimension is unchanged. Use it when the same model is served by a different stack so the recorded identity stops drifting from the config. A changed vector dimension is rejected; regenerate embeddings with `--embed-only` or a full rebuild instead.
**`--descriptions` mode** runs the configured VLM (`processing.conversion_options.picture_description.model`) over the picture bytes already stored in `document_items.picture_data`, patches each description into the stored docling blob's `pictures[i].meta.description.text`, and re-chunks + re-embeds so chunk text reflects the new descriptions. Requires `processing.pictures: description` in the config. Idempotent: pictures that already carry a description are skipped, so the operation is safe to re-run after a partial failure. The docling parse is skipped entirely. Only the VLM time is paid.
### Vacuum (Optimize and Cleanup)
Reduce disk usage by optimizing and pruning old table versions across all tables:
@ -461,119 +220,25 @@ Reduce disk usage by optimizing and pruning old table versions across all tables
haiku-rag vacuum
```
**Automatic Cleanup:** Vacuum runs automatically in the background after document operations, throttled to at most once every 5 minutes so sustained ingestion does not trigger continuous compaction (a final vacuum runs when the client closes). By default, it removes versions older than 1 day (configurable via `storage.vacuum_retention_seconds`), preserving recent versions for concurrent connections. Manual vacuum can be useful for cleanup after bulk operations or to free disk space immediately.
**Automatic Cleanup:** Vacuum runs automatically in the background after document operations. By default, it removes versions older than 60 seconds (configurable via `storage.vacuum_retention_seconds`), preserving recent versions for concurrent connections. Manual vacuum can be useful for cleanup after bulk operations or to free disk space immediately.
## MCP Server
### Rebuild Database
Rebuild the database by deleting all chunks & embeddings and re-indexing all documents. This is useful
when want to switch embeddings provider or model:
```bash
# HTTP transport on port 8001
haiku-rag mcp
# stdio transport (for Claude Desktop)
haiku-rag mcp --stdio
# Custom port
haiku-rag mcp --port 9000
# Bind to all interfaces (containers, trusted LAN)
haiku-rag mcp --host 0.0.0.0
haiku-rag rebuild
```
See [MCP](mcp.md) for details. For continuous document ingestion
(filesystem watch, S3 polling, HTTP / WebDAV sources), use the
[ingester](ingester.md).
### Download Models
## Settings
View current configuration settings:
```bash
haiku-rag settings
```
### Generate Configuration File
Generate a YAML configuration file with defaults:
```bash
haiku-rag init-config [output_path]
```
If no path is specified, creates `haiku.rag.yaml` in the current directory.
## Tags
A tag names the current database state. It is a logical snapshot composed of one LanceDB tag on each of the five tables, created from a single version snapshot.
Download required runtime models:
```bash
# Tag the current state, e.g. at deploy time or after an ingestion run
haiku-rag tag create release-1
# List tags with the versions they point to
haiku-rag tag list
# Delete a tag, releasing its versions for cleanup
haiku-rag tag delete release-1
haiku-rag download-models
```
A tag present on every table is complete. A tag missing from some tables (created outside haiku.rag, or left behind by a failure) is partial. `tag list` marks partial tags. Partial tags can be listed and deleted but never restored.
Create tags with other writers stopped. Tag creation coordinates writers within one process only; a writer in another process can commit between the per-table snapshot reads, and the tag then captures a mixed state.
Tagged versions survive `vacuum`. Vacuum retains the oldest tagged version and every newer version; versions older than the oldest tag remain eligible for cleanup. Delete tags you no longer need so cleanup can advance.
### Restore
`tag restore` brings the database back to a tagged state:
```bash
haiku-rag tag restore release-1
```
Restore changes the live state. It is not a read-only view: each table gets a new latest version equal to the tagged one, and reads and writes continue from there. Versions written after the tag remain in history until vacuum removes them.
Before changing anything, restore creates a complete safety tag (`before-restore-<timestamp>`) for the current state and reports it, so you always have a named path back:
```bash
haiku-rag tag create release-1 --db /path/to/db.lancedb
# Stop all writers before either restore.
haiku-rag tag restore release-1 --db /path/to/db.lancedb --yes
haiku-rag tag list --db /path/to/db.lancedb
haiku-rag tag restore before-restore-YYYYMMDDTHHMMSSZ --db /path/to/db.lancedb --yes
```
Restore is a maintenance operation:
- Stop all ingestion and other writers before restoring and keep them stopped until it finishes.
- The operation is coordinated but not transactionally atomic across tables. On failure it attempts to roll back to the pre-restore state and reports whether the rollback succeeded.
- `--yes` only skips the confirmation prompt. It provides no locking and no concurrent-writer protection.
- Restore never migrates. Restoring a tag from an older haiku.rag version completes normally, and the next open reports the required migration. Run `haiku-rag migrate` explicitly.
### Version History
View version history for database tables:
```bash
# Show history for all tables
haiku-rag history
# Show history for a specific table
haiku-rag history --table documents
# Limit number of versions shown
haiku-rag history --limit 10
```
Output shows version numbers and timestamps, sorted newest first, with tags marked:
```
Version History
documents
v5: 2025-01-15 14:30:00 <- release-1
v4: 2025-01-14 10:00:00
v3: 2025-01-13 09:15:00
chunks
v8: 2025-01-15 14:30:00 <- release-1
v7: 2025-01-14 10:00:00
...
```
This command:
- Downloads Docling OCR/conversion models (no-op if already present).
- Pulls Ollama models referenced in your configuration (embeddings, QA, research, rerank).

574
docs/configuration.md Normal file
View file

@ -0,0 +1,574 @@
# Configuration
Configuration is done through YAML configuration files.
!!! note
If you create a db with certain settings and later change them, `haiku.rag` will detect incompatibilities (for example, if you change embedding provider) and will exit. You can **rebuild** the database to apply the new settings, see [Rebuild Database](./cli.md#rebuild-database).
## Getting Started
Generate a configuration file with defaults:
```bash
haiku-rag init-config
```
This creates a `haiku.rag.yaml` file in your current directory with all available settings.
## Configuration File Locations
`haiku.rag` searches for configuration files in this order:
1. Path specified via `--config` flag: `haiku-rag --config /path/to/config.yaml <command>`
2. `./haiku.rag.yaml` (current directory)
3. Platform-specific user directory:
- **Linux**: `~/.local/share/haiku.rag/haiku.rag.yaml`
- **macOS**: `~/Library/Application Support/haiku.rag/haiku.rag.yaml`
- **Windows**: `C:/Users/<USER>/AppData/Roaming/haiku.rag/haiku.rag.yaml`
## Minimal Configuration
A minimal configuration file with defaults:
```yaml
# haiku.rag.yaml
environment: production
embeddings:
provider: ollama
model: qwen3-embedding
vector_dim: 4096
qa:
provider: ollama
model: gpt-oss
```
## Complete Configuration Example
```yaml
# haiku.rag.yaml
environment: production
storage:
data_dir: "" # Empty = use default platform location
disable_autocreate: false
vacuum_retention_seconds: 60
monitor:
directories:
- /path/to/documents
- /another/path
ignore_patterns: [] # Gitignore-style patterns to exclude
include_patterns: [] # Gitignore-style patterns to include
lancedb:
uri: "" # Empty for local, or db://, s3://, az://, gs://
api_key: ""
region: ""
embeddings:
provider: ollama
model: qwen3-embedding
vector_dim: 4096
reranking:
provider: "" # Empty to disable, or mxbai, cohere, zeroentropy, vllm
model: ""
qa:
provider: ollama
model: gpt-oss
research:
provider: "" # Empty to use qa settings
model: ""
processing:
chunk_size: 256
context_chunk_radius: 0
markdown_preprocessor: ""
providers:
ollama:
base_url: http://localhost:11434
vllm:
embeddings_base_url: ""
rerank_base_url: ""
qa_base_url: ""
research_base_url: ""
```
## Programmatic Configuration
When using haiku.rag as a Python library, you can pass configuration directly to the `HaikuRAG` client:
```python
from haiku.rag.config import AppConfig
from haiku.rag.client import HaikuRAG
# Create custom configuration
custom_config = AppConfig(
qa={"provider": "openai", "model": "gpt-4o"},
embeddings={"provider": "ollama", "model": "qwen3-embedding"},
processing={"chunk_size": 512}
)
# Pass configuration to the client
client = HaikuRAG(config=custom_config)
```
If you don't pass a config, the client uses the global configuration loaded from your YAML file or defaults.
This is useful for:
- Jupyter notebooks
- Python scripts
- Testing with different configurations
- Applications that need multiple clients with different configurations
## File Monitoring
Set directories to monitor for automatic indexing:
```yaml
monitor:
directories:
- /path/to/documents
- /another_path/to/documents
```
### Filtering Monitored Files
Use gitignore-style patterns to control which files are monitored:
```yaml
monitor:
directories:
- /path/to/documents
# Exclude specific files or directories
ignore_patterns:
- "*draft*" # Ignore files with "draft" in the name
- "temp/" # Ignore temp directory
- "**/archive/**" # Ignore all archive directories
- "*.backup" # Ignore backup files
# Only include specific files (whitelist mode)
include_patterns:
- "*.md" # Only markdown files
- "*.pdf" # Only PDF files
- "**/docs/**" # Only files in docs directories
```
**How patterns work:**
1. **Extension filtering** - Only supported file types are considered
2. **Include patterns** - If specified, only matching files are included (whitelist)
3. **Ignore patterns** - Matching files are excluded (blacklist)
4. **Combining both** - Include patterns are applied first, then ignore patterns
**Common patterns:**
```yaml
# Only monitor markdown documentation, but ignore drafts
monitor:
include_patterns:
- "*.md"
ignore_patterns:
- "*draft*"
- "*WIP*"
# Monitor all supported files except in specific directories
monitor:
ignore_patterns:
- "node_modules/"
- ".git/"
- "**/test/**"
- "**/temp/**"
```
Patterns follow [gitignore syntax](https://git-scm.com/docs/gitignore#_pattern_format):
- `*` matches anything except `/`
- `**` matches zero or more directories
- `?` matches any single character
- `[abc]` matches any character in the set
## Embedding Providers
If you use Ollama, you can use any pulled model that supports embeddings.
### Ollama (Default)
```yaml
embeddings:
provider: ollama
model: mxbai-embed-large
vector_dim: 1024
```
The Ollama base URL can be configured in your config file or via environment variable:
```yaml
providers:
ollama:
base_url: http://localhost:11434
```
Or via environment variable:
```bash
export OLLAMA_BASE_URL=http://localhost:11434
```
If not configured, it defaults to `http://localhost:11434`.
!!! note
You can use a `.env` file in your project directory to set environment variables like `OLLAMA_BASE_URL` and API keys (e.g., `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`). These will be automatically loaded when running `haiku-rag` commands.
### VoyageAI
If you installed `haiku.rag` (full package), VoyageAI is already included. If you installed `haiku.rag-slim`, install with VoyageAI extras:
```bash
uv pip install haiku.rag-slim[voyageai]
```
```yaml
embeddings:
provider: voyageai
model: voyage-3.5
vector_dim: 1024
```
Set your API key via environment variable:
```bash
export VOYAGE_API_KEY=your-api-key
```
### OpenAI
OpenAI embeddings are included in the default installation:
```yaml
embeddings:
provider: openai
model: text-embedding-3-small # or text-embedding-3-large
vector_dim: 1536
```
Set your API key via environment variable:
```bash
export OPENAI_API_KEY=your-api-key
```
### vLLM
For high-performance local inference, you can use vLLM to serve embedding models with OpenAI-compatible APIs:
```yaml
embeddings:
provider: vllm
model: mixedbread-ai/mxbai-embed-large-v1
vector_dim: 512
providers:
vllm:
embeddings_base_url: http://localhost:8000
```
**Note:** You need to run a vLLM server separately with an embedding model loaded.
## Question Answering Providers
Configure which LLM provider to use for question answering. Any provider and model supported by [Pydantic AI](https://ai.pydantic.dev/models/) can be used.
### Ollama (Default)
```yaml
qa:
provider: ollama
model: gpt-oss
```
The Ollama base URL can be configured via the `OLLAMA_BASE_URL` environment variable, config file, or defaults to `http://localhost:11434`:
```bash
export OLLAMA_BASE_URL=http://localhost:11434
```
Or in your config file:
```yaml
providers:
ollama:
base_url: http://localhost:11434
```
### OpenAI
OpenAI QA is included in the default installation:
```yaml
qa:
provider: openai
model: gpt-4o-mini # or gpt-4, gpt-3.5-turbo, etc.
```
Set your API key via environment variable:
```bash
export OPENAI_API_KEY=your-api-key
```
### Anthropic
Anthropic QA is included in the default installation:
```yaml
qa:
provider: anthropic
model: claude-3-5-haiku-20241022 # or claude-3-5-sonnet-20241022, etc.
```
Set your API key via environment variable:
```bash
export ANTHROPIC_API_KEY=your-api-key
```
### vLLM
For high-performance local inference:
```yaml
qa:
provider: vllm
model: Qwen/Qwen3-4B # Any model with tool support in vLLM
providers:
vllm:
qa_base_url: http://localhost:8002
```
**Note:** You need to run a vLLM server separately with a model that supports tool calling loaded. Consult the specific model's documentation for proper vLLM serving configuration.
### Other Providers
Any provider supported by Pydantic AI can be used. Examples:
```yaml
# Google Gemini
qa:
provider: gemini
model: gemini-1.5-flash
# Groq
qa:
provider: groq
model: llama-3.3-70b-versatile
# Mistral
qa:
provider: mistral
model: mistral-small-latest
```
See the [Pydantic AI documentation](https://ai.pydantic.dev/models/) for the complete list of supported providers and models.
## Reranking
Reranking improves search quality by re-ordering the initial search results using specialized models. When enabled, the system retrieves more candidates (3x the requested limit) and then reranks them to return the most relevant results.
Reranking is **disabled by default** (`provider: ""`) for faster searches. You can enable it by configuring one of the providers below.
### MixedBread AI
If you installed `haiku.rag` (full package), MxBAI is already included. If you installed `haiku.rag-slim`, add the mxbai extra:
```bash
uv pip install haiku.rag-slim[mxbai]
```
Then configure:
```yaml
reranking:
provider: mxbai
model: mixedbread-ai/mxbai-rerank-base-v2
```
### Cohere
If you installed `haiku.rag` (full package), Cohere is already included. If you installed `haiku.rag-slim`, add the cohere extra:
```bash
uv pip install haiku.rag-slim[cohere]
```
Then configure:
```yaml
reranking:
provider: cohere
model: rerank-v3.5
```
Set your API key via environment variable:
```bash
export CO_API_KEY=your-api-key
```
### Zero Entropy
If you installed `haiku.rag` (full package), Zero Entropy is already included. If you installed `haiku.rag-slim`, add the zeroentropy extra:
```bash
uv pip install haiku.rag-slim[zeroentropy]
```
Then configure:
```yaml
reranking:
provider: zeroentropy
model: zerank-1 # Currently the only available model
```
Set your API key via environment variable:
```bash
export ZEROENTROPY_API_KEY=your-api-key
```
### vLLM
For high-performance local reranking using dedicated reranking models:
```yaml
reranking:
provider: vllm
model: mixedbread-ai/mxbai-rerank-base-v2
providers:
vllm:
rerank_base_url: http://localhost:8001
```
**Note:** vLLM reranking uses the `/rerank` API endpoint. You need to run a vLLM server separately with a reranking model loaded. Consult the specific model's documentation for proper vLLM serving configuration.
## Other Settings
### Database and Storage
By default, `haiku.rag` uses a local LanceDB database:
```yaml
storage:
data_dir: /path/to/data # Empty = use default platform location
```
For remote storage, use the `lancedb` settings with various backends:
```yaml
# LanceDB Cloud
lancedb:
uri: db://your-database-name
api_key: your-api-key
region: us-west-2 # optional
# Amazon S3
lancedb:
uri: s3://my-bucket/my-table
# Use AWS credentials or IAM roles
# Azure Blob Storage
lancedb:
uri: az://my-container/my-table
# Use Azure credentials
# Google Cloud Storage
lancedb:
uri: gs://my-bucket/my-table
# Use GCP credentials
# HDFS
lancedb:
uri: hdfs://namenode:port/path/to/table
```
Authentication is handled through standard cloud provider credentials (AWS CLI, Azure CLI, gcloud, etc.) or by setting `api_key` for LanceDB Cloud.
**Note:** Table optimization is automatically handled by LanceDB Cloud (`db://` URIs) and is disabled for better performance. For object storage backends (S3, Azure, GCS), optimization is still performed locally.
#### Disable database auto-creation
By default, haiku.rag creates the local LanceDB directory and required tables on first use. To prevent accidental database creation and fail fast if a database hasn't been set up yet:
```yaml
storage:
disable_autocreate: true
```
When enabled, for local paths, haiku.rag errors if the LanceDB directory does not exist, and it will not create parent directories.
### Document Processing
```yaml
processing:
# Chunk size for document processing
chunk_size: 256
# Number of adjacent chunks to include before/after retrieved chunks for context
# 0 = no expansion (default), 1 = include 1 chunk before and after, etc.
# When expanded chunks overlap or are adjacent, they are automatically merged
# into single chunks with continuous content to eliminate duplication
context_chunk_radius: 0
# Optional dotted path or file path to a callable that preprocesses
# markdown content before chunking
markdown_preprocessor: ""
storage:
# Vacuum retention threshold (seconds) for automatic cleanup
# When documents are added/updated, old table versions older than this are removed
# Default: 60 seconds (safe for concurrent connections)
# Set to 0 for aggressive cleanup (removes all old versions immediately)
vacuum_retention_seconds: 60
```
#### Markdown Preprocessor
Optionally preprocess Markdown before chunking by pointing to a callable that receives and returns Markdown text. This is useful for normalizing content, stripping boilerplate, or applying custom transformations before chunk boundaries are computed.
```yaml
processing:
# A callable path in one of these formats:
# - package.module:func
# - package.module.func
# - /abs/or/relative/path/to/file.py:func
markdown_preprocessor: my_pkg.preprocess:clean_md
```
!!! note
- The function signature should be `def clean_md(text: str) -> str` or `async def clean_md(text: str) -> str`.
- If the function raises or returns a non-string, haiku.rag logs a warning and proceeds without preprocessing.
- The preprocessor affects only the chunking pipeline. The stored document content remains unchanged.
Example implementation:
```python
# my_pkg/preprocess.py
def clean_md(text: str) -> str:
# strip HTML comments and collapse multiple blank lines
lines = [line for line in text.splitlines() if not line.strip().startswith("<!--")]
out = []
for line in lines:
if line.strip() == "" and (out and out[-1] == ""):
continue
out.append(line)
return "\n".join(out)
```

View file

@ -1,211 +0,0 @@
# Configuration
Configuration is done through YAML configuration files.
!!! note
haiku.rag enforces one hard rule on existing databases: the embedding `vector_dim` in your config must match the value stored in the db. A mismatch exits with `ConfigMismatchError` and you must **rebuild** to apply the change (see [Rebuild Database](../cli.md#rebuild-database)).
Opening a database never writes to it, so the stored embedding identity is left untouched. Changing only `provider` or `name` (e.g. switching from Ollama to vLLM serving the same model) is treated as soft drift: read-only opens log a warning and continue, while writable opens exit with `ConfigMismatchError`. Reconcile the stored identity with your config by running `haiku-rag rebuild --set-embedder` (see [Rebuild Database](../cli.md#rebuild-database)). If the change was unintentional, revert your config instead.
## Getting Started
Generate a configuration file with defaults:
```bash
haiku-rag init-config
```
This creates a `haiku.rag.yaml` file in your current directory with all available settings.
## Configuration File Locations
`haiku.rag` searches for configuration files in this order:
1. Path specified via `--config` flag: `haiku-rag --config /path/to/config.yaml <command>`
2. `./haiku.rag.yaml` (current directory)
3. Platform-specific user directory:
- **Linux**: `~/.local/share/haiku.rag/haiku.rag.yaml`
- **macOS**: `~/Library/Application Support/haiku.rag/haiku.rag.yaml`
- **Windows**: `C:/Users/<USER>/AppData/Roaming/haiku.rag/haiku.rag.yaml`
## Environment Variables
Any string value can reference an environment variable, so secrets stay out of the file and one config can serve multiple deployments:
```yaml
ingester:
queue:
dburi: postgresql+asyncpg://haiku:${POSTGRES_PASSWORD}@db:5432/haiku_rag
```
- `${VAR}` is replaced with the value of `VAR`. If `VAR` is unset, loading fails with an error naming the variable.
- `${VAR:-default}` uses `default` when `VAR` is unset or empty.
- `$$` produces a literal `$`.
Substitution happens after the YAML is parsed, so a value containing `:`, `@`, or `#` fills the string verbatim and never changes the document structure.
## Minimal Configuration
A minimal configuration file with defaults:
```yaml
# haiku.rag.yaml
environment: production
embeddings:
model:
provider: ollama
name: qwen3-embedding:4b
vector_dim: 2560
qa:
model:
provider: ollama
name: qwen3.8
enable_thinking: true
```
## Complete Configuration Example
```yaml
# haiku.rag.yaml
environment: production
storage:
data_dir: "" # Empty = use default platform location
vacuum_retention_seconds: 86400
ingester:
sources:
- type: fs
id: local-docs
root: /path/to/documents
ignore_patterns: [] # Gitignore-style patterns to exclude
include_patterns: [] # Gitignore-style patterns to include
delete_orphans: true
lancedb:
databases: {} # Name-to-location map; empty places haiku.rag under data_dir
api_key: "" # LanceDB Cloud (db://) credentials
region: ""
embeddings:
model:
provider: ollama
name: qwen3-embedding:4b
vector_dim: 2560
reranking:
# Omit this section, or set `model: null`, to disable reranking.
model:
provider: cross-encoder # cross-encoder, cohere, zeroentropy, vllm, jina, jina-local
name: cross-encoder/ms-marco-MiniLM-L-6-v2
multimodal: false # vllm only: send picture chunks to the reranker as images
qa:
model:
provider: ollama
name: qwen3.8
enable_thinking: true
temperature: 0.3
max_searches: 5
search:
limit: 5 # Default number of results to return
max_context_chars: 5000 # Maximum characters in expanded context
vector_index_metric: cosine # cosine or l2
vector_refine_factor: 30
doctor:
duplicates: # Near-duplicate document detection (doctor command)
similarity_threshold: 0.97 # cosine cutoff on document embedding centroids
min_chunks: 3 # documents with fewer chunks are excluded
prompts:
domain_preamble: "" # Prepended to capability instructions
processing:
converter: docling-local # docling-local or docling-serve
chunker: docling-local # docling-local or docling-serve
chunker_type: hybrid # hybrid or hierarchical
chunk_size: 256
chunking_tokenizer: "Qwen/Qwen3-Embedding-0.6B"
chunking_merge_peers: true
chunking_use_markdown_tables: false
auto_title: false # Auto-generate titles on ingestion
title_model:
provider: ollama
name: qwen3.8
enable_thinking: false
temperature: 0.3
max_tokens: 100
conversion_options:
do_ocr: true
force_ocr: false
ocr_lang: []
do_table_structure: true
table_mode: accurate
table_cell_matching: true
images_scale: 2.0
providers:
ollama:
base_url: http://localhost:11434
docling_serve:
base_url: http://localhost:5001
api_key: ""
timeout: 300
```
## Programmatic Configuration
When using haiku.rag as a Python library, you can pass configuration directly to the `HaikuRAG` client:
```python
from haiku.rag.config import AppConfig
from haiku.rag.config.models import EmbeddingModelConfig, ModelConfig, QAConfig, EmbeddingsConfig
from haiku.rag.client import HaikuRAG
# Create custom configuration
custom_config = AppConfig(
qa=QAConfig(
model=ModelConfig(
provider="openai",
name="gpt-4o",
temperature=0.3
)
),
embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig(
provider="ollama",
name="qwen3-embedding:4b",
vector_dim=2560
)
),
processing={"chunk_size": 512}
)
# Pass configuration to the client
async with HaikuRAG(config=custom_config) as client:
...
```
If you don't pass a config, the client uses the global configuration loaded from your YAML file or defaults.
This is useful for:
- Jupyter notebooks
- Python scripts
- Testing with different configurations
- Applications that need multiple clients with different configurations
## Configuration Topics
For detailed configuration of specific topics, see:
- **[Providers](providers.md)** - Model settings and provider-specific configuration (embeddings, reranking)
- **[Search and Question Answering](qa.md)** - Search settings and question answering
- **[Document Processing](processing.md)** - Document conversion and chunking
- **[Ingester](../ingester.md)** - Continuous ingestion from filesystem, HTTP, S3, and WebDAV sources
- **[Storage](storage.md)** - Database, remote storage, and vector indexing
- **[Prompts](prompts.md)** - Customize agent prompts for your domain

View file

@ -1,419 +0,0 @@
# Document Processing
This guide covers how haiku.rag converts and chunks documents. Continuous
ingestion (watching directories, polling HTTP / S3 / WebDAV sources) lives
in the [ingester](../ingester.md) service.
## Document Processing
Configure how documents are converted and chunked:
```yaml
processing:
# Chunking configuration
chunk_size: 256 # Maximum tokens per chunk
# Converter selection
converter: docling-local # docling-local or docling-serve
# Chunker selection and configuration
chunker: docling-local # docling-local or docling-serve
chunker_type: hybrid # hybrid or hierarchical
chunking_tokenizer: "Qwen/Qwen3-Embedding-0.6B" # HuggingFace model for tokenization
chunking_merge_peers: true # Merge undersized successive chunks
chunking_use_markdown_tables: false # Use markdown tables vs narrative format
# PDF /EmbeddedFiles attachments
extract_pdf_attachments: true # Ingest embedded files as separate Documents
# Automatic title generation
auto_title: false # Auto-generate titles on ingestion
title_model: # LLM for title generation (fallback)
provider: ollama
name: qwen3.8
enable_thinking: false
# Conversion options (works with both local and remote converters)
conversion_options:
# OCR settings
do_ocr: true # Enable OCR for bitmap content
force_ocr: false # Replace existing text with OCR
ocr_engine: auto # OCR engine: auto, easyocr, rapidocr, tesseract, tesserocr, ocrmac
ocr_lang: [] # OCR languages (e.g., ["en", "fr", "de"])
# Table extraction
do_table_structure: true # Extract table structure
table_mode: accurate # fast or accurate
table_cell_matching: true # Match table cells back to PDF cells
# Image settings
images_scale: 2.0 # Image scale factor
generate_page_images: true # Include rendered page images (for visualize_chunk)
# VLM settings used when processing.pictures == "description" (see "Picture Handling" below)
picture_description:
model:
provider: ollama
name: qwen3.8
pictures: image # none | description | image
```
### Local vs Remote Processing
**Local processing** (default):
- Uses `docling` library locally
- No external dependencies
- Good for development and small workloads
**Remote processing** (docling-serve):
- Offloads processing to docling-serve API
- Better for heavy workloads and production
- Requires docling-serve instance (see [Remote processing setup](../remote-processing.md))
To use remote processing:
```yaml
processing:
converter: docling-serve
chunker: docling-serve
providers:
docling_serve:
base_url: http://localhost:5001
api_key: "your-api-key" # Optional
```
`base_url` also accepts a list — jobs round-robin across the entries, with
each job's submit / poll / result pinned to one instance (task IDs are
instance-local):
```yaml
providers:
docling_serve:
base_url:
- http://gpu-1:5001
- http://cpu-1:5001
- http://cpu-2:5001
max_attempts: 3
circuit_breaker:
failure_threshold: 3
cooldown_s: 30.0
```
The round-robin counter is per-process — multiple concurrent ingester or
client processes pick independently, so the distribution evens out over many
jobs without coordination. When a listed instance crashes or returns 5xx, the
client fails the request over to another instance (up to `max_attempts`) and
opens that instance's circuit breaker so subsequent jobs skip it until its
`cooldown_s` elapses. An external load balancer can only front docling-serve in
RQ mode (shared Redis task state); with the default standalone instances the
submit / poll / result trio is instance-pinned, so the failover and health
checks live in the client.
**Tuning `ingester.workers.worker_count` for docling-serve users**: convert
is usually the throughput ceiling — a default docling-serve instance
processes one task at a time (configurable via `DOCLING_SERVE_ENG_LOC_NUM_WORKERS`
if you've set it). A reasonable starting point for `worker_count` is **12 ×
the number of `docling_serve.base_url` entries**: enough to overlap fetch /
embed / store of one job with the convert of another, without piling jobs
into docling-serve's internal queue beyond what its workers can chew through.
The ingester logs the worker / source / docling-serve counts on startup so
you can eyeball the ratio.
Conversion options work identically for both local and remote processing.
### Large PDFs and docling memory
Docling's parser is memory-hungry and has confirmed leaks in current versions
([docling #2209](https://github.com/docling-project/docling/issues/2209),
[#1343](https://github.com/docling-project/docling/issues/1343),
[#2954](https://github.com/docling-project/docling/issues/2954);
[docling-serve #366](https://github.com/docling-project/docling-serve/issues/366),
[#474](https://github.com/docling-project/docling-serve/issues/474)).
Single-pass conversion of 400-page PDFs can OOM a workstation in local mode,
and long-running docling-serve containers see RSS grow monotonically.
Mitigation in haiku.rag — set `processing.split_pages`:
```yaml
processing:
split_pages: 10 # 0 disables (default)
```
When `split_pages > 0`, PDFs are split at the byte level into N-page slices
(using pypdfium2, already bundled), each slice converted independently, then
merged back via `DoclingDocument.concatenate` — preserving page numbers and
re-indexing `self_ref` values across slices. Peak memory per conversion is
bounded by one slice's working set rather than the whole document; in
docling-serve mode each slice is also an independent task that lets the
server release task-local state between requests.
Recommendation: `10` is a sensible starting point for any consistently-large
PDF workload. Smaller slices reduce peak memory but multiply task overhead
(per-slice docling startup + HTTP round-trips for docling-serve). Cross-page
references (named destinations, multi-page link annotations) are dropped at
the split — accepted loss; haiku.rag doesn't surface them downstream.
**Operational note for long-running ingest**: even with `split_pages`,
docling's per-process leak rate is non-zero. For deployments running
continuously:
- *docling-serve mode*: set `mem_limit` on the container in Compose
(or `resources.limits.memory` in Kubernetes) plus `restart: unless-stopped`
so the kernel OOM-kills and the runtime restarts. Run multiple
docling-serve replicas behind the round-robin `base_url` list above so a
restart of one doesn't stop ingest.
- *docling-local mode*: the leak is inside the `haiku-ingester` process
itself. Apply the same `mem_limit` + restart policy to the ingester
container. Restarts are graceful — in-flight jobs land in the queue's
reaper window and resume on next start.
**Note:** When using `chunker: docling-serve`, OCR options (`do_ocr`, `force_ocr`, `ocr_engine`, `ocr_lang`) from `conversion_options` are passed to the chunking API. This is useful when running docling-serve in a read-only container where OCR model downloads fail. Set `do_ocr: false` to disable OCR entirely.
### Conversion Options
The `conversion_options` section allows fine-grained control over document conversion. These options work with both `docling-local` and `docling-serve` converters.
#### OCR Settings
```yaml
conversion_options:
do_ocr: true # Enable OCR for bitmap/scanned content
force_ocr: false # Replace all text with OCR output
ocr_engine: auto # OCR engine selection
ocr_lang: [] # List of OCR languages, e.g., ["en", "fr", "de"]
```
- **do_ocr**: When `true`, applies OCR to images and scanned pages. Disable for faster processing if documents contain only native text.
- **force_ocr**: When `true`, replaces existing text layers with OCR output. Useful for documents with poor text extraction.
- **ocr_engine**: Select the OCR engine to use. Options:
- `auto` (default): Automatically select the best available engine
- `easyocr`: EasyOCR - supports many languages, good accuracy
- `rapidocr`: RapidOCR - fast processing
- `tesseract`: Tesseract OCR
- `tesserocr`: Tesseract via tesserocr Python binding
- `ocrmac`: macOS native OCR (macOS only)
- **ocr_lang**: List of language codes for OCR. Empty list uses default language detection. Examples: `["en"]`, `["en", "fr", "de"]`.
#### Table Extraction
```yaml
conversion_options:
do_table_structure: true # Extract structured table data
table_mode: accurate # fast or accurate
table_cell_matching: true # Match cells back to PDF
```
- **do_table_structure**: When `true`, extracts table structure. Disable for faster processing if tables aren't important.
- **table_mode**:
- `accurate`: Better table structure recognition (slower)
- `fast`: Faster processing with simpler table detection
- **table_cell_matching**: When `true`, matches detected table cells back to PDF cells. Disable if tables have merged cells across columns.
#### Image Settings
```yaml
conversion_options:
images_scale: 2.0 # Image resolution scale factor
generate_page_images: true # Include rendered page images
fetch_remote_images: true # Fetch external <img src> URLs in HTML/MD
```
- **images_scale**: Scale factor for extracted images. Higher values = better quality but larger size. Typical range: 1.0-3.0.
- **generate_page_images**: When `true` (default), rendered images of each PDF page are included in the document. Required for `visualize_chunk()` to show visual grounding. When `false`, page images are excluded to reduce document size.
- **fetch_remote_images**: When `true` (default), HTML and Markdown inputs have their external `<img src="https://...">` URLs fetched and stored as picture bytes. Set `false` for air-gapped ingest. Applies only to `docling-local`. **docling-serve doesn't fetch external `<img>` URLs** (the `ConvertDocumentsOptions` API exposes no equivalent flag, and HTML falls through to docling's `fetch_images=False` default); HTML ingested via docling-serve produces picture items with `picture_data=NULL`. Use `converter: docling-local` if you need image bytes from HTML/Markdown.
#### External image fetching
For HTML and Markdown inputs, docling fetches images referenced by URL when `fetch_remote_images: true`. Pictures end up in `document_items.picture_data` alongside the ones extracted from PDF/DOCX/PPTX. Inherited from docling:
- **SSRF guard**: hostnames must resolve to a global IP. Loopback, private (RFC1918), link-local, reserved, multicast, and unspecified addresses are rejected.
- **Size cap**: 20 MB per image (sent as a `Range` header), enforced again when streaming the response body.
- **Timeouts**: 5 s connect, 30 s read.
- **SVGs are skipped** (PIL cannot rasterize them).
- **`data:` URIs** are decoded inline (no network).
- **`file://` URIs** are *not* fetched. `enable_local_fetch` stays off to keep the SSRF surface narrow for arbitrary HTML/MD content.
Per-image failures (404, timeout, oversized, unreadable) leave that picture as a placeholder with `picture_data=NULL`. The rest of the document still ingests.
**Scope of conversion options across formats:**
| Input | OCR / table options | `images_scale` / `generate_page_images` | `pictures` | `fetch_remote_images` |
|---|---|---|---|---|
| `.pdf` | ✅ | ✅ | ✅ | n/a |
| `.png` / `.jpg` / `.jpeg` / `.bmp` / `.tiff` / `.webp` | ✅ | ✅ | ✅ | n/a |
| `.html` / `.xhtml` | n/a (markup-based) | n/a | ✅ on embedded pictures | ✅ |
| `.md` / `.qmd` / `.rmd` | n/a | n/a | ✅ on embedded pictures | ✅ (only `<img>` HTML blocks; native `![alt](url)` syntax is not fetched by docling) |
| `.docx` / `.pptx` | n/a | n/a | ✅ on embedded pictures | n/a |
| Other (`.csv`, `.xlsx`, `.adoc`, `.tex`, `.xml`) | n/a | n/a | n/a | n/a |
#### Picture Handling
`processing.pictures` picks one of three modes:
| Mode | Picture-image generation in docling | Bytes stored in `document_items.picture_data` | VLM runs at ingest |
|---|---|---|---|
| `none` | off | no | no |
| `description` | on | yes | yes |
| `image` (default) | on | yes | no |
Not every picture becomes a picture chunk. Identical picture bytes within a document produce a single chunk, so a watermark or logo repeated on every page embeds once. Pictures smaller than `processing.min_picture_size` pixels on their smaller side (default 64, `0` disables) are skipped entirely. Filtered pictures keep their bytes in `document_items`, so context expansion and vision QA still see them.
Use `none` when you don't need picture content (e.g. very large reference manuals where RAM is tight). Use `description` to weave VLM-generated text into chunk content and keep bytes for later. Use `image` (default) to keep bytes without paying the VLM cost. The prompt is configurable under `prompts.picture_description`. See [Prompts](prompts.md).
```yaml
processing:
pictures: description # none | description | image
conversion_options:
picture_description: # only consulted when pictures == "description"
model:
provider: ollama # any OpenAI-compatible /v1/chat/completions provider
name: qwen3.8
timeout: 90
max_tokens: 200
```
!!! warning "Breaking change"
`processing.conversion_options.picture_description.enabled` is replaced by `processing.pictures`. Map `enabled: true``pictures: description`, `enabled: false``pictures: image`. The pre-April-30 `generate_picture_images` flag also no longer exists. Use `pictures: none` for the old opt-out.
**Switching modes on an existing database** doesn't require reingesting when the bytes are already stored:
- `image``description`: `haiku-rag rebuild --descriptions` runs the VLM over stored bytes and re-chunks. Skips the docling parse entirely.
- `description``image`: `haiku-rag rebuild --rechunk` recomposes chunk text from the stripped docling blob without descriptions.
- Switching to/from `none`: a full reingest is needed since the bytes either weren't stored or need to be discarded.
When using `converter: docling-serve`, the VLM is invoked from docling-serve rather than haiku.rag. See [Remote processing](../remote-processing.md#vlm-picture-description-with-docling-serve).
#### Pictures × embedder × QA model: how the pieces compose
Three independent settings drive ingest, retrieval, and QA:
| Setting | Question it answers | Values |
|---|---|---|
| `processing.pictures` | Generate and/or describe pictures at ingest? | `none` / `description` / `image` (default) |
| `embeddings.model.multimodal` | Can the embedder index image content? | `false` (default, text-only) / `true` (supported on `vllm`, `voyageai`, `cohere`) |
| `qa.model.vision` | Can the QA model interpret images? | `false` / `true` (default) |
The Embedder column below is driven by `embeddings.model.multimodal`, not the provider name — a vision-capable model under a text-only configuration still indexes no images, and an image-only document then produces zero chunks. See [Multimodal embedders](providers.md#multimodal-embedders).
**What gets stored** by `pictures` × embedder:
| `pictures` | Embedder | Text chunks | Synthetic picture chunks |
|---|---|---|---|
| `none` | any | text only (caption/surrounding) | none |
| `image` | text-only | text only (caption/surrounding) | none |
| `image` | multimodal | text only | one per distinct picture, vector = image embedding |
| `description` | text-only | text + descriptions | none |
| `description` | multimodal | text + descriptions | one per distinct picture, vector = image embedding |
**What QA receives** at search time:
- `qa.model.vision: false` — text chunks only (descriptions, when present, answer figure questions in prose).
- `qa.model.vision: true` — text chunks + raw picture bytes via `BinaryContent`. The model reads figures directly. Requires `pictures != none` so the bytes exist.
`qa.model.vision` is independent of ingestion. Flipping it never requires reingesting. It declares what the model can read: the default `qwen3.8` is vision-capable, so the default is `true`. Set it `false` when pointing `qa.model` at a text-only model, where `true` causes silent acceptance and confabulation on Ollama and a 400 on OpenAI.
**Recommended combinations:**
| Use case | `processing.pictures` | Embedder | `qa.model.vision` |
|---|---|---|---|
| Pure text RAG, no figures, lowest RAM | `none` | text-only | `false` |
| Text RAG, store figure bytes for later | `image` | text-only | `false` |
| Text RAG, figures answered through descriptions | `description` | text-only | `false` |
| Vision QA on figure-rich docs (no cross-modal search) | `image` or `description` | text-only | `true` |
| Cross-modal search + vision QA | `image` or `description` | multimodal | `true` |
| Cross-modal search, text QA only | `description` | multimodal | `false` |
### Chunking Strategies
**Hybrid chunking** (default):
- Structure-aware chunking
- Respects document boundaries
- Best for most use cases
**Hierarchical chunking**:
- Creates hierarchical chunk structure
- Preserves document hierarchy
- Useful for complex documents
### Chunk Size
```yaml
processing:
chunk_size: 256 # Maximum tokens per chunk
```
Context expansion settings (for enriching search results with surrounding content) are configured in the `search` section. See [Search Settings](qa.md#search-settings).
### Table Serialization
Control how tables are represented in chunks:
```yaml
processing:
chunking_use_markdown_tables: false # Default: narrative format
```
- `false`: Tables as narrative text ("Value A, Column 2 = Value B")
- `true`: Tables as markdown (preserves table structure)
### Automatic Title Generation
Enable automatic title generation during document ingestion:
```yaml
processing:
auto_title: true
title_model:
provider: ollama
name: qwen3.8
enable_thinking: false
```
When `auto_title` is enabled, haiku.rag attempts to extract a title for each document during ingestion using a two-tier approach:
1. **Structural extraction** (free, no model calls): Scans the DoclingDocument for semantic labels (HTML `<title>` tags, `<h1>` headings, PDF title blocks, and section headers)
2. **LLM fallback**: When no structural title is found (e.g., plain text), generates a title using the configured `title_model`
Priority order: HTML `<title>` (furniture layer) → h1/PDF title (body layer) → first section header → LLM generation.
Explicit titles passed via `title=` parameter always take precedence and are never overridden. When updating documents, existing titles are preserved. Auto-generation only applies to untitled documents.
To generate titles for existing untitled documents, use [`rebuild --title-only`](../cli.md#rebuild-database).
### PDF Embedded Attachments
A PDF can carry other files inside it via the `/EmbeddedFiles` table (signed memos, appendices, supporting documents). With `extract_pdf_attachments: true` (the default), each embedded file is ingested as a separate Document linked to the wrapper through `metadata.parent_uri`:
```yaml
processing:
extract_pdf_attachments: true
```
```python
# After ingesting a PDF with two attachments:
parent = await client.create_document_from_source("/path/to/parent.pdf")
children = await client.list_documents(
filter=f"metadata LIKE '%\"parent_uri\": \"{parent.uri}\"%'"
)
# children: 2 Documents, each with parent.uri in metadata.parent_uri,
# URIs like file:///path/to/parent.pdf#attachment=memo.pdf
```
Behavior:
- Children inherit the standard ingest metadata (`content_type`, `md5`, `source_revision`) plus `parent_uri`.
- Re-ingesting the wrapper reconciles its current attachment set against existing children: new files are added, changed bytes update in place, and dropped names are deleted.
- `delete_document(parent_id)` cascades through `parent_uri` and removes all children.
- Nested attachments (a PDF whose attachment is itself a PDF with attachments) recurse up to 3 levels. Deeper chains log a warning and skip.
- Attachments whose extension or content type the converter does not support log a warning and are skipped without aborting the rest of the set.
Set `extract_pdf_attachments: false` to ingest only the wrapper.
## Continuous ingestion
For automatic ingestion of local directories, S3 buckets, or HTTP
sources (with filtering, retries, and a dead-letter queue), see the
[Ingester](../ingester.md) page.

View file

@ -1,76 +0,0 @@
# Prompt Customization
Customize the prompts used by haiku.rag's capabilities to match your domain.
## Configuration
```yaml
prompts:
# Domain context prepended to capability instructions
domain_preamble: |
This knowledge base contains technical documentation for the Helios solar panel
system, including installation manuals, maintenance procedures, and safety guidelines.
Questions about "the system" or unqualified specs refer to the Helios panel.
# VLM prompt for image description during conversion.
# Omit the key to use the built-in prompt.
picture_description: |
Describe this figure in two sentences, naming any axis labels and units.
```
## Domain Preamble
The `domain_preamble` field provides **domain context** prepended to the RAG and analysis capability instructions. Use this to:
- Describe what the knowledge base contains
- Clarify domain-specific terminology
- Provide context that helps the model interpret ambiguous queries
**Important:** `domain_preamble` is for domain context, not behavioral instructions. Descriptions of subject matter, terminology, and content scope belong here. Applications can add behavioral guidance through normal Pydantic AI agent instructions.
**Example:**
```yaml
prompts:
domain_preamble: |
This knowledge base contains product documentation, API references,
and troubleshooting guides for Acme Corp's cloud platform.
"Deployment" refers to Acme's managed deployment service, not general CI/CD.
```
## Picture Description Prompt
Customize the prompt used when generating VLM descriptions for embedded images during document conversion. This prompt is sent to the configured Vision Language Model for each image.
**Default prompt:**
```
Describe this image for a blind user. State the image type (screenshot, chart, photo, etc.),
what it depicts, any visible text, and key visual details. Be concise and accurate.
```
**Custom example:**
```yaml
prompts:
picture_description: |
Describe this image for a document search system.
Focus on: image type, main content, any text, key visual elements.
Be concise and factual.
```
The prompt is used when `processing.pictures` is `"description"`. See [Picture Handling](processing.md#picture-handling) for full configuration.
## Programmatic Configuration
```python
from haiku.rag.config import AppConfig
from haiku.rag.config.models import PromptsConfig
config = AppConfig(
prompts=PromptsConfig(
domain_preamble="This knowledge base contains Acme Corp product documentation and API references.",
picture_description="Describe this image for search indexing.",
)
)
```

View file

@ -1,554 +0,0 @@
# Providers
haiku.rag supports multiple AI providers for embeddings, question answering, and reranking. This guide covers provider-specific configuration and setup.
!!! note
You can use a `.env` file in your project directory to set environment variables like `OLLAMA_BASE_URL` and API keys (e.g., `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`). These will be automatically loaded when running `haiku-rag` commands.
## Model Settings
Configure model behavior for the `qa` and `analysis` capabilities. These settings apply to any provider that supports them.
### Basic Settings
```yaml
qa:
model:
provider: ollama
name: qwen3.8
temperature: 0.3
max_tokens: 500
```
**Available options:**
- **temperature**: Sampling temperature (0.0-1.0+). Defaults vary by task: 0.3 for QA and title generation, 0.0 for analysis and picture description.
- Lower (0.0-0.3): Deterministic, focused responses
- Medium (0.4-0.7): Balanced
- Higher (0.8-1.0+): Creative, varied responses
- **max_tokens**: Maximum tokens in response. Default: unset (provider default), except title generation (100).
- **enable_thinking**: Control reasoning behavior (see below)
- **base_url**: Custom endpoint for OpenAI-compatible servers (vLLM, LM Studio, etc.)
- **api_key**: Key for this endpoint, overriding the provider's environment variable (see [Per-endpoint API keys](#per-endpoint-api-keys))
- **extra_body**: Raw dict forwarded to the model SDK (see [Raw Provider Pass-through](#raw-provider-pass-through))
### Per-endpoint API keys
The `openai` provider reads `OPENAI_API_KEY`, so several `openai`-compatible endpoints in one config would otherwise share a single key. Set `api_key` per model to give each its own, and keep the secret in the environment with [variable expansion](index.md#environment-variables):
```yaml
qa:
model:
provider: openai
name: some-model
base_url: https://vendor-a.example/v1
api_key: ${VENDOR_A_KEY}
embeddings:
model:
provider: openai
name: some-embedding-model
vector_dim: 1024
base_url: https://vendor-b.example/v1
api_key: ${VENDOR_B_KEY}
```
`api_key` is honored on the `openai` and `ollama` providers, on `vllm` embedders and rerankers, and on the picture-description VLM endpoint (which otherwise falls back to `OPENAI_API_KEY` only for the public OpenAI endpoint, never for a custom `base_url`). Other providers (`anthropic`, `cohere`, `voyageai`, …) reach their vendor SDK by name and read their own environment variable; setting `api_key` there raises rather than being dropped silently.
### Thinking Control
The `enable_thinking` setting controls whether models use explicit reasoning steps before answering.
```yaml
qa:
model:
enable_thinking: true # Better grounded answers
```
**Values:**
- `false`: Disable reasoning for faster responses
- `true`: Enable reasoning for complex tasks
- Not set: Use model defaults
**Provider support:**
See the [Pydantic AI thinking documentation](https://ai.pydantic.dev/thinking/) for detailed provider support. haiku.rag supports thinking control for:
- **OpenAI**: Reasoning models (o1, o3, gpt-oss)
- **Anthropic**: All Claude models
- **Google**: Gemini models with thinking support
- **Groq**: Models with reasoning capabilities
- **Bedrock**: Claude, Qwen, and `gpt-oss` models. Bedrock Converse does not serve the proprietary OpenAI models, so configuring one raises an error. Reach those through `provider: bedrock-mantle`.
- **Ollama**: Any model with a thinking capability. `enable_thinking` maps to `reasoning_effort`: `false` sends `none` (`low` for `gpt-oss`, whose template has no `none` level), `true` sends `high`.
- **vLLM**: Models with a pydantic-ai reasoning profile (gpt-oss). Qwen3, Gemma, and similar templates ignore the OpenAI `reasoning_effort` that `enable_thinking` translates to — use [`extra_body`](#raw-provider-pass-through) to drive them.
- **LM Studio**: Models supporting reasoning (gpt-oss, etc.)
**When to use:**
- Enable for QA, complex reasoning, and mathematical problems
- Disable for speed-critical applications, title generation, and simple tasks
!!! note "Anthropic thinking and max_tokens"
Anthropic requires `max_tokens` to exceed the thinking budget, and `enable_thinking: true` requests Pydantic AI's default budget of 10000 tokens. Set `max_tokens` above 10000 on Claude models that use budget-based thinking, or leave it unset on Sonnet 4.6+ and Opus 4.6+, which use adaptive thinking instead of a budget.
!!! note "vLLM-served models without a reasoning profile"
On `provider: openai` with a custom `base_url`, `enable_thinking` only takes effect for models whose pydantic-ai profile advertises reasoning support (o-series, gpt-5, gpt-oss). For other vLLM-served models (Qwen3, Gemma family, …) the field is a silent no-op. Reach the chat template's thinking switch directly via [`extra_body`](#raw-provider-pass-through).
### Raw Provider Pass-through
The `extra_body` setting takes a dict that haiku.rag forwards verbatim to the underlying model SDK as `ModelSettings.extra_body`. Use it to reach provider-specific keys that haiku.rag does not model with a dedicated field.
**Example: disable Qwen3 thinking on vLLM:**
```yaml
qa:
model:
provider: openai
name: qwen3.6-35b
base_url: http://localhost:11430/v1
extra_body:
chat_template_kwargs:
enable_thinking: false
```
vLLM serves Qwen3 chat templates that read their thinking switch from `chat_template_kwargs.enable_thinking`. The high-level `enable_thinking` setting on the openai provider maps to vLLM's `reasoning_effort` parameter, which Qwen3 templates ignore, so the field is a no-op for this combination. `extra_body` reaches the chat template directly and disables thinking. With it off, Qwen3 returns the answer in `content` immediately instead of emitting a hidden reasoning trace first.
**Example: enable Gemma-family thinking on vLLM:**
```yaml
qa:
model:
provider: openai
name: nvidia/Gemma-4-26B-A4B-NVFP4
base_url: http://localhost:11432/v1
extra_body:
chat_template_kwargs:
enable_thinking: true
```
Same mechanism, opposite direction. Without `extra_body` the Gemma-4 chat template defaults to non-thinking and dumps a verbose answer straight into `content`. With it on, vLLM (started with `--reasoning-parser`) populates the parsed `reasoning` field and leaves `content` as the concise final answer.
**Provider support:** honored by openai, ollama, anthropic, and groq via pydantic-ai's `ModelSettings.extra_body`. Silently ignored by google and bedrock.
## Embedding Providers
Embedding models require three settings: `provider`, `name`, and `vector_dim`. Optionally, use `base_url` for OpenAI-compatible servers and [`api_key`](#per-endpoint-api-keys) for the key that endpoint expects.
### Batch Size
`embeddings.batch_size` (default `512`) sets how many text chunks are sent per `/v1/embeddings` call during ingest. Lower it if your provider caps total tokens per request. Picture embeddings are always sent one image per call and are unaffected.
### Ollama (Default)
```yaml
embeddings:
model:
provider: ollama
name: mxbai-embed-large
vector_dim: 1024
```
The Ollama base URL can be configured in your config file or via environment variable:
```yaml
providers:
ollama:
base_url: http://localhost:11434
```
Or via environment variable:
```bash
export OLLAMA_BASE_URL=http://localhost:11434
```
If not configured, it defaults to `http://localhost:11434`.
### VoyageAI
If you installed `haiku.rag` (full package), VoyageAI is already included. If you installed `haiku.rag-slim`, install with VoyageAI extras:
```bash
uv pip install haiku.rag-slim[voyageai]
```
```yaml
embeddings:
model:
provider: voyageai
name: voyage-3.5
vector_dim: 1024
```
Set your API key via environment variable:
```bash
export VOYAGE_API_KEY=your-api-key
```
### OpenAI
OpenAI embeddings are included in the default installation:
```yaml
embeddings:
model:
provider: openai
name: text-embedding-3-small # or text-embedding-3-large
vector_dim: 1536
```
Set your API key via environment variable:
```bash
export OPENAI_API_KEY=your-api-key
```
### Cohere
Cohere embeddings are available via pydantic-ai:
```yaml
embeddings:
model:
provider: cohere
name: embed-v4.0
vector_dim: 1024
```
Set your API key via environment variable:
```bash
export CO_API_KEY=your-api-key
```
### SentenceTransformers
For local embeddings using HuggingFace models:
```yaml
embeddings:
model:
provider: sentence-transformers
name: all-MiniLM-L6-v2
vector_dim: 384
```
### OpenAI-Compatible Servers (vLLM, LM Studio, etc.)
For local inference servers with OpenAI-compatible APIs, use the `openai` provider with a custom `base_url`:
```yaml
# vLLM example
embeddings:
model:
provider: openai
name: mixedbread-ai/mxbai-embed-large-v1
vector_dim: 512
base_url: http://localhost:8000/v1
# LM Studio example
embeddings:
model:
provider: openai
name: text-embedding-qwen3-embedding-4b
vector_dim: 2560
base_url: http://localhost:1234/v1
```
**Note:** The `base_url` must include the `/v1` path for OpenAI-compatible endpoints. This path is text-only. For a vision-language model served by vLLM, use `provider: vllm` with `multimodal: true` (below), not `provider: openai`.
### Multimodal embedders
For cross-modal retrieval (text and pictures share a single vector space), set `embeddings.model.multimodal: true`. Capability is decided by this flag, not the provider name: each provider passes images in its own wire format, so multimodal is supported only on `vllm`, `voyageai`, and `cohere`. Setting it on any other provider raises at startup.
A model produces picture chunks at ingest only when its embedder is multimodal. Without the flag, an image-only document produces zero chunks and is not retrievable. Switching `multimodal` on or off does not change the stored embedding identity, so it raises no drift error; re-ingest or `rebuild` to add or drop picture chunks.
**vLLM** — a vLLM server hosting a multimodal embedding model. Text inputs use the standard OpenAI `input` field; image inputs use vLLM's `messages`-with-`image_url` superset. Tested with `Qwen/Qwen3-VL-Embedding-8B` (4096-dim) and `jinaai/jina-embeddings-v4` (2048-dim). Run vLLM separately; haiku.rag adds no Python ML dependencies for this path.
```yaml
embeddings:
model:
provider: vllm
name: Qwen/Qwen3-VL-Embedding-8B
vector_dim: 4096
base_url: http://localhost:8000/v1
multimodal: true
```
**VoyageAI** — `voyage-multimodal-3` (1024-dim) via the `voyageai` extra. Reads `VOYAGE_API_KEY` from the environment.
```yaml
embeddings:
model:
provider: voyageai
name: voyage-multimodal-3
vector_dim: 1024
multimodal: true
```
**Cohere** — `embed-v4.0` (configurable `vector_dim`, e.g. 1536) via the `cohere` extra. Reads `CO_API_KEY` from the environment.
```yaml
embeddings:
model:
provider: cohere
name: embed-v4.0
vector_dim: 1536
multimodal: true
```
A text-only model served by vLLM uses `provider: vllm` without the flag (or `provider: openai` with a `base_url`).
Picture chunks for retrieval are emitted at ingest under any multimodal embedder. See [Picture Handling](processing.md#picture-handling).
## Question Answering Providers
Configure which LLM provider to use for question answering. Any provider and model supported by [Pydantic AI](https://ai.pydantic.dev/models/) can be used.
### Ollama (Default)
```yaml
qa:
model:
provider: ollama
name: qwen3.8
```
The Ollama base URL can be configured via the `OLLAMA_BASE_URL` environment variable, config file, or defaults to `http://localhost:11434`:
```bash
export OLLAMA_BASE_URL=http://localhost:11434
```
Or in your config file:
```yaml
providers:
ollama:
base_url: http://localhost:11434
```
### OpenAI
OpenAI QA is included in the default installation:
```yaml
qa:
model:
provider: openai
name: gpt-4o-mini # or gpt-4, gpt-3.5-turbo, etc.
```
Set your API key via environment variable:
```bash
export OPENAI_API_KEY=your-api-key
```
### Anthropic
Anthropic QA is included in the default installation:
```yaml
qa:
model:
provider: anthropic
name: claude-3-5-haiku-20241022 # or claude-3-5-sonnet-20241022, etc.
```
Set your API key via environment variable:
```bash
export ANTHROPIC_API_KEY=your-api-key
```
### OpenAI-Compatible Servers (vLLM, LM Studio, etc.)
For local inference servers with OpenAI-compatible APIs, use the `openai` provider with a custom `base_url`:
```yaml
# vLLM example
qa:
model:
provider: openai
name: Qwen/Qwen3-4B
base_url: http://localhost:8002/v1
# LM Studio example
qa:
model:
provider: openai
name: gpt-oss-20b
base_url: http://localhost:1234/v1
enable_thinking: false
```
**Note:** The server must be running with a model that supports tool calling. The `base_url` must include the `/v1` path.
### Other Providers
Any provider supported by Pydantic AI can be used. Examples:
```yaml
# Google Gemini
qa:
model:
provider: google
name: gemini-1.5-flash
# Groq
qa:
model:
provider: groq
name: llama-3.3-70b-versatile
# Mistral
qa:
model:
provider: mistral
name: mistral-small-latest
```
See the [Pydantic AI documentation](https://ai.pydantic.dev/models/) for the complete list of supported providers and models.
## Reranking Providers
Reranking improves search quality by re-ordering the initial search results using specialized models. When enabled, the system retrieves more candidates (10x the requested limit) and then reranks them to return the most relevant results.
Reranking is **disabled by default** for faster searches: there is no `reranking.model`. Enable it by configuring one of the providers below, and disable it again by removing the section or setting `model: null`.
### Cohere
If you installed `haiku.rag` (full package), Cohere is already included. If you installed `haiku.rag-slim`, add the cohere extra:
```bash
uv pip install haiku.rag-slim[cohere]
```
Then configure:
```yaml
reranking:
model:
provider: cohere
name: rerank-v3.5
```
Set your API key via environment variable:
```bash
export CO_API_KEY=your-api-key
```
### Zero Entropy
If you installed `haiku.rag` (full package), Zero Entropy is already included. If you installed `haiku.rag-slim`, add the zeroentropy extra:
```bash
uv pip install haiku.rag-slim[zeroentropy]
```
Then configure:
```yaml
reranking:
model:
provider: zeroentropy
name: zerank-1 # Currently the only available model
```
Set your API key via environment variable:
```bash
export ZEROENTROPY_API_KEY=your-api-key
```
### vLLM
For high-performance local reranking using dedicated reranking models:
```yaml
reranking:
model:
provider: vllm
name: Qwen/Qwen3-Reranker-4B
base_url: http://localhost:8001/v1
```
**Note:** vLLM reranking posts to the `/v1/rerank` endpoint. As with the embedder, `base_url` may be written with or without the `/v1` path. You need to run a vLLM server separately with a reranking model loaded.
#### Multimodal reranking
When serving a vision reranker (for example `nvidia/llama-nemotron-rerank-vl-1b-v2`), set `multimodal: true` to score picture chunks by their image bytes in addition to their description text:
```yaml
reranking:
multimodal: true
model:
provider: vllm
name: nvidia/llama-nemotron-rerank-vl-1b-v2
base_url: http://localhost:8001/v1
```
Picture chunks are sent as image documents (base64 data URIs) alongside plain text documents in the same rerank request. The flag is supported on the vllm provider only, and the served model must accept multimodal inputs.
### Jina AI
Jina provides high-quality reranking with two deployment options: API mode and local inference.
#### API Mode
Use the Jina Reranker API for cloud-based reranking:
```yaml
reranking:
model:
provider: jina
name: jina-reranker-v3
```
Set your API key via environment variable:
```bash
export JINA_API_KEY=your-api-key
```
#### Local Mode
For local inference, install the jina extra:
```bash
uv pip install haiku.rag-slim[jina]
```
Then configure:
```yaml
reranking:
model:
provider: jina-local
name: jinaai/jina-reranker-v3
```
**Note:** The Jina Reranker v3 local model is licensed under CC BY-NC 4.0, which restricts commercial use. For commercial applications, use the API mode instead.
### Cross-Encoder (sentence-transformers)
Run any HuggingFace cross-encoder reranker in-process via `sentence-transformers`. No separate server required. Useful when you want a specific model (BGE, Qwen3-Reranker, MS-MARCO MiniLM, etc.) without running vLLM.
Install the extra:
```bash
uv pip install haiku.rag-slim[cross-encoder]
```
Then configure with any HuggingFace model id:
```yaml
reranking:
model:
provider: cross-encoder
name: Qwen/Qwen3-Reranker-0.6B
```
Other tested models: `BAAI/bge-reranker-v2-m3`, `cross-encoder/ms-marco-MiniLM-L-6-v2`. Any model exposed as a `sentence_transformers.CrossEncoder` works.

View file

@ -1,63 +0,0 @@
# Search and Question Answering
## Search Settings
Configure search behavior and context expansion:
```yaml
search:
limit: 5 # Default number of results to return
max_context_chars: 5000 # Maximum characters in expanded context
```
- **limit**: Default number of search results to return when no limit is specified. Used by CLI, MCP server, and QA. Default: 5
- **max_context_chars**: Hard limit on total characters in expanded content. Default: 5000.
Context expansion is automatic and section-aware. For structured documents (with section headers), expansion includes the entire section containing the match. For sections that exceed the budget or are too small (e.g., a title+authors area), expansion grows outward item-by-item from the match center, skipping noise labels (footnotes, page headers). This naturally crosses into adjacent sections until the budget is filled. Picture and table matches are exempt: they return their enclosing section as-is and never cross section boundaries. For unstructured documents, expansion grows outward item-by-item. Results without `doc_item_refs` (e.g., custom chunks passed to `import_document`) pass through unexpanded.
!!! note "Reranking behavior"
When a reranker is configured, search automatically retrieves 10x the requested limit, then reranks to return the final count. This improves result quality without requiring you to adjust `limit`.
## Question Answering Configuration
Configure the RAG capability (used by `client.ask` and `haiku-rag ask`):
```yaml
qa:
model:
provider: ollama
name: qwen3.8
enable_thinking: true
temperature: 0.3 # Default: 0.3
vision: true # Set false for text-only models
max_searches: 5 # Maximum search units per question
```
- **model**: LLM configuration (see [Providers](providers.md#model-settings))
- **model.vision**: Set to `true` for vision-capable models (`qwen2.5vl`, `qwen3.6`, `gpt-4o`, `claude-sonnet`, …). The capability's `search` tool only attaches picture bytes (`BinaryContent`) to its `ToolReturn` when this is `true`, otherwise picture bytes are withheld. See [Pictures × embedder × QA model](processing.md#pictures-embedder-qa-model-how-the-pieces-compose) for the full matrix.
- **max_searches**: Maximum number of search units a capability can spend per question (default: 5). Up to three searches emitted in the same model response share one unit, so a model that rephrases its query in one response spends one unit. A search in a later response starts a new unit, as does each further group of three within one response. Shared by the RAG and analysis capabilities. Searches in one response also deduplicate their returns: evidence a sibling search already showed collapses to a reference line, and each picture attaches once per response.
!!! note "Thinking on vLLM"
`enable_thinking` only applies to models with a pydantic-ai reasoning profile (o-series, gpt-5, gpt-oss). For other vLLM-served models such as Qwen3 or the Gemma family, the field is a silent no-op — set the chat template switch via [`extra_body`](providers.md#raw-provider-pass-through) instead.
## Analysis Configuration
Configure the analysis capability:
```yaml
analysis:
model:
provider: anthropic
name: claude-sonnet-4-20250514
temperature: 0.0 # Default: 0.0 (deterministic for code generation)
code_timeout: 60.0 # Per call: compute stops, no read or search starts past it
max_output_chars: 50000 # Truncate output after this many chars
max_executions: 15 # Max execute_code calls per question
```
- **model**: LLM configuration (see [Providers](providers.md#model-settings)). When unset, falls back to `qa.model`.
- **code_timeout**: Seconds a single `execute_code` call has (default: 60). Past it the sandbox starts no further host call, a document read or an in-code `search()` / `list_documents()`; one already running finishes. Code that computes without host calls is killed by the worker watchdog at the same limit. `code_timeout * max_executions` is the cumulative ceiling across all calls in one question.
- **max_output_chars**: Truncate code output after this many characters (default: 50000)
- **max_executions**: Maximum `execute_code` calls per question before the capability is told to answer from what it has (default: 15)
See [Analysis capability](../capabilities/analysis.md) for usage details.

View file

@ -1,360 +0,0 @@
# Database and Storage
## Operational constraints
Four things to know before deploying.
**Run one writer per database.** This is a haiku.rag constraint, not a LanceDB
one. A write that spans several tables is serialized by an in-process lock and
rolled back by restoring each table to the version it had when the write started.
Both are process-local: a second writing process can commit between that snapshot
and the mutation, and a rollback would then revert its work along with ours. Run
a single writer, either the [`haiku-ingester`](../ingester.md) service or your own
application. Read-only consumers are unrestricted.
**Readers lag by an interval.** A connection always sees its own writes. It sees
another process's writes after `lancedb.read_consistency_interval_seconds`
(default 30).
**Migrate after an upgrade that changes the schema.** `haiku-rag migrate` applies
pending migrations in place, and `haiku-rag info` lists what is pending. A
release that needs it says so in the [changelog](../changelog.md).
**The embedding dimension is fixed per database.** Every chunk vector has the
dimension the database was created with. Changing `embeddings.model.vector_dim`
raises `ConfigMismatchError` on open, because stored vectors cannot be compared
against new ones. Changing the provider or model name while keeping the dimension
warns on a read-only open and raises on a writable one. `haiku-rag rebuild
--set-embedder` adopts the new identity without re-embedding, and `haiku-rag
rebuild --embed-only` re-embeds against the new model.
## Local Storage
By default, `haiku.rag` uses a local LanceDB database:
```yaml
storage:
data_dir: /path/to/data # Empty = use default platform location
auto_vacuum: true # Enable automatic vacuuming after operations
vacuum_retention_seconds: 86400 # Cleanup threshold in seconds
```
- **data_dir**: Directory for local database storage. When empty, uses platform-specific default locations
- **auto_vacuum**: When enabled (default), automatically runs vacuum after document create/update/delete operations and database rebuilds. Background vacuums are throttled to at most one every 5 minutes, so sustained ingestion does not trigger continuous compaction, and a final vacuum runs when the client closes. Set to `false` to disable automatic vacuuming and rely on manual `haiku-rag vacuum` commands only. Disabling can help avoid potential crashes in high-concurrency scenarios
- **vacuum_retention_seconds**: When vacuum runs, old table versions older than this threshold are removed. Default: 86400 seconds (1 day). Set to 0 for aggressive cleanup (removes all old versions immediately)
!!! warning "Vacuum Retention Threshold"
The `vacuum_retention_seconds` value should be larger than the typical time it takes to process and write a document. If a concurrent operation is in progress while vacuum runs, setting this value too low can cause race conditions where vacuum removes table versions that an in-flight operation still needs. The default of 86400 seconds (1 day) is conservative and safe for most use cases.
### Vacuum Memory Requirements
Vacuum compacts small data files into larger ones. LanceDB targets roughly one million rows per fragment, which a `documents` table holding multi-megabyte docling blobs never reaches, so each vacuum that follows new documents re-merges the whole existing fragment rather than only the new ones. Peak memory therefore scales with the total size of the `documents` table, not with how much was added.
Measured peak resident memory is about 5x the size of the `documents` table's data files. An 8.8 GB table peaked at 48.7 GB. Plan for **6x the size of `documents/` on disk** as available RAM, or the vacuum will be killed by the OOM killer partway through.
Check the current size with:
```bash
du -sh /path/to/database.lancedb/documents.lance
```
If that number times six exceeds available RAM, use one of:
- Reduce `images_scale` (see [Image Settings](processing.md#image-settings)). Rendered page rasters dominate the size of `documents`, and their byte cost falls with the square of the scale factor.
- Set `generate_page_images: false` if visual grounding through `visualize_chunk()` is not needed. This removes page rasters entirely.
- Set `auto_vacuum: false` and run `haiku-rag vacuum` manually when the machine is otherwise idle, so the peak does not land alongside ingestion.
Vacuum also folds new rows into the full-text index. Search stays correct without it but scans the uncovered rows on every query. `haiku-rag doctor` reports the coverage.
This is an upstream limitation rather than a `haiku.rag` setting. Compaction bounds itself by row count instead of bytes, and LanceDB's async API exposes no batch size or fragment target to override it. Tracked at [lancedb/lancedb#2325](https://github.com/lancedb/lancedb/issues/2325). The requirement above will drop once compaction batches by bytes.
### Placing the Database
`lancedb.databases` maps a name to a location, a local path or a URI, and is the one way to place databases. With nothing configured, the database is the entry `haiku.rag` at `<storage.data_dir>/haiku.rag.lancedb`. To put one database somewhere else, name it:
```yaml
lancedb:
databases:
notes: /data/notes.lancedb
```
The name is what `source` carries in search results, citations and documents, and what `--db-name` and `sources` select. The default database answers to `haiku.rag`.
An explicit `--db PATH` on the command line opens that database instead, named by the path's stem, whatever is configured. From Python, `db_path` places the database only where the configuration places none: beside `lancedb.databases` it raises `AmbiguousDatabaseError`.
A value with no scheme is a local path wherever it is configured, so `haiku-rag init` creates it and every command that opens an existing database requires it to exist. A mistyped path fails rather than becoming a new empty database.
## Database Creation
Databases must be explicitly created before use:
**CLI:**
```bash
# Create in default location (see Configuration File Locations below)
haiku-rag init
# Create at custom path
haiku-rag init --db /path/to/database.lancedb
```
**Python:**
```python
# Create at custom path
async with HaikuRAG("/path/to/database.lancedb", create=True) as client:
...
# Create in default location
async with HaikuRAG(create=True) as client:
...
```
The [default location](index.md#configuration-file-locations) is platform-specific (e.g., `~/Library/Application Support/haiku.rag/` on macOS).
Opening a nonexistent local database given as a path raises `FileNotFoundError`, naming the path. This prevents accidental database creation from typos or misconfigured paths. A configured or default database raises `SourceUnavailableError` instead, naming the database and not its location.
## Remote Storage
For remote storage, give the database a URI as its location. Credentials and storage options are connection settings, shared by every database in the configuration:
```yaml
# LanceDB Cloud
lancedb:
databases:
papers: db://your-database-name
api_key: your-api-key
region: us-west-2
# Amazon S3
lancedb:
databases:
papers: s3://my-bucket/my-table
storage_options:
region: us-east-1
# Amazon S3 with explicit credentials
lancedb:
databases:
papers: s3://my-bucket/my-table
storage_options:
aws_access_key_id: YOUR_ACCESS_KEY
aws_secret_access_key: YOUR_SECRET_KEY
region: us-east-1
# S3-compatible (SeaweedFS, Tigris, etc.)
lancedb:
databases:
papers: s3://my-bucket/my-table
storage_options:
endpoint: http://localhost:8333
aws_access_key_id: YOUR_ACCESS_KEY
aws_secret_access_key: YOUR_SECRET_KEY
region: us-east-1
allow_http: "true"
# Azure Blob Storage
lancedb:
databases:
papers: az://my-container/my-table
# Google Cloud Storage
lancedb:
databases:
papers: gs://my-bucket/my-table
# HDFS
lancedb:
databases:
papers: hdfs://namenode:port/path/to/table
```
- **LanceDB Cloud** (`db://`): Requires `api_key` and `region`. Table optimization and indexing are managed server-side.
- **Object storage** (`s3://`, `gs://`, `az://`, `hdfs://`): Uses `storage_options` for credentials and endpoint configuration. Authentication can also be provided via environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, etc.) or cloud provider SDK defaults (AWS CLI, Azure CLI, gcloud).
- **S3-compatible stores** (MinIO, Tigris, etc.): Set `endpoint` in `storage_options`. When using `http://` endpoints, also set `allow_http: "true"`.
- **Local path** (no scheme): a location without a scheme is a local path. See [Placing the Database](#placing-the-database).
The `storage_options` keys are case-insensitive and passed directly to the underlying object store library. Available keys depend on the backend. See the [LanceDB storage docs](https://lancedb.com/docs/storage/) for details.
**Note:** Table optimization is automatically handled by LanceDB Cloud (`db://` URIs) and is disabled for better performance. For object storage backends (S3, Azure, GCS), optimization and vector indexing are still performed normally.
### Caching and Read Consistency
```yaml
lancedb:
read_consistency_interval_seconds: 30 # null to never re-check
index_cache_size_bytes: 536870912 # null for the LanceDB default
metadata_cache_size_bytes: 268435456
```
- **read_consistency_interval_seconds**: how often a connection checks for writes from another process. `null` never checks, so a long-lived reader never sees the ingester's writes. `0` checks on every read.
- **index_cache_size_bytes** / **metadata_cache_size_bytes**: sizes for the caches held by the LanceDB session, which is shared across every connection in the process. The first vector query loads the index into it, so on object storage the cache is what stops the next connection refetching it. Size it for the total set of indexes a process keeps warm, against the memory available to it.
### Deployment Pattern: One Writer, Many Readers
The [one-writer constraint](#operational-constraints) shapes the deployment: one
writing process per database URI, any number of read-only consumers.
The recommended layout for production is "different buckets, same account, separate IAM roles per process":
- **Ingestion process** — IAM role with `s3:Get/List` on the documents bucket and `s3:Get/Put/Delete` on the LanceDB bucket. Runs `haiku-ingester serve` (with `ingester.sources[type=s3]` pointing at the documents bucket). Exactly one such process per LanceDB URI.
- **Consumer processes** (1..N) — IAM role with `s3:Get/List` on the LanceDB bucket only. Run `haiku-rag mcp`, the chat TUI, etc. They never see the documents bucket.
Each process picks up its own credentials from the AWS default chain (env vars, IAM instance role, AWS profile), so no credentials are hard-coded in the configuration files.
`haiku-ingester` writes the database the configuration places, so a one-entry `lancedb.databases` needs no further option. `--db PATH` overrides it. When `lancedb.databases` contains more than one database the ingester has no way to name which it writes, and refuses to start with `AmbiguousDatabaseError`: give each database its own ingester process, each with a configuration naming a single database, or select one with `--db PATH`.
## Multiple Databases
Use `lancedb.databases` to name local or remote databases that should be searched together:
```yaml
lancedb:
databases:
papers: s3://my-bucket/papers.lancedb
wiki: s3://my-bucket/wiki.lancedb
notes: /data/notes.lancedb
```
A location can be a URI or local path.
Results, documents, and citations use the configured name as `source`. An unavailable configured database raises `SourceUnavailableError`, which names the database and not its location, so a location never travels in an error a consumer might render or log. A migration, configuration or read-only failure keeps its own type, with the database named in the message. Commands that report on a database, such as `info`, still show where it is.
Searches spanning multiple databases identify each result with a model-facing `Collection:` line. Searches over one database omit it. Structured `source` fields on results, documents, citations, and analysis dictionaries are unchanged.
Embedding compatibility is checked against two different things.
On open, each database is compared with the current configuration. A dimension mismatch raises `ConfigMismatchError`. A provider or model-name mismatch at the same dimension warns in read-only mode and raises in writable mode.
Across a selection, the databases are compared with each other. Vector and hybrid search embed the query once, so every database answering it must record the same provider, model, and dimension. A disagreement raises `ConfigMismatchError` in read-only mode as well. Only the databases searched together have to agree, and full-text search embeds nothing, so it is unaffected.
### Search and Provenance
`search`, `ask`, and `analyze` use the full set by default. Pass `sources` to select a subset:
```python
results = await client.search("query") # every database
results = await client.search("query", sources=["papers"]) # one of them
```
Candidates are combined into one ranked list with the configured reranker, or by cosine similarity to the query when reranking is disabled, with within-database rank breaking ties (full-text-only searches order by retrieval score). `SearchResult.source`, `Citation.source`, and `Document.source` carry the database name, for a set and for one database alike.
The CLI labels results and citations only when the operation spans multiple databases. A command already narrowed with `--db-name` does not repeat the name on every result.
#### Duplicate IDs
IDs are unique within a database, not across databases. Copies of a database therefore retain the same IDs.
Citation ambiguity is evaluated against evidence available to the run. A cited chunk ID is rejected with `AmbiguousCitationError` if search returned it from multiple databases, or it was previously cited from another database. If only one retrieved result has the ID, that result is cited. For an ID absent from search results, the fallback checks every selected database and rejects multiple holders. A shared ID that nothing cites is ignored.
`get_document_by_id`, `get_chunk_by_id` and `get_picture_bytes` take an optional `source`, and ask that database alone. A name the client does not cover raises `UnknownDatabaseError`. Without one, the document and chunk lookups ask every covered database and answer from the first that holds the ID; `get_picture_bytes` requires one whenever the client covers a set.
The analysis sandbox rejects shared document IDs because its mount path is `/documents/{id}/`.
The chat document filter selects by document and database: the search is narrowed to the databases the selection names, and the ID filter applies within them. An ID that copies share still matches in every selected database that holds it.
#### Ranking
Without a reranker, the fused list is ordered by cosine similarity between the query vector and each candidate. The databases in a selection share an embedder, so similarity in that one space is comparable across databases, where retrieval scores are each database's own arithmetic. Ties resolve by the candidate's rank within its own database, and configured order decides only when both tie. Similarities rarely tie exactly, so declaration order decides almost nothing: on MTRAG retrieval benchmarks, reversing it left recall unchanged in every cell. Full-text-only searches have no query vector and order by retrieval score instead.
Results are not guaranteed to spread across databases: a database with nothing relevant to a query contributes nothing, and a strong database can fill every slot. On MTRAG retrieval benchmarks over two to eight collections, cosine fusion holds recall roughly flat as collections are added, where position-based fusion lost up to half its recall at eight.
A configured reranker scores the combined candidate set directly, ignoring which database each candidate came from, and remains the strongest option: roughly 6 to 8 recall points above cosine fusion on the same benchmarks. Its cost grows with the number of databases because each contributes candidates.
Image queries are vector-only and skip the reranker: the reranker interface takes a text query, and multimodal reranking applies to pictures on the candidate side, not to image queries. Their fused list is ordered by cosine similarity like any other vector search.
If a selected database is unavailable, the operation fails with `SourceUnavailableError`, which names that database.
### Python Operations
Creating, writing, rebuilding, and vacuuming require one database. Calling these operations on a client that covers multiple raises `AmbiguousDatabaseError`. Select one at creation time or obtain a single-database client:
```python
async with HaikuRAG(config=config, create=True, sources=["papers"]) as papers:
...
async with HaikuRAG(config=config) as client:
papers = (await client.clients_for(["papers"]))[0]
```
Conversion, chunking, and title generation do not access a database and remain available on a multi-database client.
### CLI Commands
Commands use database sets as follows:
- **Set-capable**: `search`, `ask`, `analyze`, `chat`, and `mcp` use the full configured set, or the single database selected by `--db-name`.
- **Config-only**: `settings`, `init-config`, and `download-models` do not open a database.
- **Single-database**: everything else — document writes, `rebuild`, `vacuum`, `migrate`, `init`, `info`, `history`, `tag`, `doctor`, `list`, `inspect`, and `visualize` — works on one database, selected with the global `--db-name` option.
```bash
haiku-rag search "query" # every configured database
haiku-rag --db-name papers list # one of them
haiku-rag --db-name papers migrate
```
`--db-name` selects an entry from `lancedb.databases`, including remote entries, and `haiku.rag` when nothing is configured. `--db` opens a local path, named by its stem, whatever is configured. A single-database command requires one of these options when multiple databases are configured. A configured set of one is selected automatically.
Each database is created, migrated and vacuumed on its own:
```bash
haiku-rag --db-name papers init
haiku-rag --db-name wiki init
```
## Vector Indexing
Configure vector search settings:
```yaml
search:
vector_index_metric: cosine # cosine or l2
vector_refine_factor: 30 # Re-ranking factor for accuracy
vector_nprobes: 20 # IVF partitions searched per query
```
For search behavior settings (`limit`, `max_context_chars`), see [Search and Question Answering](qa.md#search-settings).
- **vector_index_metric**: Distance metric for vector similarity:
- `cosine`: Cosine similarity (default, best for most embeddings)
- `l2`: Euclidean distance
- **vector_refine_factor**: Improves accuracy when using a vector index by retrieving `refine_factor * limit` candidates (using approximate search) and re-ranking them with exact distances. Higher values increase accuracy but slow down queries. Default: 30
- **Only applies with a vector index** - has no effect on brute-force search, which already returns exact results
- **vector_nprobes**: How many IVF partitions each query searches. Higher values increase recall and latency. A larger corpus holds more partitions, so the same value covers a smaller fraction of it. Default: 20
- **Only applies with a vector index** - ignored by brute-force search
!!! note
Vector indexes are only necessary for large datasets with over 100,000 chunks. For smaller datasets, LanceDB's brute-force kNN search provides exact results with good performance. Only create an index if you notice search performance degradation on large datasets.
Retrieval MAP with and without an index, measured on copies of the benchmark databases with no reranker:
| Dataset | Chunks | Dim | Exact | Indexed | Delta | Build | Peak RSS |
|---------|-------:|----:|------:|--------:|------:|------:|---------:|
| `hotpotqa` | 70,527 | 2560 | 0.6978 | 0.6979 | +0.0001 | 29.3 s | 3.19 GB |
| `orb_multimodal_nemotron` | 121,168 | 2048 | 0.9799 | 0.9800 | +0.0001 | 25.8 s | 3.38 GB |
| `frames` | 425,940 | 2560 | 0.5431 | 0.5387 | -0.0044 | 34.1 s | 4.02 GB |
An index costs no accuracy at 70k and 121k chunks and 0.0044 MAP at 426k. A larger corpus holds more IVF partitions, so the default number of probes covers a smaller fraction of the space, and `vector_refine_factor` can only re-score what those probes returned. Raise `vector_nprobes` to trade latency for recall on a large corpus. Build cost is near-flat in row count because training samples the data rather than scanning it, and vector dimension drives it more than corpus size.
**Index creation:**
Vector indexes are **not created automatically** during document ingestion to avoid slowing down the process. After you've added documents (at least 256 chunks required), create the index manually:
```bash
haiku-rag create-index
```
This command:
- Checks if you have enough data (minimum 256 chunks)
- Creates an IVF_PQ index for fast approximate nearest neighbor (ANN) search
- Uses LanceDB's automatic parameter calculation based on your dataset size and vector dimensions
**Re-indexing:**
New chunks reach the index without a rebuild. `optimize()`, which runs after writes while `auto_vacuum` is on, adds them as a delta part. Between a write and the next optimize, LanceDB serves ANN over the indexed rows and a brute-force scan over the remainder, then combines the results.
A rebuild retrains the centroids, which are fitted once at build time and never recomputed. As a corpus grows past the distribution it was trained on the partitioning fits it less well, and delta parts accumulate. Rebuild after substantial growth:
```bash
haiku-rag create-index
```
For datasets with fewer than 256 chunks, searches use brute-force kNN scans (exact nearest neighbors, 100% recall) which work well for small datasets but don't scale beyond a few hundred thousand vectors.

View file

@ -1,255 +0,0 @@
# Custom Processing Pipelines
haiku.rag provides processing primitives that let you build custom document pipelines. Use these when you need control over conversion, chunking, or embedding (for example, to preprocess content, use external services, or implement custom chunking logic).
## When to Use Custom Pipelines
Use the primitives when you need to:
- Preprocess or clean content before chunking
- Filter or modify chunks before embedding
- Use external embedding services
- Implement custom chunking strategies
- Debug or inspect intermediate processing steps
For standard use cases, prefer the convenience methods:
- `create_document()` - Create from text content
- `create_document_from_source()` - Create from file or URL
- `import_document()` - Store pre-processed documents with custom chunks
## Processing Primitives
The client exposes four primitives that can be composed into custom workflows:
| Primitive | Input | Output | Purpose |
|-----------|-------|--------|---------|
| `convert()` | file, URL, or text | `DoclingDocument` | Convert source to structured document |
| `chunk()` | `DoclingDocument` | `list[Chunk]` | Split document into chunks |
| `embed_chunks()` | `list[Chunk]`, embedder | `list[Chunk]` | Generate embeddings for chunks (includes contextualization) |
| `contextualize()` | `list[Chunk]` | `list[str]` | Get embedding-ready text (for custom embedders only) |
## Basic Pipeline
The standard pipeline mirrors what `create_document()` does internally:
```python
from haiku.rag.client import HaikuRAG
from haiku.rag.embeddings import embed_chunks
async with HaikuRAG("database.lancedb", create=True) as client:
# 1. Convert source to DoclingDocument
docling_doc = await client.convert("path/to/document.pdf")
# 2. Chunk the document
chunks = await client.chunk(docling_doc)
# 3. Generate embeddings
embedded_chunks = await embed_chunks(chunks, client.embedder)
# 4. Store the document with chunks
doc = await client.import_document(
docling_document=docling_doc,
chunks=embedded_chunks,
uri="file:///path/to/document.pdf",
title="My Document",
)
```
## Convert
`convert()` accepts files, URLs, or plain text and returns a `DoclingDocument`:
```python
# From local file
docling_doc = await client.convert("report.pdf")
docling_doc = await client.convert(Path("/absolute/path/to/file.docx"))
# From URL (downloads and converts)
docling_doc = await client.convert("https://example.com/paper.pdf")
# From plain text (parsed as markdown by default)
docling_doc = await client.convert("# Title\n\nYour text content here")
# From HTML text (use format parameter to preserve structure)
html_content = "<h1>Title</h1><p>Paragraph</p><ul><li>Item</li></ul>"
docling_doc = await client.convert(html_content, format="html")
# From file:// URI
docling_doc = await client.convert("file:///path/to/document.md")
```
The `format` parameter controls how text content is parsed:
- `"md"` (default) - Parse as Markdown
- `"html"` - Parse as HTML, preserving semantic structure (headings, lists, tables)
!!! note
The `format` parameter only applies to text content. Files and URLs determine their format from the file extension or content-type header.
Supported formats depend on your converter configuration (docling-local or docling-serve). Common formats include PDF, DOCX, HTML, Markdown, and images.
## Chunk
`chunk()` splits a `DoclingDocument` into `Chunk` objects with metadata:
```python
chunks = await client.chunk(docling_doc)
for chunk in chunks:
print(f"Order: {chunk.order}")
print(f"Content: {chunk.content[:100]}...")
# Access structured metadata
meta = chunk.get_chunk_metadata()
print(f"Headings: {meta.headings}")
print(f"Page numbers: {meta.page_numbers}")
print(f"Labels: {meta.labels}")
# Access raw metadata (including headings, page_numbers and labels)
print(f"Raw metadata: {chunk.metadata}")
```
Chunks are returned with:
- `content` - The chunk text
- `order` - Position in document (0-indexed)
- `metadata` - Dict with `doc_item_refs`, `headings`, `labels`, `page_numbers`
- `embedding` - `None` (not yet embedded)
- `document_id` - `None` (not yet stored)
A custom `DocumentChunker` can provide other keys and values in `metadata`. They will be stored with the chunk and are accessible when it is returned in a search result or citation, within `SearchResult.chunk_meta` / `Citation.chunk_meta`.
## Embed
`embed_chunks()` generates embeddings for chunks using the client's embedder. It automatically contextualizes chunks (prepends section headings) before embedding for better semantic search, without modifying the stored content:
```python
from haiku.rag.embeddings import embed_chunks
# Generate embeddings (returns new Chunk objects)
embedded_chunks = await embed_chunks(chunks, client.embedder)
# Original chunks unchanged
assert chunks[0].embedding is None
# New chunks have embeddings
assert embedded_chunks[0].embedding is not None
```
`embed_chunks()` returns **new** `Chunk` objects with embeddings set. The original chunks are not modified.
## Contextualize (for custom embedders)
`contextualize()` is a lower-level utility that prepares chunk content for embedding by prepending section headings. You only need this when implementing custom embedding logic. `embed_chunks()` already calls it internally.
```python
from haiku.rag.embeddings import contextualize
# Get embedding-ready text (only needed for custom embedders)
texts = contextualize(chunks)
# texts[0] might be: "Chapter 1\nIntroduction\nThe actual chunk content..."
```
See the [Custom Embeddings](#custom-embeddings) example below for when to use `contextualize()`.
## Custom Processing Examples
### Preprocessing Content
Transform content before chunking:
```python
def clean_markdown(text: str) -> str:
"""Remove HTML comments and normalize whitespace."""
import re
text = re.sub(r'<!--.*?-->', '', text, flags=re.DOTALL)
text = re.sub(r'\n{3,}', '\n\n', text)
return text.strip()
async with HaikuRAG("database.lancedb", create=True) as client:
# Convert to get raw content
docling_doc = await client.convert("document.md")
# Extract and preprocess markdown
markdown = docling_doc.export_to_markdown()
cleaned = clean_markdown(markdown)
# Re-convert the cleaned content
processed_doc = await client.convert(cleaned)
# Continue with standard pipeline
chunks = await client.chunk(processed_doc)
embedded_chunks = await embed_chunks(chunks, client.embedder)
await client.import_document(
chunks=embedded_chunks,
content=cleaned,
)
```
### Filtering Chunks
Remove unwanted chunks before embedding:
```python
async with HaikuRAG("database.lancedb", create=True) as client:
docling_doc = await client.convert("document.pdf")
chunks = await client.chunk(docling_doc)
# Filter out short chunks or boilerplate
filtered = [
c for c in chunks
if len(c.content) > 50
and "copyright" not in c.content.lower()
]
# Re-number the order field after filtering
for i, chunk in enumerate(filtered):
chunk.order = i
embedded_chunks = await embed_chunks(filtered, client.embedder)
await client.import_document(
docling_document=docling_doc,
chunks=embedded_chunks,
)
```
### Custom Embeddings
Use your own embedding service:
```python
async def my_embedder(texts: list[str]) -> list[list[float]]:
"""Your custom embedding function."""
# Call your embedding API here
...
async with HaikuRAG("database.lancedb", create=True) as client:
docling_doc = await client.convert("document.pdf")
chunks = await client.chunk(docling_doc)
# Use contextualize for consistent embedding input
texts = contextualize(chunks)
# Generate embeddings with your service
embeddings = await my_embedder(texts)
# Create chunks with embeddings
from haiku.rag.store.models.chunk import Chunk
embedded_chunks = [
Chunk(
content=chunk.content,
metadata=chunk.metadata,
order=chunk.order,
embedding=embedding,
)
for chunk, embedding in zip(chunks, embeddings)
]
await client.import_document(
docling_document=docling_doc,
chunks=embedded_chunks,
)
```

View file

@ -1,123 +0,0 @@
# Development
This guide covers setting up a development environment and running tests.
## Setup
Clone the repository and install dependencies:
```bash
git clone https://github.com/ggozad/haiku.rag.git
cd haiku.rag
uv sync
```
## Running Tests
```bash
uv run pytest
```
### Test Markers
Tests use pytest markers to categorize them:
- `@pytest.mark.integration` - Tests requiring local services (Docling models, etc.) that aren't available in CI
- `@pytest.mark.asyncio` - Async tests (applied automatically via pytest-asyncio)
- `@pytest.mark.vcr()` - Tests with HTTP call recording
CI runs `pytest -m "not integration"` to skip integration tests.
## HTTP Recording with VCR
Tests use [pytest-recording](https://github.com/kiwicom/pytest-recording) (VCR.py) to record and replay HTTP calls. This allows tests to run without external services like Ollama or API providers.
### How It Works
1. Tests marked with `@pytest.mark.vcr()` record HTTP interactions to YAML cassettes
2. On subsequent runs, HTTP calls are replayed from cassettes instead of hitting real services
3. Cassettes are committed to the repository so CI can run tests without external dependencies
### Recording New Cassettes
When adding a new test that makes HTTP calls:
1. Add the `@pytest.mark.vcr()` decorator to your test
2. Run the test with the required services available (e.g., Ollama running)
3. The cassette is automatically created on first run
### Re-recording Cassettes
To update an existing cassette, delete it and re-run the test, or use `--record-mode=rewrite`.
### Running Without Cassettes (Live Mode)
To run tests against real services instead of recorded cassettes:
```bash
uv run pytest --disable-recording
```
## Writing Tests
### Common Fixtures
Available fixtures from `tests/conftest.py`:
- `temp_db_path` - Isolated temporary database
- `temp_yaml_config` - Temporary config file
- `allow_model_requests` - Enables pydantic-ai model calls
### Example: Adding a New Test with VCR
```python
import pytest
from haiku.rag.client import HaikuRAG
@pytest.mark.asyncio
@pytest.mark.vcr()
async def test_my_feature(temp_db_path):
async with HaikuRAG(temp_db_path, create=True) as client:
doc = await client.create_document("Test content", uri="test://doc")
assert doc.id is not None
```
### Integration Tests
For tests requiring local services that can't be mocked via VCR:
```python
@pytest.mark.integration
@pytest.mark.asyncio
async def test_pdf_visualization(temp_db_path):
# Test code that needs local PDF processing
pass
```
Integration tests are skipped in CI but run locally when you have the required services.
## Linting and Formatting
```bash
uv run ruff check
uv run ruff format
uv run ty check
```
## Mock API Keys
Tests automatically set mock API keys for providers that require them during client initialization. When running with VCR playback, these mock keys are sufficient since no real API calls are made.
Recording reaches the real service, so the recording command needs network
access and the keys that service reads. Name the exact test and pass `-n0`:
a module-wide `--record-mode=rewrite` re-records every cassette in it,
including ones whose service you do not have running.
```bash
# Ollama-backed cassettes need no key, only a running Ollama
uv run pytest tests/test_embedder.py::test_ollama_embedder -n0 --record-mode=rewrite
# A keyed provider reads its own variable. Cohere's SDK reads CO_API_KEY
CO_API_KEY=... uv run pytest tests/test_reranker.py::test_cohere_reranker -n0 --record-mode=rewrite
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 640 KiB

View file

@ -1,38 +1,66 @@
---
title: haiku.rag
description: Local-first agentic RAG. Index PDFs, web pages, and whole directories, then ask questions and get answers cited to page numbers and section headings. Hybrid search, reranking, and multimodal retrieval on embedded LanceDB.
---
# haiku.rag
haiku.rag indexes PDFs, web pages, and whole directories, retrieves with hybrid search, and answers with citations down to the page number and section heading. It runs on an embedded database with open models, so your documents stay on your machine and there is no server to operate.
`haiku.rag` is a Retrieval-Augmented Generation (RAG) library built to work with LanceDB as a local vector database. It uses LanceDB for storing embeddings and performs semantic (vector) search as well as full-text search combined through native hybrid search with Reciprocal Rank Fusion. Both open-source (Ollama, MixedBread AI) as well as commercial (OpenAI, VoyageAI) embedding providers are supported.
## Features
- **Local LanceDB**: No need to run additional servers
- **Support for various embedding providers**: Ollama, VoyageAI, OpenAI or add your own
- **Native Hybrid Search**: Vector search combined with full-text search using native LanceDB RRF reranking
- **Reranking**: Optional result reranking with MixedBread AI or Cohere
- **Question Answering**: Built-in QA agents using Ollama, OpenAI, or Anthropic
- **File monitoring**: Automatically index files when run as a server
- **Extended file format support**: Parse 40+ file formats including PDF, DOCX, HTML, Markdown, code files and more. Or add a URL!
- **MCP server**: Exposes functionality as MCP tools
- **CLI commands**: Access all functionality from your terminal
- Add sources from text, files, or URLs, optionally with a humanreadable title
- **Python client**: Call `haiku.rag` from your own python applications
## Quick Start
Install haiku.rag:
```bash
uv pip install haiku.rag
haiku-rag init
haiku-rag add-src ~/Documents/some-paper.pdf
haiku-rag ask "what does it conclude?"
```
[Quickstart](tutorial.md) covers provider setup and the first ingestion.
Use from Python:
## Why haiku.rag
```python
from haiku.rag.client import HaikuRAG
**Answers you can check.** Every answer carries citations with page numbers and section headings. Visual grounding shows the cited chunk highlighted on the original page image. Optional capabilities require an answer to declare what grounds it, including declaring that nothing does.
async with HaikuRAG("database.lancedb") as client:
# Add a document
doc = await client.create_document("Your content here")
**Local-first, no server.** Embedded [LanceDB](https://lancedb.com/) and open models through [Ollama](https://ollama.com/) by default. No database to run and no API keys required. The same code runs against S3, GCS, Azure, LanceDB Cloud, or any provider Pydantic AI supports.
# Search documents
results = await client.search("query")
**Built for agents.** Native [Pydantic AI](https://ai.pydantic.dev/) capabilities compose into your own agents. An [MCP server](mcp.md) exposes the same database to Claude Desktop and other assistants. The analysis capability runs sandboxed Python across documents for questions that need computation rather than retrieval.
# Ask questions
answer = await client.ask("Who is the author of haiku.rag?")
```
**Measured, not asserted.** Retrieval and answer quality are tracked against public benchmarks with runnable configs. See [Benchmarks](benchmarks.md).
Or use the CLI:
## Start here
```bash
haiku-rag add "Your document content"
haiku-rag add "Your document content" --meta author=alice
haiku-rag add-src /path/to/document.pdf --title "Q3 Financial Report" --meta source=manual
haiku-rag search "query"
haiku-rag ask "Who is the author of haiku.rag?"
```
- [Quickstart](tutorial.md): install, index, chat.
- [Installation](installation.md): packages and extras.
- [Architecture](overview.md): how a document becomes a cited answer.
- [Capabilities](capabilities/index.md): native RAG and analysis capabilities for Pydantic AI agents.
- [Python API](python.md): use haiku.rag from code.
- [MCP server](mcp.md): expose haiku.rag to Claude Desktop or other AI assistants.
- [Configuration](configuration/index.md): every setting.
## Documentation
MIT licensed. Source on [GitHub](https://github.com/ggozad/haiku.rag).
- [Getting started](tutorial.md) - Tutorial
- [Installation](installation.md) - Install haiku.rag with different providers
- [Configuration](configuration.md) - Environment variables and settings
- [CLI](cli.md) - Command line interface usage
- [Server](server.md) - File monitoring and server mode
- [MCP](mcp.md) - Model Context Protocol integration
- [Python](python.md) - Python API reference
- [Agents](agents.md) - QA agent and multi-agent research
## License
This project is licensed under the [MIT License](https://raw.githubusercontent.com/ggozad/haiku.rag/main/LICENSE).

View file

@ -1,684 +0,0 @@
# Ingester
The ingester is a long-running service that watches sources for
changes and feeds documents into haiku.rag's LanceDB. It runs as a
separate process (`haiku-ingester serve`), owns its own job queue
(SQLite by default, or a database server), and exposes a small HTTP
control plane for operations.
Use the ingester when:
- you have a corpus you want to keep in sync continuously
- documents arrive over time from filesystem, S3, or HTTP sources
- you want retry + dead-letter behavior, not "fire and forget"
For one-off ingestion, the `haiku-rag add-src` CLI is enough — see
[CLI → Add Documents](cli.md).
**On this page:**
- [Install](#install)
- [Configure sources](#configure-sources) (FS, S3, HTTP, WebDAV)
- [Workers and retry](#workers-and-retry)
- [Circuit breaker](#circuit-breaker)
- [Run it](#run-it)
- [HTTP control plane](#http-control-plane)
- [Operating](#operating) (smoke test, queue inspection, logs, API)
Single-writer constraint: only one ingester per LanceDB. See
[Storage → Deployment Pattern](configuration/storage.md#deployment-pattern-one-writer-many-readers).
## Install
The ingester ships behind an optional extra:
```bash
pip install 'haiku.rag-slim[ingester]'
# or, for the full package:
pip install 'haiku.rag[ingester]'
```
That pulls `fastapi`, `uvicorn`, `sqlalchemy`, `aiosqlite`, `asyncpg`, and
the `[s3]` extra. The production binary is `haiku-ingester`.
## Configure sources
Add an `ingester:` block to your `haiku.rag.yaml`. The minimum is a
single source:
```yaml
ingester:
sources:
- type: fs
id: local-docs
root: /Users/you/docs
delete_orphans: true
```
### Filesystem
```yaml
ingester:
sources:
- type: fs
id: local-docs # optional; auto-derives from root
root: /Users/you/docs
poll_interval_s: 300
delete_orphans: true
ignore_patterns: ["**/.git/**", "**/node_modules/**"]
include_patterns: ["*.md", "*.pdf"] # optional whitelist
```
Uses `watchfiles` for push events plus a periodic sweep that catches
anything the OS dropped between starts. Patterns follow
[gitignore syntax](https://git-scm.com/docs/gitignore#_pattern_format).
### S3 / object storage
```yaml
ingester:
sources:
- type: s3
id: corp-docs
uri: s3://my-bucket/incoming/
poll_interval_s: 300
delete_orphans: true
ignore_patterns: ["draft*"]
include_patterns: ["*.pdf", "*.md"]
storage_options:
endpoint: http://seaweed:8333 # omit for AWS default chain
aws_access_key_id: ${AWS_KEY}
aws_secret_access_key: ${AWS_SECRET}
region: us-east-1
allow_http: "true"
```
ETags are the cheap-skip key. Each sweep lists the prefix, compares
the listed ETag against the document's stored `metadata["source_revision"]`,
and only fetches keys whose ETag has changed. If the bytes turn out to
match the stored MD5 (multipart re-upload landing a new ETag on the
same content), only the revision is refreshed — no re-chunk.
`storage_options` follows the same convention as `lancedb.storage_options`
the dict is passed straight to obstore (the Rust `object_store` library
LanceDB uses internally), so credentials configured for the LanceDB
backend can be copy-pasted here.
### HTTP
```yaml
ingester:
sources:
- type: http
id: arxiv
urls:
- https://arxiv.org/pdf/2301.12345.pdf
headers:
Authorization: Bearer ${SOME_TOKEN}
poll_interval_s: 86400
```
HTTP is pull-based with HEAD-driven change detection. A `410 Gone`
response from a configured URL triggers a delete event; other failure
statuses fall through to UPSERT-with-no-revision so the worker can
GET and decide.
### WebDAV
```yaml
ingester:
sources:
- type: webdav
id: nextcloud
base_url: https://nextcloud.example.com/remote.php/dav/files/alice/Documents/
username: alice
password: ${NEXTCLOUD_APP_PASSWORD}
ignore_patterns: ["**/Trash/**"]
poll_interval_s: 600
```
Each sweep issues one `PROPFIND` with `Depth: infinity` against
`base_url` and parses the multistatus response. Files (non-collection
resources) are emitted as UPSERT / UNCHANGED based on the `getetag`
property (falling back to `getlastmodified` if the server omits it);
URIs that were in the previous snapshot but no longer appear under the
collection are emitted as DELETE.
Fetches are plain HTTP GETs — any WebDAV server already supports them.
Redirects are followed for both `PROPFIND` and `GET`, so front-ended
servers that 30x on trailing-slash normalisation or scheme upgrades (e.g.
Plone) work without extra configuration. Discovered URIs stay anchored to
`base_url` (the `GET` fetch follows redirects to the bytes). A same-host
scheme upgrade (`http`→`https`) is transparent; a redirect that moves the
collection to a different path or host makes discovery raise so you can
point `base_url` at the new location rather than silently dropping every
file. Credentials are never replayed to a different host on a redirect.
Bearer-token auth can replace HTTP Basic via the standard `headers` map:
```yaml
- type: webdav
id: kdrive
base_url: https://kdrive.infomaniak.com/app/drive/123/
headers:
Authorization: Bearer ${KDRIVE_TOKEN}
```
### File size limits
Any source can set `max_file_size` (bytes) to reject oversized files
before they are read into memory. Files exceeding the limit go
straight to the DLQ without retrying.
```yaml
- type: fs
root: /data/docs
max_file_size: 104857600 # 100 MB
```
FS and S3 sources know the size before downloading (`stat`, object
metadata), so the limit is always enforced. For HTTP and WebDAV the check
relies on a `Content-Length` response header; a server that omits it (for
example a chunked response) is fetched in full and the limit does not
apply.
### Metadata providers
A source can attach custom metadata to every document it ingests by
naming a `metadata_provider`. The provider is a callable that an external
package registers under the `haiku.rag.metadata_providers` entry-point
group; when the document is fetched for ingestion, the ingester calls it
with `(source_id, uri, result)`, where `result` is the source's
`FetchResult`, and merges the returned dict into the document's metadata.
```yaml
- type: webdav
id: handbook
base_url: https://dav.example.com/remote.php/dav/files/svc
metadata_provider: example-provider
```
The provider is a zero-argument callable returning the provider instance,
so a class is its own factory:
```python
# example_pkg/__init__.py
from urllib.parse import urlparse
from haiku.rag.sources import FetchResult
class Provider:
async def __call__(
self, source_id: str, uri: str, result: FetchResult
) -> dict:
path = urlparse(uri).path
return {
"collection": source_id,
"folder": path.rsplit("/", 1)[0] or "/",
"bytes": str(len(result.body)),
}
```
```toml
# in the provider package's pyproject.toml
[project.entry-points."haiku.rag.metadata_providers"]
example-provider = "example_pkg:Provider"
```
The provider is built once at startup, so it can hold a client or cache
across calls. When a document's source revision is unchanged, the
ingester keeps the existing cheap HEAD short-circuit and preserves the
stored provider metadata; the provider runs again when the document is
fetched for a new or changed revision. The source-derived keys (`md5`,
`source_revision`, `content_type`) are stripped from provider output, so
a provider cannot override them. A `metadata_provider` name with no
installed entry point fails at startup. A provider exception is
classified like any other ingestion error (network and timeout errors
retry; others go to the DLQ).
### Custom sources
The four built-in source types (`fs`, `http`, `s3`, `webdav`) cover the
common cases. To ingest from something else (a git host, a ticketing
system, a bespoke API), an external package registers a source factory
under the `haiku.rag.sources` entry-point group and a config references it
with `type: plugin`.
```yaml
- type: plugin
id: api-docs
plugin: git
options:
owner: acme
repo: api
branch: main
token: ${SCM_TOKEN}
```
`plugin` is the entry-point name. `options` is an opaque mapping passed
straight to the factory, which validates it however it likes (for example
with its own Pydantic model). The base fields on every source
(`id`, `poll_interval_s`, `delete_orphans`, `max_file_size`, `retry`,
`circuit_breaker`, `metadata_provider`) are handled by the ingester and
are not part of `options`.
The factory is called with the source id, the validated `options`, and the
ambient extension and size limits, and returns a `Source`:
```python
def __call__(
self,
*,
source_id: str,
options: dict,
supported_extensions: list[str] | None,
max_file_size: int | None,
) -> Source: ...
```
A `Source` implements this protocol:
```python
class Source(Protocol):
source_id: str
def supports(self, uri: str) -> bool: ...
# Current revision for `uri`, cheaply, or None if there is no cheap
# lookup. Lets the pipeline skip re-ingest when the revision is unchanged.
async def head(self, uri: str) -> str | None: ...
# Release resources (connection pools, etc.). Called once at shutdown.
async def aclose(self) -> None: ...
async def fetch(self, uri: str) -> FetchResult: ...
# Yield UPSERT / UNCHANGED / DELETE events. `since` is the uri -> revision
# snapshot from the previous sweep so the source can emit only deltas.
def discover(
self,
since: RevisionSnapshot | None = None,
*,
known_uris: set[str] | None = None,
) -> AsyncIterator[SourceEvent]: ...
```
`FetchResult`, `SourceEvent`, `SourceEventKind`, and `RevisionSnapshot`
live in `haiku.rag.sources`.
```toml
# in the source package's pyproject.toml
[project.entry-points."haiku.rag.sources"]
git = "example_pkg:build_git_source"
```
Only the plugin a source references is imported, so an unused plugin with a
missing optional dependency does not break startup. A `plugin` name with no
installed entry point fails at startup, as does a factory that returns
something that is not a `Source`.
Two limits to know:
- Custom sources are reached through configured discovery and the job
queue, not through one-shot `haiku-rag add-src <uri>`, which only knows
the built-in URI schemes.
- Change detection is per `(source, uri)`. A source that needs a single
per-source cursor (for example a git last-commit SHA) tracks it itself,
by encoding it in each URI's revision or stashing it under a sentinel URI.
## Workers and retry
```yaml
ingester:
workers:
worker_count: 4
poll_idle_interval_s: 1.0
lease_ttl_s: 120
heartbeat_interval_s: 30
reaper_interval_s: 60
shutdown_grace_s: 60 # SIGTERM drains in-flight up to this long
retry:
max_attempts: 5
base_delay_s: 2.0
max_delay_s: 300.0
jitter: 0.25 # ±25%
```
The worker pool runs `worker_count` async workers, each processing one
job at a time. `worker_count` is therefore also the maximum number of
concurrent in-flight jobs. Jobs that hit a `TransientError` are
rescheduled with exponential backoff plus jitter, up to `max_attempts`,
then land in the dead-letter queue. `PermanentError` (unsupported
extension, 4xx HTTP except 408/429, object-store credential and
configuration errors, etc.) skips retry entirely.
While a worker processes a job it renews the job's lease every
`heartbeat_interval_s`. A reaper task resets any claim whose lease has not
been renewed within `lease_ttl_s` so a crashed worker doesn't strand its
job. Because a live worker keeps renewing, `lease_ttl_s` need not exceed
job duration — a slow job is not reaped while it is still running.
**Backpressure.** Each poller skips its periodic sweep when its source
already has queued or claimed jobs in the queue. The unique-index dedup
would coalesce a re-sweep anyway; the skip saves the listing round-trip
(`PROPFIND` / `S3 LIST` / FS walk). FS push events from `watchfiles`
still flow during a skipped sweep, so new files aren't lost.
**Graceful shutdown.** On `SIGINT` / `SIGTERM`, pollers stop immediately
and workers are given `shutdown_grace_s` to finish in-flight jobs. Jobs
still running after the grace window are cancelled and released back to
`queued` for immediate re-claim; any release that doesn't land has its
lease lapse and is reclaimed by the reaper after `lease_ttl_s`.
**Tuning.**
- `lease_ttl_s` bounds how long a crashed worker's job stays stuck before
another worker takes it over. It no longer needs to exceed job duration,
so it can be short; keep it well above `heartbeat_interval_s`.
- `heartbeat_interval_s` must be at most `lease_ttl_s / 3` so scheduler
jitter or a slow DB round-trip can't let a live job's lease lapse.
- `worker_count` should match downstream capacity. docling-serve
processes one task per instance, so `worker_count` above the number
of `providers.docling_serve.base_url` entries over-subscribes the
fleet — extra submissions queue inside docling-serve. They are not
reaped while queued because the worker keeps renewing the lease.
- `poll_idle_interval_s`: lower = faster pickup, more SQLite churn.
- `reaper_interval_s`: worst-case post-crash reclaim is
`lease_ttl_s + reaper_interval_s`.
**Per-source override.** A source can opt out of the global retry
policy:
```yaml
ingester:
sources:
- type: http
id: flaky-api
urls: [...]
retry:
max_attempts: 10
base_delay_s: 10
```
## Circuit breaker
After N consecutive `discover()` failures, a source's circuit breaker
opens and polling pauses for a cooldown. Other sources keep running.
```yaml
ingester:
sources:
- type: http
id: rate-limited
urls: [...]
circuit_breaker:
failure_threshold: 5
cooldown_s: 600
```
## Run it
```bash
haiku-ingester serve # workers + pollers + API
haiku-ingester serve --no-api # workers + pollers only
haiku-ingester serve --db /path.lancedb # explicit DB
haiku-ingester serve --host 0.0.0.0 # bind API on all interfaces
haiku-ingester serve --port 9000 # override API port
```
`--host` and `--port` are CLI overrides for `ingester.api.host` and
`ingester.api.port` in `haiku.rag.yaml`. Both default to the YAML value
(which itself defaults to `127.0.0.1:8765` — loopback only).
The service blocks until SIGINT or SIGTERM. Shutdown drains the API
server, then pollers, then in-flight workers.
### Single-writer constraint
haiku.rag serializes multi-table writes with a process-local lock and rolls
them back by restoring table versions, so a second writing process can
commit inside another's transaction and be reverted by its rollback. Run
exactly one `haiku-ingester serve` against a given LanceDB. Multiple
MCP servers or read-only consumers against the same DB are fine. Sharing
the Postgres queue across processes is safe (the claim/lease lifecycle is
cross-process-correct) but does not relax this constraint — it governs the
queue, not the LanceDB.
## HTTP control plane
By default the ingester exposes a FastAPI control plane on
`127.0.0.1:8765`. Set `ingester.api.auth_token` to require a Bearer
token; without one the API stays open and the service logs a warning.
!!! warning "Non-loopback binds need a token"
Loopback (`127.0.0.1`) is local-only and safe to leave open. If
you bind to any other interface (`0.0.0.0`, a LAN IP, behind a
reverse proxy) **set `auth_token`** — the control plane can
cancel jobs, retry from the DLQ, and trigger source refreshes.
The startup warning is your only signal that you forgot.
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/` | browser dashboard (HTML; unauthenticated, the JS attaches the bearer on its own JSON fetches) |
| `GET` | `/health` | liveness + queue counts + live worker/poller counts; `status` is `"ok"` or `"degraded"` |
| `GET` | `/sources` | configured pollers + last-poll time + breaker state + last skip reason |
| `POST` | `/sources/{id}/refresh` | force an out-of-band sweep |
| `GET` | `/jobs` | filtered list (`status`, `source_id`, `uri`, `limit`, `offset`) |
| `GET` | `/jobs/{id}` | one job |
| `POST` | `/jobs/{id}/retry` | reset attempts to 0, status to queued |
| `DELETE` | `/jobs/{id}` | cancel a queued/claimed job |
| `GET` | `/dlq` | dead jobs |
| `POST` | `/dlq/{id}/retry` | resurrect from DLQ |
| `GET` | `/stats` | rolling throughput (5m / 30m / 1h succeeded), worker occupancy, oldest queued age, per-source DLQ + backlog |
| `GET` | `/database` | LanceDB snapshot — stored version, embeddings, per-table row counts/sizes, vector index status, pending migrations, package versions (same data as `haiku-rag info`) |
| `GET` | `/config` | full effective configuration (defaults filled in) as YAML, with secrets redacted |
OpenAPI docs at `http://localhost:8765/docs`. The dashboard at `/` polls
the JSON endpoints above every few seconds and surfaces the same data
visually — queue depth chips, per-source health with a `queue busy` badge
when sweeps are skipped, throughput counters, active jobs with a Cancel
button, recent failures with a Retry button, and the last-completed
feed. The Database and Configuration panels are collapsed and load on
demand (the Database panel has a Refresh button) rather than on the poll
loop.
![Ingester dashboard mid-ingest: queue depth, per-source health, active and recent jobs](img/ingester-dashboard.png)
```yaml
ingester:
api:
enabled: true
host: 127.0.0.1
port: 8765
auth_token: secret # null → unauthenticated
root_path: "" # e.g. /ingester behind a proxy
```
### Behind a reverse proxy
To serve the control plane under a sub-path (so a reverse proxy can front
it alongside other services on one origin, e.g. `https://host/ingester/`),
set `ingester.api.root_path` (or `serve --root-path /ingester`). It is
forwarded to FastAPI/uvicorn as `root_path` — OpenAPI/`/docs` links become
prefix-aware — and the dashboard is served with a matching `<base href>` so
its JSON fetches resolve under the prefix. The value is normalized to a
single leading slash with no trailing slash (`ingester`, `/ingester/` and
`/` become `/ingester`, `/ingester` and `""`). Strip the prefix at the proxy
before forwarding; for example, with nginx:
```nginx
# Redirect the bare prefix to the trailing-slash form so the dashboard's
# <base href> resolves correctly.
location = /ingester {
return 308 /ingester/;
}
location /ingester/ {
rewrite ^/ingester/?(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8765;
}
```
## Operating
### One-shot batch build
`run-batch` runs a single discover sweep across every configured source,
drains the queue, then exits. New and changed resources are ingested,
resources that vanished from a source are deleted. The periodic poller
loops never start, so the run is deterministic and finishes as soon as the
queue is empty. This is the mode for building a database in CI or on a
schedule rather than running the service continuously.
```bash
haiku-ingester run-batch
haiku-ingester run-batch --db rag.lancedb
```
To review a batch before it mutates the document store, use `--dry-run`.
Dry-run performs the same discovery checks but writes no queue jobs and does
not update `sync_state`. It writes a YAML manifest named
`manifest-<datestamp>.yaml` by default:
```bash
haiku-ingester run-batch --dry-run
haiku-ingester run-batch --dry-run --output manifest-20260622.yaml
```
The manifest records the `upsert` and `delete` changes discovered for each
source. Replay it later to ingest exactly that changeset, without another
discovery sweep:
```bash
haiku-ingester run-batch --manifest manifest-20260622.yaml
```
Manifest replay rejects sources with queued or claimed work, preserving the
one-active-changeset-per-source pattern. Revisioned upserts are checked
against the current upstream revision before fetch; if the resource changed
after dry-run, that job dead-letters and the newer version waits for the next
dry-run. Sources that provide no revision can freeze URI discovery but cannot
prove byte identity at replay time.
Orphan deletion compares each source against `sync_state` in the queue DB,
so persist `ingester.db` between runs for deletions to be detected. It exits
non-zero if any job dead-letters or a source's discovery sweep does not
complete.
### The queue
The ingester's SQLite queue lives at
`~/Library/Application Support/haiku.rag/ingester.db` on macOS
(platform user data dir; configurable via `ingester.queue.path`). It's
created automatically by `serve`.
For ops setup you can pre-create it:
```bash
haiku-ingester queue init # create the DB and schema
haiku-ingester queue migrate # apply pending schema changes
```
Terminal job rows (`succeeded` and `dead`) are kept for history and pruned by
the reaper once they age past `retention_days`:
```yaml
ingester:
queue:
path: /var/lib/haiku-rag/ingester.db
retention_days: 30 # null disables pruning
```
The reaper deletes terminal rows whose `completed_at` is older than the window
on its `reaper_interval_s` cadence. Set `retention_days: null` to keep all
terminal rows.
#### Using a database server
If you already run a database server, point the queue at it with
`ingester.queue.dburi`, a SQLAlchemy async URL. SQLite is used when `dburi` is
unset.
```yaml
ingester:
queue:
dburi: postgresql+asyncpg://haiku:secret@db:5432/haiku_rag
```
Postgres (`postgresql+asyncpg://`) is supported alongside the default SQLite.
The `asyncpg` driver ships with the `[ingester]` extra. `dburi` overrides
`path`, and the `--queue` CLI flag is ignored while it is set. Create the schema
the same way as for SQLite:
```bash
haiku-ingester queue init
```
Workers claim jobs with `FOR UPDATE SKIP LOCKED`, and the claim/lease lifecycle
is cross-process-safe — claims are renewed and reaped correctly no matter which
process owns them — so several `haiku-ingester serve` processes can share one
Postgres queue without double-claiming or reaping each other's live jobs.
This does not lift the LanceDB
[single-writer constraint](#single-writer-constraint): each `serve` still owns
its own LanceDB. A shared queue therefore spans processes writing distinct
LanceDB URIs; it does not let several processes write one database.
One caveat: idle workers wake on new work instantly only within their own
process. Workers in other processes pick up enqueued jobs on their next
`poll_idle_interval_s` tick rather than immediately.
### Logs
The service logs via Python `logging` to stderr through a Rich handler.
A typical run looks like:
```
INFO Ingester running: 4 worker(s), 1 source(s)
INFO API listening on 127.0.0.1:8765
INFO Swept local-docs: 142 upsert, 0 delete, 8 unchanged
INFO Processing upsert file:///.../a.md (job 5d9a...)
INFO Job 5d9a... succeeded in 0.34s: file:///.../a.md
```
When `LOGFIRE_TOKEN` is set, spans are also shipped to Logfire. Spans carry
`service.name` (`haiku-ingester`) and `service.version`. To tell concurrent
ingestions apart in Logfire, give each process a distinct name via the standard
`OTEL_SERVICE_NAME` (or `LOGFIRE_SERVICE_NAME`) environment variable, which
overrides the default:
```bash
OTEL_SERVICE_NAME=ingester-tenant-a haiku-ingester serve
```
The span tree is `ingester.poller.sweep` -> `ingester.job` (tagged with
`source_id` and `uri`) -> `document.convert` / `document.chunk`. When a source
uses docling-serve, each request emits a `docling_serve.request` span carrying
the instance `url` and `attempt`, so a failed conversion can be traced to the
exact instance that served it. A worker circuit breaker opening emits an
`ingester.worker breaker opened` event with `source_id`, `threshold`, and
`cooldown_s`.
The `debug-ingestion` skill in `.claude/skills/` turns these spans into
ready-made Logfire queries (failed jobs, docling-serve failover, per-source
sweeps, breaker trips) for use from Claude Code.
### Operating against the API
```bash
TOKEN=$INGESTER_TOKEN # omit -H entirely if no token configured
curl http://localhost:8765/health
curl -H "Authorization: Bearer $TOKEN" http://localhost:8765/sources
curl -H "Authorization: Bearer $TOKEN" 'http://localhost:8765/jobs?status=dead'
# Force a poll now
curl -H "Authorization: Bearer $TOKEN" -X POST \
http://localhost:8765/sources/local-docs/refresh
# Resurrect a dead job
curl -H "Authorization: Bearer $TOKEN" -X POST \
http://localhost:8765/jobs/<id>/retry
```

View file

@ -10,56 +10,40 @@
uv pip install haiku.rag
```
The full package pulls the `docling`, `voyageai`, `cohere`, `zeroentropy`,
`cross-encoder`, `jina` and `tui` extras. It does not include `s3` or `ingester`:
The full package includes **all features and extras**:
- **Document processing** (Docling) - PDF, DOCX, PPTX, images, and 40+ file formats
- **All embedding providers** - VoyageAI
- **All rerankers** - MixedBread AI, Cohere, Zero Entropy
```bash
uv pip install 'haiku.rag[ingester]' # the haiku-ingester service
uv pip install 'haiku.rag[s3]' # S3 and object storage
```
This is the easiest way to get started with all features enabled.
### Slim Package (Minimal Dependencies)
```bash
# Minimal installation (no document processing)
uv pip install haiku.rag-slim
uv pip install 'haiku.rag-slim[docling]'
uv pip install 'haiku.rag-slim[docling,voyageai,cross-encoder]'
# With document processing
uv pip install haiku.rag-slim[docling]
# With specific providers
uv pip install haiku.rag-slim[docling,voyageai,mxbai]
```
### Extras
The slim package has minimal dependencies and lets you install only what you need:
Every extra `haiku.rag-slim` defines. The right-hand column marks the ones the
full `haiku.rag` package already includes.
| Extra | Provides | In `haiku.rag` |
|---|---|---|
| `docling` | PDF, DOCX, PPTX, images and 40+ formats, converted locally | yes |
| `tui` | Terminal UI for `chat` and `inspect` | yes |
| `voyageai` | VoyageAI embeddings | yes |
| `cohere` | Cohere embeddings and reranking | yes |
| `zeroentropy` | Zero Entropy reranking | yes |
| `cross-encoder` | Local reranking via sentence-transformers | yes |
| `jina` | Local Jina reranking (`provider: jina-local`) | yes |
| `s3` | S3 and object-storage access | no |
| `ingester` | The `haiku-ingester` service (also pulls `s3`) | no |
| `anthropic` | Anthropic Claude models | no |
| `google` | Google Gemini models | no |
| `groq` | Groq models | no |
| `mistral` | Mistral models | no |
| `bedrock` | AWS Bedrock models | no |
| `vertexai` | Google Vertex AI models | no |
Ollama and any OpenAI-compatible endpoint work with no extra at all.
- `docling` - PDF, DOCX, PPTX, images, and other document formats
- `voyageai` - VoyageAI embeddings
- `mxbai` - MixedBread AI reranking
- `cohere` - Cohere reranking
- `zeroentropy` - Zero Entropy reranking
**Built-in providers** (no extras needed):
- **Ollama** (default embedding provider)
- **OpenAI** (GPT models for QA and embeddings)
- **vLLM** and other OpenAI-compatible endpoints (embeddings, QA, reranking)
- **Jina** reranking via `provider: jina`, which calls the Jina HTTP API
- **Anthropic** (Claude models for QA)
Other providers come from the extras above, which pull the matching Pydantic AI extra. For Claude models, `uv pip install 'haiku.rag-slim[anthropic]'`.
See [Configuration](configuration/index.md) for configuring providers including advanced options like vLLM.
See [Configuration](configuration.md) for configuring providers including advanced options like vLLM.
## Requirements
@ -74,45 +58,18 @@ You can prefetch all required runtime models before first use:
haiku-rag download-models
```
This will download:
- Docling models for document processing
- HuggingFace tokenizer models for chunking
- Any Ollama models referenced by your current configuration
## Remote Processing (Optional)
When using `haiku.rag-slim`, you can skip installing the `docling` extra and instead use [docling-serve](https://github.com/docling-project/docling-serve) for remote document processing. This is useful for:
- Keeping dependencies minimal
- Offloading heavy document processing to a dedicated service
- Production deployments with separate processing infrastructure
See [Remote processing](remote-processing.md) for setup instructions and [Document Processing](configuration/processing.md) for configuration options.
This will download Docling models and pull any Ollama models referenced by your current configuration.
## Docker
Only the slim image is published. Build the full image yourself:
### Slim Image (Minimal)
Pre-built slim image with minimal dependencies - use with external docling-serve for document processing:
```bash
docker pull ghcr.io/ggozad/haiku.rag-slim:latest
docker pull ghcr.io/ggozad/haiku.rag:latest
```
See `examples/docker/docker-compose.yml` for a complete setup with docling-serve.
### Full Image (Self-contained)
Build locally to include all features and document processing without docling-serve:
Run the container with all services:
```bash
docker build -f docker/Dockerfile -t haiku-rag .
docker run -p 8001:8001 \
-v /path/to/haiku.rag.yaml:/app/haiku.rag.yaml \
-v /path/to/data:/data \
haiku-rag
docker run -p 8000:8000 -p 8001:8001 -v $(pwd)/data:/data ghcr.io/ggozad/haiku.rag:latest
```
See `docker/README.md` for complete build and configuration instructions, including how to run the [ingester](ingester.md) service for continuous document ingestion.
This starts the MCP server on port 8001, with data persisted to `./data`.

View file

@ -1,210 +1,30 @@
# Model Context Protocol (MCP)
The MCP server exposes `haiku.rag` as MCP tools for compatible MCP clients like Claude Desktop.
The MCP server exposes `haiku.rag` as MCP tools for compatible MCP clients.
## Available Tools
### Document Management
- `add_document_from_file` - Add documents from local file paths
- `add_document_from_url` - Add documents from URLs
- `add_document_from_text` - Add documents from raw text content
- `get_document` - Retrieve specific documents by ID
- `list_documents` - List all documents with pagination and optional filtering
- `delete_document` - Delete documents by ID
### Search
- `search_documents` - Search documents using hybrid search (vector + full-text)
## Starting MCP Server
The MCP server supports Streamable HTTP and stdio transports:
The MCP server starts automatically with the serve command and supports Streamable HTTP and stdio transports:
```bash
# Default streamable HTTP transport on 127.0.0.1:8001
haiku-rag mcp
# Custom port
haiku-rag mcp --port 9000
# Bind to all interfaces (e.g. inside a container)
haiku-rag mcp --host 0.0.0.0 --port 8001
# Default streamable HTTP transport
haiku-rag serve
# stdio transport (for Claude Desktop)
haiku-rag mcp --stdio
haiku-rag serve --stdio
```
`--host` defaults to `127.0.0.1` (loopback only). Bind to `0.0.0.0` only
when you want the MCP server reachable from outside the local machine —
e.g. inside a Docker container with port mapping, or on a trusted LAN.
The server opens the database read-only. Ingestion goes through the CLI
(`haiku-rag add`, `add-src`, `delete`) or [`haiku-ingester`](ingester.md).
## Collections
With several databases in `lancedb.databases`, the server covers all of
them, as `haiku-rag search` does. Results and documents name theirs in
`source`. `sources` on `search_documents`, `search_documents_by_image`
and `execute_code` restricts a call to a subset; `source` on `get_document` names the database holding the
document. A name the server does not cover is an error.
`haiku-rag --db-name NAME mcp` serves one. See
[Multiple Databases](configuration/storage.md#multiple-databases).
## Claude Code
The repository ships a plugin that registers the server and a skill telling
Claude when and how to use it:
```bash
claude plugin marketplace add ggozad/haiku.rag
claude plugin install haiku-rag
```
The plugin runs `haiku-rag mcp --stdio`, so `haiku-rag` must be on the PATH
and the configuration decides the database. The skill pre-approves every tool
and is also invocable as `/haiku-rag`. To register the server without the
plugin:
```bash
claude mcp add haiku-rag -- haiku-rag mcp --stdio
```
The skill works with that registration too: copy `plugins/haiku-rag/skills/haiku-rag`
into `~/.claude/skills/` and change the tool prefix in its `allowed-tools` from
`mcp__plugin_haiku-rag_haiku-rag__` to `mcp__haiku-rag__`.
## Codex
The repository's Codex plugin registers the server and installs the same Agent
Skill:
```bash
codex plugin marketplace add ggozad/haiku.rag
codex plugin add haiku-rag@haiku-rag
```
The plugin runs `haiku-rag mcp --stdio`, so `haiku-rag` must be on the PATH.
Invoke the skill as `$haiku-rag`. Codex can also select it automatically from
its description. To register the server without the plugin:
```bash
codex mcp add haiku-rag -- haiku-rag mcp --stdio
```
The skill works with that registration too: copy
`plugins/haiku-rag/skills/haiku-rag` into `~/.agents/skills/`.
The `allowed-tools` field supplies Claude Code's tool pre-approval and may be
ignored by other Agent Skills clients. Codex configures MCP tool approvals
separately in `config.toml`.
## Claude Desktop Integration
Add to your Claude Desktop configuration (`claude_desktop_config.json`):
```json
{
"mcpServers": {
"haiku-rag": {
"command": "haiku-rag",
"args": ["mcp", "--stdio"]
}
}
}
```
With a custom database path:
```json
{
"mcpServers": {
"haiku-rag": {
"command": "haiku-rag",
"args": ["mcp", "--stdio", "--db", "/path/to/database.lancedb"]
}
}
}
```
After restarting Claude Desktop, you can ask Claude to search your documents or answer questions using your knowledge base.
## Tools
Every tool is read-only and says so in its annotations. Each parameter carries
a description in the tool schema, so the listing below names them without
repeating it.
| Tool | Registered | Parameters |
|---|---|---|
| `search_documents` | always | `query`, `limit`, `include_images`, `filter`, `sources` |
| `search_documents_by_image` | multimodal embedder only | `image_base64`, `limit`, `include_images`, `filter`, `sources` |
| `get_document` | always | `document_id`, `source` |
| `get_document_outline` | always | `document_id`, `source` |
| `get_document_section` | always | `document_id`, `section_id`, `source` |
| `list_documents` | always | `limit`, `offset`, `filter` |
| `execute_code` | always | `code`, `filter`, `sources` |
`search_documents` runs hybrid search, vector and full-text. Its text content
is the rendering the in-process agents read: results best first, each with its
rank, `Document ID`, `Collection` when the server covers several, the document
title, section headings, the matched chunk's metadata when it has any, and the
passage expanded to its section the way the agents get it
(`search.max_context_chars` caps it). Pictures in the results follow as
image blocks, one per distinct picture, each preceded by a line naming its
result; `include_images: false` leaves them out. Search results carry no
structured content, so every client shows the model the same text and
images. Scores are not comparable across
queries or search types, so rank is the signal. `search_documents_by_image`
embeds the query image and searches by vector similarity alone.
`get_document` returns a document whole, in reading order. For a long one,
`get_document_outline` returns the heading tree with page numbers and
`get_document_section` the text of one section, subsections included; a
node's `id` in the outline is the `section_id`. A document without headings
has an empty outline. `list_documents` returns titles, URIs and metadata,
which is how a client learns what a filter can match.
### Code
`execute_code` runs a Python program in the sandbox of the
[analysis capability](capabilities/analysis.md), over the documents `filter`
and `sources` select, and returns what it printed. The program reads
`/documents/{document_id}/` (`metadata.json`, `content.txt`, `items.jsonl`,
`chunks.jsonl`, `toc.json`) and can `await search()` and
`await list_documents()`; the tool description spells out the fields and the
patterns that matter. Each call is one program: nothing carries over between
calls, and the sandbox is created and closed per call. A failing program is a
tool error carrying the interpreter's message and any output printed before
it. No model runs on the server. Claude Code moves a call still running after
about two minutes to a background task.
The interpreter is [Monty](https://github.com/pydantic/monty), a Python subset.
Useful modules include `json`, `re`, `math`, `pathlib`, `datetime`,
`collections`, `itertools`, `functools` and `dataclasses`. Absent, and often
reached for: `decimal` and `statistics`. No generator functions, class
inheritance or `match` statements, and a file object cannot be iterated. Files are read-only, and
there is no network and no filesystem
beyond `/documents`. `analysis.code_timeout` is the call's budget: compute is
stopped at it, and past it no further host call starts, a file read or an
in-code search alike, though one already running finishes.
`analysis.max_output_chars` bounds the output.
### Filters
`filter` is a SQL WHERE clause over the document columns `id`, `uri`, `title`,
`metadata`, `created_at`, `updated_at`. `metadata` is a JSON string, so match
its keys with LIKE:
```sql
metadata LIKE '%"author": "Smith"%'
uri LIKE '%.pdf'
title = 'Q3 report'
```
### Errors
A failure is an MCP error carrying its message, never an empty result: a
document or section id that matches nothing, a collection the server does not
cover, a filter the query engine rejects, invalid base64, a program that fails
in `execute_code` with the error it hit, and anything unexpected with its own
message.
### Instructions
The server publishes `instructions` describing the knowledge base: what it
holds, when to reach for it, the collection names when it covers several, and
`prompts.domain_preamble` when set. Claude Code and Codex show them to the
model. Claude Desktop does not, so every tool description stands on its own.
## Continuous ingestion
For continuous document ingestion (filesystem watch, S3 polling, HTTP
sources, a job queue with retries), run [`haiku-ingester`](ingester.md)
as a separate process against the same LanceDB.

View file

@ -1,90 +0,0 @@
# Architecture
haiku.rag ingests documents, retrieves from them with hybrid search, and answers
with citations. This page follows the data through the system. For a working
setup, start with the [Quickstart](tutorial.md).
## Ingestion
```text
source adapter -> converter -> chunker -> embedder -> LanceDB
```
A **source adapter** owns the I/O and the identity of a document: it fetches
bytes, reports the backend's revision (mtime for a file, ETag for S3 or HTTP),
and computes the content hash. The same adapters serve one-shot ingestion
(`haiku-rag add-src`, `HaikuRAG.create_document_from_source`) and the continuous
[`haiku-ingester`](ingester.md) service, so both agree on what a document is and
when it has changed.
The **converter** turns those bytes into a `DoclingDocument`, the structured form
that carries headings, tables, pictures and page provenance. It runs in-process
with the `docling` extra, or against a [docling-serve](remote-processing.md)
fleet.
The **chunker** splits that structure into chunks, each keeping the headings it
sits under, the page numbers it came from, and references to the document items
it covers. With a multimodal embedder, pictures become chunks of their own.
The **embedder** vectorizes them in batches. The document, its mutable metadata,
its chunks and its structural items are written under one process-local
transaction: it takes a version snapshot, and on failure restores each table to
it. A rollback that cannot complete raises rather than reporting success, and the
snapshot is only meaningful while this process is the only writer.
## Storage
LanceDB is embedded, so there is no server. The same code runs against a local
directory, S3, GCS, Azure or LanceDB Cloud by changing a database's location in
`lancedb.databases`.
Tables are versioned. Vacuum collapses old versions on a retention window, and
[tags](cli.md) name a state across all tables so a database can be restored to
it later.
One process writes at a time. Reads are unrestricted, and a reader sees another
process's writes after `lancedb.read_consistency_interval_seconds`.
## Retrieval
```text
query -> vector + full-text search -> fusion -> rerank -> context expansion
```
Search runs a vector query and a full-text query and fuses the rankings. With a
reranker configured, it retrieves ten times the requested limit and reranks down
to it, so quality improves without changing the caller's limit.
Results then expand: a chunk is returned with the section it belongs to, bounded
by `search.max_context_chars`. Sections that fit come back whole, larger ones
grow outward from the match, and small ones grow across boundaries. Every result
carries its page numbers and headings, which is what makes a citation checkable.
## Answering
Two [capabilities](capabilities/index.md) sit on top, both native Pydantic AI
capabilities you can attach to your own agent:
- The **RAG capability** searches and cites. Its citations carry page numbers and
headings, and `haiku-rag visualize` draws the cited chunk on the page image.
- The **analysis capability** adds a sandboxed Python interpreter with the
documents mounted as a filesystem, for questions that need computation across
documents rather than retrieval.
Two optional capabilities compose with them: evidence compaction replaces older
turns' evidence with what was actually cited, and citation policy requires every
answer to declare what grounds it.
The same database is reachable from [Python](python.md), the [CLI](cli.md), and
the [MCP server](mcp.md).
## Running it
A laptop needs nothing but the package and Ollama. Production adds the
[`haiku-ingester`](ingester.md) service, which polls its sources, queues work in
SQLite or Postgres, and retries with a circuit breaker per source.
Before deploying, read the operational constraints in
[Storage](configuration/storage.md): one writer per database, `haiku-rag migrate`
after an upgrade that changes the schema, and a fixed embedding dimension per
database.

View file

@ -8,33 +8,12 @@ Use `haiku.rag` directly in your Python applications.
from pathlib import Path
from haiku.rag.client import HaikuRAG
# Create a new database
async with HaikuRAG("path/to/database.lancedb", create=True) as client:
# Use as async context manager (recommended)
async with HaikuRAG("Path(path/to/database.lancedb")) as client:
# Your code here
pass
# Open an existing database (will fail if database doesn't exist)
async with HaikuRAG("path/to/database.lancedb") as client:
# Your code here
pass
# Open in read-only mode (blocks writes)
async with HaikuRAG("path/to/database.lancedb", read_only=True) as client:
results = await client.search("query") # Read operations work
# await client.create_document(...) # Would raise ReadOnlyError
```
`async with` is the lifecycle. A caller that owns the client some other way releases it with `await client.aclose()`, which does the same work for every client shape. `client.close()` closes the connection to one database and nothing else, since draining the background vacuum and releasing the embedder and reranker are awaitable; it refuses a client covering several.
!!! note
Databases must be explicitly created with `create=True` or via `haiku-rag init` before use. Opening a nonexistent local database given as a path raises `FileNotFoundError`, naming the path; a configured or default database raises `SourceUnavailableError`, which names the database rather than its location. A path beside a configured `lancedb.databases` raises `AmbiguousDatabaseError`.
!!! note
Read-only mode is useful for safely accessing databases without risk of modification. It blocks all write operations and downgrades an embedding provider/name mismatch to a warning instead of raising `ConfigMismatchError`.
!!! warning "Database Migrations"
When upgrading haiku.rag to a version with schema changes, opening an existing database will raise `MigrationRequiredError`. Run `haiku-rag migrate` to apply pending migrations before using the database. See [CLI Database Management](cli.md#migrate-database) for details.
## Document Management
### Creating Documents
@ -49,25 +28,31 @@ doc = await client.create_document(
)
```
From HTML content (preserves document structure):
With custom externally generated chunks:
```python
html_content = "<h1>Title</h1><p>Paragraph</p><ul><li>Item 1</li></ul>"
from haiku.rag.store.models.chunk import Chunk
# Create custom chunks with optional embeddings
chunks = [
Chunk(
content="This is the first chunk",
metadata={"section": "intro"}
),
Chunk(
content="This is the second chunk",
metadata={"section": "body"},
embedding=[0.1] * 1024 # Optional pre-computed embedding
),
]
doc = await client.create_document(
content=html_content,
uri="doc://html-example",
format="html" # parse as HTML instead of markdown
content="Full document content",
uri="doc://custom",
metadata={"source": "manual"},
chunks=chunks # Use provided chunks instead of auto-generating
)
```
The `format` parameter controls how text content is parsed:
- `"md"` (default) - Parse as Markdown
- `"html"` - Parse as HTML, preserving semantic structure (headings, lists, tables)
- `"plain"` - Plain text, no parsing (creates a simple text document)
!!! note
The document's `content` field stores the markdown export of the parsed document for consistent display. The original DoclingDocument structure is preserved in the `docling_document` field (zstd-compressed, without page images). Page images are stored separately in `docling_pages`.
From file:
```python
doc = await client.create_document_from_source(
@ -82,15 +67,11 @@ doc = await client.create_document_from_source(
)
```
PDFs that carry attachments via the `/EmbeddedFiles` table are split into one Document per attachment, linked to the wrapper through `metadata.parent_uri`. See [PDF Embedded Attachments](configuration/processing.md#pdf-embedded-attachments).
### Retrieving Documents
By ID:
```python
doc = await client.get_document_by_id("document-id-string")
doc = await client.get_document_by_id("document-id-string", "papers")
chunk = await client.get_chunk_by_id("chunk-id-string", "papers")
doc = await client.get_document_by_id(1)
```
By URI:
@ -98,21 +79,9 @@ By URI:
doc = await client.get_document_by_uri("file:///path/to/document.pdf")
```
Both return content, uri, title and metadata. The multi-MB docling blobs are
loaded separately:
```python
docling = await client.document_repository.get_docling_data(doc.id)
pages = await client.document_repository.get_pages_data(doc.id)
```
List all documents:
```python
docs = await client.list_documents(limit=10, offset=0)
# Include the text content (not loaded by default). A listing never loads the
# docling blobs.
docs = await client.list_documents(include_content=True)
```
Filter documents by properties:
@ -130,62 +99,43 @@ docs = await client.list_documents(
)
```
Count documents:
```python
# Count all documents
total = await client.count_documents()
# Count with filter
pdf_count = await client.count_documents(filter="uri LIKE '%.pdf'")
```
### Updating Documents
```python
# Update content (triggers re-chunking)
await client.update_document(document_id=doc.id, content="New content")
# Update metadata only (no re-chunking)
await client.update_document(
document_id=doc.id,
metadata={"version": "2.0", "updated_by": "admin"}
)
# Update title only (no re-chunking)
await client.update_document(document_id=doc.id, title="New Title")
# Update uri only (no re-chunking)
await client.update_document(document_id=doc.id, uri="file:///new/path.txt")
# Update multiple fields at once
await client.update_document(
document_id=doc.id,
content="New content",
title="Updated Title",
metadata={"status": "final"}
)
# Use custom chunks (embeddings optional - will be generated if missing)
custom_chunks = [
Chunk(content="Custom chunk 1"),
Chunk(content="Custom chunk 2", embedding=[...]), # Pre-computed embedding
]
await client.update_document(document_id=doc.id, chunks=custom_chunks)
doc.content = "Updated content"
await client.update_document(doc)
```
**Notes:**
- Updates to only `metadata` or `title` skip re-chunking
- Updates to `content` trigger re-chunking and re-embedding
- Custom `chunks` with embeddings are stored as-is. Missing embeddings are generated automatically
### Deleting Documents
```python
await client.delete_document(doc.id)
```
Deleting a document also removes any child Documents linked to it via `metadata.parent_uri` (PDF attachment children, primarily). The cascade is transitive.
### Rebuilding the Database
```python
async for doc_id in client.rebuild_database():
print(f"Processed document {doc_id}")
```
## Maintenance
Run maintenance to optimize storage and prune old table versions:
```python
await client.vacuum()
```
This compacts tables and removes historical versions to keep disk usage in check. Its safe to run anytime, for example after bulk imports or periodically in longrunning apps.
### Atomic Writes and Rollback
Document create and update operations take a snapshot of table versions before any write and automatically roll back to that snapshot if something fails (for example, during chunking or embedding). This restores both the `documents` and `chunks` tables to their preoperation state using LanceDBs table versioning.
- Applies to: `create_document(...)`, `create_document_from_source(...)`, `update_document(...)`, and internal rebuild/update flows.
- Scope: Both document rows and all associated chunks are rolled back together.
- Vacuum: Running `vacuum()` later prunes old versions for disk efficiency; rollbacks occur immediately during the failing operation and are not impacted.
## Searching Documents
@ -194,14 +144,12 @@ The search method performs native hybrid search (vector + full-text) using Lance
Basic hybrid search (default):
```python
results = await client.search("machine learning algorithms", limit=5)
for result in results:
print(f"Score: {result.score:.3f}")
print(f"Content: {result.content}")
print(f"Document ID: {result.document_id}")
for chunk, score in results:
print(f"Score: {score:.3f}")
print(f"Content: {chunk.content}")
print(f"Document ID: {chunk.document_id}")
```
Each result carries the parent document's metadata in `result.document_meta` and the relevant chunk's verbatim metadata in `result.chunk_meta`. Neither is shown to the model during QA.
Search with different search types:
```python
# Vector search only
@ -226,68 +174,15 @@ results = await client.search(
)
# Process results
for result in results:
print(f"Relevance: {result.score:.3f}")
print(f"Content: {result.content}")
print(f"From document: {result.document_id}")
print(f"Document URI: {result.document_uri}")
print(f"Document Title: {result.document_title}") # when available
for chunk, relevance_score in results:
print(f"Relevance: {relevance_score:.3f}")
print(f"Content: {chunk.content}")
print(f"From document: {chunk.document_id}")
print(f"Document URI: {chunk.document_uri}")
print(f"Document Title: {chunk.document_title}") # when available
print(f"Document metadata: {chunk.document_meta}")
```
### Searching Multiple Databases
With [`lancedb.databases`](configuration/storage.md#multiple-databases) configured, a client covers the full set. Use `sources` to select a subset. Each result includes its database name:
```python
results = await client.search("machine learning") # all of them
results = await client.search("machine learning", sources=["papers"]) # one of them
for result in results:
print(f"{result.source}: {result.content}")
```
`ask` and `analyze` also accept `sources`. Citations include the database name:
```python
answer, citations = await client.ask("What changed?", sources=["papers", "wiki"])
for cite in citations:
print(f"[{cite.source}] {cite.document_title or cite.document_uri}")
result = await client.analyze("How many documents mention it?", sources=["papers"])
```
A scoped question can cite only the selected databases. Analysis mounts only their documents.
`sources=None` covers every database the client covers. `sources=[]` covers none: `search` returns no results, and `ask` and `analyze` run with no evidence from any database.
A name no client covers raises `UnknownDatabaseError`, a `KeyError`, wherever it is given: at construction, per query, and when placing a citation.
On the constructor `sources=[]` means something else. Passing `sources` alongside a database path raises `AmbiguousDatabaseError` immediately, since both say which database to open. Passing `sources=[]` alone raises `ValueError` on entering the client: a selection of nothing to search is a legitimate question, a client over no database is not.
#### Inspecting the client scope
```python
client.covers_multiple # whether the client covers more than one database
client.source_names # database names, in order; known before the client opens
client.source # the one database's name, or None for a set
owner = await client.reader_for("papers") # the client reading that database
papers, wiki = await client.clients_for(["papers", "wiki"])
```
`reader_for` and `clients_for` open databases lazily and return borrowed clients. They remain valid while the covering client is open and inherit its read-only mode. The covering client owns and closes their database sessions.
To learn what a configuration covers without opening anything, resolve it:
```python
from haiku.rag.client import DatabaseScope
for ref in DatabaseScope.resolve(config).databases:
print(ref.name, ref.location) # "haiku.rag", Path(".../haiku.rag.lancedb") when nothing is configured
```
`DatabaseScope.resolve` is pure: it reads the configuration and classifies each location as a local path or a URI.
### Filtering Search Results
Filter search results to only include chunks from documents matching specific criteria:
@ -330,276 +225,60 @@ results = await client.search(
- `created_at`, `updated_at` - Timestamps
- `metadata` - Document metadata (as string, use LIKE for pattern matching)
### Image queries
`client.search()` accepts an image instead of a text query when the configured embedder is multimodal (`embeddings.model.multimodal: true` on a vLLM, VoyageAI, or Cohere model). The image is embedded once and the chunks table is searched vector-only. Full-text search and reranking don't apply without a text query.
```python
from PIL import Image
# Bytes
results = await client.search(
open("figure.png", "rb").read(),
limit=5,
)
# PIL.Image works equivalently
results = await client.search(
Image.open("figure.png"),
limit=5,
)
```
Image queries surface picture chunks (synthetic per-figure chunks emitted at ingest under a multimodal embedder) and any text chunks whose vectors land near the image vector in the shared embedding space. Calling `client.search(bytes)` against a text-only embedder raises a `ValueError`.
### Expanding Search Context
Expand search results with surrounding content from the document:
Expand search results with adjacent chunks for more complete context:
```python
# Get initial search results
search_results = await client.search("machine learning", limit=3)
# Expand with section-bounded context
# Expand with adjacent chunks using config setting
expanded_results = await client.expand_context(search_results)
for result in expanded_results:
print(f"Expanded content: {result.content}")
# Or specify a custom radius
expanded_results = await client.expand_context(search_results, radius=2)
# The expanded results contain chunks with combined content from adjacent chunks
for chunk, score in expanded_results:
print(f"Expanded content: {chunk.content}") # Now includes before/after chunks
```
Context expansion is automatic and section-aware. For structured documents (with section headers), expansion includes the entire section containing the match. For sections that exceed the budget or are too small (e.g., a title+authors area), expansion grows outward item-by-item from the match center, skipping noise labels (footnotes, page headers). This naturally crosses into adjacent sections until the budget is filled. Picture and table matches are exempt: they return their enclosing section as-is and never cross section boundaries. For unstructured documents, expansion grows outward item-by-item. Results without `doc_item_refs` (e.g., custom chunks passed to `import_document`) pass through unexpanded.
**Smart Merging**: When expanded chunks overlap or are adjacent within the same document, they are automatically merged into single chunks with continuous content. This eliminates duplication and provides coherent text blocks. The merged chunk uses the highest relevance score from the original chunks.
Configuration:
- **search.max_context_chars**: Maximum characters in expanded context. Default: 5000.
**Smart Merging**: When expanded results overlap within the same document, they are automatically merged into a single result with continuous content and the highest relevance score.
This is automatically used by the QA system when `processing.context_chunk_radius > 0` (configured in `haiku.rag.yaml`) to provide better answers with more complete context.
## Question Answering
Ask questions about your documents:
```python
answer, citations = await client.ask("Who is the author of haiku.rag?")
answer = await client.ask("Who is the author of haiku.rag?")
print(answer)
for cite in citations:
print(f" [{cite.chunk_id}] {cite.document_title or cite.document_uri}")
```
Filter to specific documents:
Ask questions with citations showing source documents:
```python
answer, citations = await client.ask(
"What are the main findings?",
filter="uri LIKE '%paper%'"
answer = await client.ask("Who is the author of haiku.rag?", cite=True)
print(answer)
```
Customize the QA agent's behavior with a custom system prompt:
```python
custom_prompt = """You are a technical support expert for WIX.
Answer questions based on the knowledge base documents provided.
Be concise and helpful."""
answer = await client.ask(
"How do I create a blog?",
system_prompt=custom_prompt
)
```
Attach images to the question, for example to check an image against indexed documents:
The QA agent will search your documents for relevant information and use the configured LLM to generate a comprehensive answer. With `cite=True`, responses include citations showing which documents were used as sources. Citations prefer the document title when present, otherwise they use the URI.
```python
answer, citations = await client.ask(
"Does this image satisfy the requirements in the design spec?",
images=[Path("photo.jpg").read_bytes()],
)
```
The QA provider and model are configured in `haiku.rag.yaml` or can be passed directly to the client (see [Configuration](configuration.md)).
Images are passed to the model alongside the question. Retrieval stays text-based. The QA model must have `vision: true` in its configuration.
`client.ask` runs the [RAG capability](capabilities/rag.md) and returns `(answer_text, list[Citation])`. Citations include page numbers, section headings, document references, the document's metadata (`document_meta`), and the cited chunk's raw, unparsed metadata (`chunk_meta`), so UIs can render metadata keys such as a public source URL alongside the citation.
The QA provider and model are configured in `haiku.rag.yaml` or can be passed directly to the client (see [Configuration](configuration/index.md)).
See also: [Capabilities](capabilities/index.md) for direct agent composition.
## Analysis
Answer complex analytical questions via code execution:
```python
# Aggregation across documents
result = await client.analyze("Which quarter had the highest revenue?")
print(result.answer)
for citation in result.citations:
print(citation.uri, citation.title)
# Computation within a document set
result = await client.analyze(
"What is the average deal size mentioned in these contracts?",
filter="uri LIKE '%contracts%'"
)
```
`client.analyze` runs the [analysis capability](capabilities/analysis.md), which writes and executes Python code in a sandboxed environment to solve problems that traditional RAG struggles with: aggregation, computation, and multi-document analysis.
`client.analyze` also accepts `images=` like `client.ask`, requiring `vision: true` on the analysis model (or the QA model when no analysis model is configured).
See [Analysis capability](capabilities/analysis.md) for details and configuration.
## Building custom agents
`client.ask` and `client.analyze` are convenience wrappers. To build your own Pydantic AI agent, attach the native RAG and analysis capabilities directly. See [Capabilities](capabilities/index.md).
For the low-level toolset factories under `haiku.rag.tools` (one rung below the capability abstraction), see [Toolsets](tools.md).
## Importing Pre-Processed Documents
If you process documents externally or need custom processing, use `import_document()`:
```python
from haiku.rag.store.models.chunk import Chunk
# Convert your source to a DoclingDocument
docling_doc = await client.convert("path/to/document.pdf")
# Create chunks (embeddings optional - will be generated if missing)
chunks = [
Chunk(
content="This is the first chunk",
metadata={"section": "intro"},
order=0,
),
Chunk(
content="This is the second chunk",
metadata={"section": "body"},
embedding=[0.1] * 1024, # Optional: pre-computed embedding
order=1,
),
]
# Import document with custom chunks
doc = await client.import_document(
docling_document=docling_doc,
chunks=chunks,
uri="doc://custom",
title="Custom Document",
metadata={"source": "external-pipeline"},
)
```
The `docling_document` provides rich metadata for visual grounding, page numbers, and section headings. Content is automatically extracted from the DoclingDocument.
### Batch Import
Each `create_document*` / `import_document` call writes new versions of the `documents`, `document_meta`, `chunks`, and `document_items` tables. Ingesting many documents in a loop therefore creates a table version per document. Use `import_documents()` to write the whole batch in a single version per table:
```python
from haiku.rag.client import DocumentImport
imports = []
for path in paths: # paths: list[Path]
docling_doc = await client.convert(path)
chunks = await client.chunk(docling_doc)
imports.append(
DocumentImport(
docling_document=docling_doc,
chunks=chunks,
uri=path.absolute().as_uri(),
metadata={"source": "external-pipeline"},
)
)
docs = await client.import_documents(imports)
```
Chunks without embeddings are embedded automatically. The import is all-or-nothing: if any document fails, all tables are restored to their pre-batch state.
See [Custom Processing Pipelines](custom-pipelines.md) for building pipelines with `convert()`, `chunk()`, and `embed_chunks()`.
## Maintenance
Run maintenance to optimize storage and prune old table versions:
```python
await client.vacuum()
```
This compacts tables and removes historical versions to keep disk usage in check. Its safe to run anytime, for example after bulk imports or periodically in longrunning apps.
### Tags
Tag the current database state and restore it later, for example after an ingestion run. A tag covers all five tables and is created from a single version snapshot. Create tags with other writers stopped: the snapshot is coordinated within one process only, and a writer in another process can commit between the per-table reads.
```python
await client.store.create_tag("release-1")
tags = await client.store.list_tags()
for name, info in tags.items():
print(name, info.tables, info.complete)
```
`restore_tag` brings the live database back to a tagged state. It creates a complete safety tag for the current state before changing any table and returns its name:
```python
safety_tag = await client.store.restore_tag("release-1")
```
Restore is a maintenance operation: stop all other writers first. A tag present on only some tables is partial; `list_tags` reports it via `missing_tables`, and partial tags can be deleted but never restored.
Delete tags you no longer need. Vacuum retains the oldest tagged version and everything newer:
```python
await client.store.delete_tag("release-1")
```
### Rebuilding the Database
```python
from haiku.rag.client import RebuildMode
# Full rebuild (default) - re-converts from source files, re-chunks, re-embeds
async for doc_id in client.rebuild_database():
print(f"Processed document {doc_id}")
# Re-chunk from stored content (no source file access)
async for doc_id in client.rebuild_database(mode=RebuildMode.RECHUNK):
print(f"Processed document {doc_id}")
# Only regenerate embeddings (fastest, keeps existing chunks)
async for doc_id in client.rebuild_database(mode=RebuildMode.EMBED_ONLY):
print(f"Processed document {doc_id}")
# Add VLM picture descriptions to an existing database. Runs the VLM
# over already-stored picture bytes, patches descriptions into the
# docling blob, then re-chunks + re-embeds. Requires
# processing.pictures='description' in the config.
async for doc_id in client.rebuild_database(mode=RebuildMode.DESCRIPTIONS):
print(f"Described pictures in {doc_id}")
```
**Rebuild modes:**
- `RebuildMode.FULL` - Re-convert from source files, re-chunk, re-embed (default)
- `RebuildMode.RECHUNK` - Re-chunk from existing document content, re-embed
- `RebuildMode.EMBED_ONLY` - Keep existing chunks, only regenerate embeddings
- `RebuildMode.TITLE_ONLY` - Generate titles for untitled documents (no re-chunking or re-embedding)
- `RebuildMode.DESCRIPTIONS` - Run the VLM over picture bytes already stored on `document_items.picture_data`, patch descriptions into the docling blob, re-chunk + re-embed. Skips the docling parse entirely. Idempotent: pictures already carrying `meta.description.text` are not re-described, so the operation is safe to re-run.
### Generating Titles
Generate a title for an existing document on demand:
```python
title = await client.generate_title(doc)
if title:
await client.update_document(document_id=doc.id, title=title)
```
Uses the same two-tier approach as automatic ingestion: structural extraction from DoclingDocument metadata first, with LLM fallback via `processing.title_model`. Unlike ingestion, this method does not catch exceptions. If the LLM call fails, the error propagates.
To batch-generate titles for all untitled documents, use `RebuildMode.TITLE_ONLY`:
```python
async for doc_id in client.rebuild_database(mode=RebuildMode.TITLE_ONLY):
print(f"Generated title for {doc_id}")
```
See [Automatic Title Generation](configuration/processing.md#automatic-title-generation) for configuration details.
### Atomic Writes and Rollback
Document create, update, and delete operations take a snapshot of table versions before any write and automatically roll back to that snapshot if something fails (for example, during chunking or embedding). This restores the `documents`, `document_meta`, `chunks`, and `document_items` tables to their preoperation state using LanceDBs table versioning. These writes are serialized under a single lock, so the rollback is safe under concurrent ingester workers.
- Applies to: `create_document(...)`, `create_document_from_source(...)`, `update_document(...)`, `delete_document(...)` (including the `parent_uri` cascade), and internal rebuild/update flows.
- Scope: Document rows, their mutable attributes, and all associated chunks and items are rolled back together.
- Vacuum: Running `vacuum()` later prunes old versions for disk efficiency. Rollbacks occur immediately during the failing operation and are not impacted.
See also: [Agents](agents.md) for details on the QA agent and the multiagent research workflow.

View file

@ -1,130 +0,0 @@
# Remote Processing
`haiku.rag` can use [docling-serve](https://github.com/docling-project/docling-serve) for remote document processing and chunking, offloading resource-intensive operations to a dedicated service.
## Overview
docling-serve is a REST API service that provides:
- Document conversion (PDF, DOCX, PPTX, images, etc.)
- Intelligent chunking with structure preservation
- OCR capabilities for scanned documents
- Table and figure extraction
## When to Use docling-serve
**Use local processing (default) when:**
- Working with small to medium document volumes
- Running on development machines
- Want zero external dependencies
- Processing simple document formats
**Use docling-serve when:**
- Processing large volumes of documents
- Working with complex PDFs requiring OCR
- Running in production environments
- Separating compute-intensive tasks
- Scaling document processing independently
## Setup
haiku.rag is tested against docling-serve 1.25.0.
### Docker Compose (Recommended)
The slim Docker image with docker-compose is the recommended setup. See `examples/docker/docker-compose.yml` for a complete configuration that includes both services.
### Running docling-serve Manually
See the [official docling-serve repository](https://github.com/docling-project/docling-serve) for installation options. The quickest way is using Docker:
```bash
docker run -p 5001:5001 quay.io/docling-project/docling-serve
```
To enable the web UI for debugging:
```bash
docker run -p 5001:5001 -e DOCLING_SERVE_ENABLE_UI=true quay.io/docling-project/docling-serve
```
### Configuration
Configure haiku.rag to use docling-serve. See the [Document Processing](configuration/processing.md) guide for all available options.
```yaml
# haiku.rag.yaml
processing:
converter: docling-serve # Use remote conversion
chunker: docling-serve # Use remote chunking
providers:
docling_serve:
base_url: http://localhost:5001
api_key: "" # Optional API key for authentication
timeout: 300 # Per-request timeout in seconds
```
For converter / chunker config options (chunking strategy, tokenizer,
OCR, table handling, picture description), see
[Document Processing](configuration/processing.md). The configuration is
identical between `docling-local` and `docling-serve` modes — this page
covers only what's specific to running docling-serve as a separate
service.
## VLM picture description with docling-serve
When `processing.pictures = "description"` and `converter: docling-serve`,
the VLM API calls are made by the docling-serve container, not by
haiku.rag. Two deployment caveats:
### Enable remote services
docling-serve blocks outbound calls by default. Enable them by setting
`DOCLING_SERVE_ENABLE_REMOTE_SERVICES=true` on the container:
```bash
docker run -p 5001:5001 \
-e DOCLING_SERVE_ENABLE_REMOTE_SERVICES=true \
quay.io/docling-project/docling-serve
```
### Reach host services from inside the container
If your VLM (e.g. Ollama) runs on the host while docling-serve runs in
Docker, set the VLM's `base_url` in
`processing.conversion_options.picture_description.model` to
`http://host.docker.internal:11434` rather than `localhost`. See
[Document Processing → Picture Handling](configuration/processing.md#picture-handling)
for the full config snippet.
## Operational notes
Long-running docling-serve containers see CPU memory grow monotonically
([docling-serve #366](https://github.com/docling-project/docling-serve/issues/366),
[#474](https://github.com/docling-project/docling-serve/issues/474)). The
underlying parser leaks are in core docling
([#2209](https://github.com/docling-project/docling/issues/2209),
[#1343](https://github.com/docling-project/docling/issues/1343)) and affect
docling-local too.
Recommended deployment shape:
- Set `mem_limit` on the docling-serve container (or `resources.limits.memory`
in Kubernetes) at a value comfortably above your largest expected job.
- Combine with `restart: unless-stopped` so the runtime restarts when the
kernel OOM-kills.
- Run multiple docling-serve replicas behind haiku.rag's round-robin
`providers.docling_serve.base_url` list (see
[Document Processing](configuration/processing.md)). A restart of one
replica doesn't stop ingest.
- In haiku.rag, set `processing.split_pages` for large-PDF workloads so each
slice is an independent docling-serve task and the per-task working set
stays bounded.
## Resources
- [docling-serve GitHub](https://github.com/docling-project/docling-serve)
- [docling-serve Documentation](https://github.com/docling-project/docling-serve#readme)

95
docs/server.md Normal file
View file

@ -0,0 +1,95 @@
# Server Mode
The server provides automatic file monitoring and MCP functionality.
## Starting the Server
The `serve` command requires at least one service flag. You can enable file monitoring, MCP server, or both:
### MCP Server Only
```bash
haiku-rag serve --mcp
```
Transport options:
- Default - Streamable HTTP transport on port 8001
- `--stdio` - Standard input/output transport
- `--mcp-port` - Custom port (default: 8001)
### File Monitoring Only
```bash
haiku-rag serve --monitor
```
### Both Services
```bash
haiku-rag serve --monitor --mcp
```
This will start file monitoring and MCP server on port 8001.
## File Monitoring
Configure directories to monitor in your `haiku.rag.yaml`:
```yaml
monitor:
directories:
- /path/to/documents
- /another/path
```
Then start the server:
```bash
haiku-rag serve --monitor
```
### Monitoring Features
- **Startup**: Scans all monitored directories and adds new files
- **File Added/Modified**: Automatically parses and updates documents
- **File Deleted**: Removes corresponding documents from database
### Filtering Files
You can filter which files to monitor using gitignore-style patterns:
```yaml
monitor:
directories:
- /path/to/documents
# Ignore patterns (exclude files)
ignore_patterns:
- "*draft*" # Ignore draft files
- "temp/" # Ignore temp directory
- "**/archive/**" # Ignore archive directories
# Include patterns (whitelist files)
include_patterns:
- "*.md" # Only markdown files
- "**/docs/**" # Files in docs directories
```
**Pattern behavior:**
- Extension filtering is applied first (only supported file types)
- Include patterns create a whitelist (if specified)
- Ignore patterns exclude files
- Both can be combined for fine-grained control
### Supported Formats
The server can parse 40+ file formats including:
- PDF documents
- Microsoft Office (DOCX, XLSX, PPTX)
- HTML and Markdown
- Plain text files
- Code files (Python, JavaScript, etc.)
- Images (processed via OCR)
- And more...
URLs are also supported for web content.

View file

@ -1,53 +0,0 @@
.haiku-rag-hero {
padding: 2rem 0 2.5rem;
}
.haiku-rag-hero__inner {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2.5rem;
align-items: center;
}
@media (max-width: 60em) {
.haiku-rag-hero__inner {
grid-template-columns: 1fr;
gap: 1.5rem;
}
}
.haiku-rag-hero__title {
font-family: var(--md-code-font, monospace);
font-size: 4.5rem;
font-weight: 700;
line-height: 1;
margin: 0 0 0.75rem;
color: var(--md-primary-fg-color);
letter-spacing: -0.02em;
}
.haiku-rag-hero__tagline {
font-size: 1.15rem;
line-height: 1.5;
color: var(--md-default-fg-color--light);
margin: 0 0 1.5rem;
}
.haiku-rag-hero__actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.haiku-rag-hero__media img,
.haiku-rag-hero__media video {
display: block;
width: 100%;
height: auto;
border-radius: 0.5rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
}
body:has(.haiku-rag-hero) .md-main {
display: none;
}

View file

@ -1,66 +0,0 @@
# Toolsets
For agent integrations, use the native Pydantic AI [capabilities](capabilities/index.md). This page documents the lower-level toolsets used by other haiku.rag surfaces.
For lower-level access, `haiku.rag.tools` provides individual `FunctionToolset` factories used across haiku.rag.
## Low-Level Toolsets
For advanced use cases, individual toolset factories are available in `haiku.rag.tools` and can be reused to build custom agents.
### RAGDeps Protocol
All toolsets use the `RAGDeps` protocol for dependency injection:
```python
from haiku.rag.tools import RAGDeps
class MyDeps:
def __init__(self, client: HaikuRAG):
self.client = client
```
### Search Toolset
`create_search_toolset()` provides hybrid search with context expansion.
```python
from haiku.rag.tools import create_search_toolset
search = create_search_toolset(config)
```
| Parameter | Default | Description |
|-----------|---------|-------------|
| `config` | required | `AppConfig` |
| `expand_context` | `True` | Expand results with surrounding chunks |
| `base_filter` | `None` | SQL WHERE clause applied to all searches |
| `tool_name` | `"search"` | Name of the tool exposed to the agent |
| `on_results` | `None` | Callback `(list[SearchResult]) -> None` invoked with results |
### Document Toolset
`create_document_toolset()` provides document browsing and retrieval.
```python
from haiku.rag.tools import create_document_toolset
docs = create_document_toolset(config)
```
| Parameter | Default | Description |
|-----------|---------|-------------|
| `config` | required | `AppConfig` |
| `base_filter` | `None` | SQL WHERE clause for list operations |
**Tools:**
- `list_documents(page?)` — Paginated document listing (50 per page).
- `get_document(query)` — Retrieve a document by title or URI.
- `summarize_document(query)` — Generate an LLM summary of a document's content.
## Filter Helpers
`haiku.rag.tools.filters` provides utilities for building SQL filters:
- **`build_multi_document_filter(document_names)`** — Combines multiple document name filters with OR logic. Matches against both `uri` and `title`, case-insensitive.

View file

@ -1,124 +0,0 @@
# Tuning
How to adjust haiku.rag's pipeline for better retrieval and answer quality. For individual setting definitions and defaults, see [Configuration](configuration/index.md).
For ingester-side tuning (worker count, lease TTL and heartbeat, retry policy, backpressure, circuit breakers), see [Ingester → Workers and retry](ingester.md#workers-and-retry).
## Pipeline Overview
Documents flow through: **chunking → embedding → hybrid search (vector + FTS) → reranking → context expansion → LLM generation**. Retrieval tuning (chunking through reranking) is the highest-leverage stage. If the LLM never sees the right chunks, no prompt or model change will help.
## Tuning Retrieval
### Chunking
`chunk_size` controls the granularity of retrieval. Smaller chunks match queries more precisely but carry less context each. Larger chunks provide more surrounding information but dilute relevance signals. See [Processing](configuration/processing.md#chunk-size) for configuration.
`chunker_type` selects between `hybrid` (default) and `hierarchical` chunking. Hierarchical chunking preserves the document's heading structure and works better for deeply nested or structured content. See [Chunking Strategies](configuration/processing.md#chunking-strategies).
### Embedding Model
Larger embedding models produce better representations at the cost of slower indexing and more storage. The choice of embedding model has a larger impact on retrieval quality than most other settings. See [Providers](configuration/providers.md) for available options and [Benchmarks](benchmarks.md) for real comparisons across models.
### Reranking
When configured, a cross-encoder reranker re-scores 10x the requested candidates and returns the top results. This adds latency but improves precision. See [Search Settings](configuration/qa.md#search-settings) for how reranking integrates with search.
### Search Settings
`limit` controls how many results reach the LLM. More candidates improve recall but increase token usage. See [Search Settings](configuration/qa.md#search-settings).
Context expansion is automatic and section-aware. Search results are expanded to include surrounding content from the same document section. For structured documents, expansion stays within section boundaries and filters noise (footnotes, page headers). For unstructured documents, expansion grows outward until the character budget is filled. `max_context_chars` caps expansion to prevent context bloat.
## Tuning Generation
Model and temperature selection affect answer quality directly. See [Providers](configuration/providers.md#model-settings) for options.
`domain_preamble` prepends domain context to the RAG and analysis capability instructions. Use it to describe what the knowledge base contains and clarify domain-specific terminology. See [Prompt Customization](configuration/prompts.md).
## What Requires a Rebuild
| Change | Rebuild required? |
|--------|:-:|
| `chunk_size`, `chunker_type`, `chunking_merge_peers` | Yes (run `haiku-rag rebuild`) |
| Embedding model | Yes (run `haiku-rag rebuild`) |
| Search settings, reranking, prompts | No |
## Inspector
The inspector shows what your model would actually receive for a given query. Run it against your database and step through the same hybrid search, context expansion, and chunk previews the RAG capability uses at runtime. Press `c` on a chunk and you see the exact context the LLM would get back from a search hit.
```bash
haiku-rag inspect
haiku-rag inspect --db /path/to/database.lancedb
```
!!! note
Requires the `tui` extra: `pip install haiku.rag-slim[tui]` (included in the full `haiku.rag` package).
### Layout
Three panels:
- **Documents** (left): every document in the database.
- **Chunks** (top right): chunks for the selected document.
- **Detail view** (bottom right): full content and metadata.
![Inspector search](img/inspector-search.svg)
### Keys
| Key | Action |
|-----|--------|
| `Tab` | Cycle panels |
| `↑` / `↓` | Navigate lists |
| `/` | Search modal |
| `c` | Context expansion modal (the chunk plus what the agent would see around it) |
| `v` | Visual grounding modal (chunk highlighted on the page) |
| `q` | Quit |
Mouse: click to select, scroll to view content.
### Search
Press `/` to open the search modal. Type a query and press `Enter`. The left panel lists results with relevance scores like `[0.95] content preview`. The right panel shows the full chunk and its metadata. `↑` / `↓` navigates results, `Enter` jumps to the document and chunk, `Esc` closes the modal. Search uses the same hybrid (vector + full-text) retrieval the RAG capability uses.
### Context expansion (`c`)
Press `c` on a chunk to see the expanded context that would be fed to the RAG capability. This is where you find out whether your `chunk_size`, `chunker_type`, and `max_context_chars` settings actually deliver the surrounding content the model needs. The modal shows:
- The expanded text. Section-aware expansion stays within section boundaries on structured documents and fills `max_context_chars` outward on unstructured ones.
- Source document, content type, and relevance score.
- Filtered noise. Footnotes, page headers and footers are excluded from structured documents.
If `qa.model.vision = true` is set, the modal also renders the picture bytes attached to that chunk, so you see exactly what the vision model would receive.
### Visual grounding (`v`)
Press `v` to highlight the chunk's bounding box on its page image. Useful for verifying chunk boundaries and seeing how Docling carved up the document.
- `←` / `→` to navigate pages when a chunk spans multiple pages.
- `Esc` closes the modal.
![Visual grounding modal](img/tui-visual-grounding.png)
Requirements: documents must have page images (default for PDFs), and the terminal must support inline images (iTerm2, WezTerm, Kitty). Plain-text documents added via `haiku-rag add` don't have visual grounding.
You can also visualize a chunk from the CLI without launching the TUI: `haiku-rag visualize <chunk_id>`.
## Measuring Changes
For systematic measurement, use the `evaluations/` workspace which provides retrieval metrics (MRR, MAP) and LLM-judged QA accuracy via `pydantic-evals`:
```bash
# Run retrieval + QA benchmarks
evaluations run <dataset>
# Skip database rebuild when only changing search/reranking/prompt settings
evaluations run <dataset> --skip-db
# Limit test cases for faster iteration
evaluations run <dataset> --limit 50
```
See [Benchmarks](benchmarks.md) for dataset details, methodology, and baseline results.

View file

@ -1,86 +1,218 @@
# Quickstart
# Tutorial
Install haiku.rag, index a document, and chat with it.
This tutorial provides quickstart instructions for getting familiar with `haiku.rag`. This tutorial is intended for people who are familiar with command line and Python, but not different AI ecosystem tools.
## Install
The tutorial covers:
```bash
- RAG and embeddings basics
- Installing `haiku.rag` Python package
- Configuring `haiku.rag` with YAML
- Adding and retrieving items
- Inspecting the database
The tutorial uses OpenAI API service - no local installation needed and will work on computers with any amount of RAM and GPU. The OpenAI API is pay-as-you-go, so you need to top it up with at least ~$5 when creating the API key.
## Introduction
Retrieval-Augmented Generation (RAG) lets you give AI models access to your own documents and data. Instead of relying solely on the model's training data, RAG finds relevant information from your documents and includes it in the AI's responses.
`haiku.rag` handles the mechanics: it converts your documents into searchable embeddings, stores them locally, and retrieves relevant chunks when you ask questions. You provide the documents and questions, and it coordinates between the embedding service (like OpenAI) and the AI model to give you accurate, grounded answers.
## Setup
First, [get an OpenAI API key](https://platform.openai.com/api-keys).
Install `haiku.rag` Python package using [uv](https://docs.astral.sh/uv/getting-started/installation/) or your favourite Python package manager:
```shell
# Python 3.12+ needed
uv pip install haiku.rag
```
You also need [Ollama](https://ollama.com/) for the default embedding and answering models:
Configure haiku.rag to use OpenAI. Create a `haiku.rag.yaml` file:
```bash
ollama pull qwen3-embedding:4b
ollama pull qwen3.8
```yaml
embeddings:
provider: openai
model: text-embedding-3-small # or text-embedding-3-large
vector_dim: 1536
qa:
provider: openai
model: gpt-4o-mini # or gpt-4o, gpt-4, etc.
```
!!! note "Prefer OpenAI?"
Drop this into a `haiku.rag.yaml` next to where you'll run the CLI:
```yaml
embeddings:
model:
provider: openai
name: text-embedding-3-small
vector_dim: 1536
qa:
model:
provider: openai
name: gpt-4o-mini
```
Then `export OPENAI_API_KEY="sk-..."` and continue with the rest of this page. Any provider Pydantic AI supports works the same way. See [Providers](configuration/providers.md).
## Initialize
Set your OpenAI API key as an environment variable (API keys should not be stored in the YAML file):
```bash
haiku-rag init
export OPENAI_API_KEY="<your OpenAI API key>"
```
This creates a LanceDB database in your platform's user directory. Pass `--db` to any subcommand to use a different path:
For the list of available OpenAI models and their vector dimensions, see the [OpenAI documentation](https://platform.openai.com/docs/guides/embeddings).
```bash
haiku-rag init --db /tmp/test.lancedb
See [Configuration](configuration.md) for all available options.
## Adding the first documents
Now you can add some pieces of text in the database:
```shell
haiku-rag add "Python is the best programming language in the world, because it is flexible, with robust ecosystem, open source licensing and thousands of contributors"
haiku-rag add "JavaScript is a popular programming language, but has a lot of warts"
haiku-rag add "PHP is a bad programming language, because of spotted security history, horrible syntax and declining popularity"
```
## Add a document
What will happen
Add a file, a URL, or a whole folder:
- The piece of text is send to OpenAI `/embeddings` API service
- OpenAI translates the free form text to RAG embedding vectors needed for the retrieval
- The vector values will be stored in a local database
```bash
haiku-rag add-src https://arxiv.org/pdf/2408.09134
haiku-rag add-src ~/Documents/papers/
Now you can view your [LanceDB](https://lancedb.com/) database, and the embeddings it is configured for:
```shell
haiku-rag info
```
Or paste text inline:
You should get the back the information:
```bash
haiku-rag add "Yiorgis wrote haiku.rag in 2025."
```
haiku.rag database info
path: /Users/moo/Library/Application Support/haiku.rag/haiku.rag.lancedb
haiku.rag version (db): 0.13.3
embeddings: openai/text-embedding-3-small (dim: 1536)
documents: 3
versions (documents): 3
versions (chunks): 3
──────────────────────────────────────────────────────────────────────────────────
Versions
haiku.rag: 0.13.3
lancedb: 0.25.2
docling: 2.58.0
```
Each `add-src` call converts the file with Docling, splits it into chunks, embeds them, and writes everything to LanceDB. Run `haiku-rag list` to see what you've added, `haiku-rag info` for a database summary.
## Asking questions and retrieving information
## Chat
Now we can use OpenAI LLMs to retrieve information from our embeddings database.
```bash
haiku-rag chat
In this example, we connect to a remote OpenAI API.
Behind the scenes [pydantic-ai](https://ai.pydantic.dev/) query is created
using `OpenAIChatModel.request()`.
The easiest way to do this is `ask` CLI command:
```shell
haiku-rag ask "What is the best programming language in the world"
```
Ask a question. The agent searches your documents, expands context around the hits, and answers with citations pointing back to the source page and section. Citations are expandable, with visual grounding so you can see the chunk highlighted on the original page. Follow-ups continue within the same session. Start a new session when you switch topics.
```
Question: What is the best programming language in the world
You can also ask a single question directly from the CLI without launching the TUI:
```bash
haiku-rag ask "Who wrote haiku.rag?"
Answer:
According to the document, Python is considered the best programming language in the world due to its flexibility, robust ecosystem, open-source licensing, and thousands of contributors.
```
## Where to go next
## Programmatic interaction in Python
- [Chat](chat.md): sessions, citations, and the full TUI.
- [CLI reference](cli.md): every command.
- [Python API](python.md): use haiku.rag in your own code.
- [Capabilities](capabilities/index.md): native RAG and analysis components used by the client.
- [Tuning](tuning.md): better retrieval.
- [Configuration](configuration/index.md): every setting.
You can interact with Haiku RAG from Python in a similar manner as you can from the command line. Here we use Haiku RAG with the interactive Python command prompt (REPL).
First we need to install `ipython`, as built-in Python REPL does not support async blocks.
```shell
uv pip install ipython
```
Run IPython:
```shell
ipython
```
Then copy paste in the snippet (you can use [%cpaste](https://ipythonbook.com/magic/cpaste.html) command):
```python
import sys
import logging
from haiku.rag.client import HaikuRAG
# Increase logging verbosity so we see what happens behind the scenes,
# and check that the logger works
logging.basicConfig(
stream=sys.stdout,
level=logging.DEBUG,
format="%(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
logger.debug("AGI here we come")
# Uses LanceDB database from default storage location
async with HaikuRAG() as client:
answer = await client.ask("What is the best programming language in the world?")
print(answer)
```
You should see:
```
2025-10-18 17:05:49,611 - DEBUG - HTTP Response: POST https://api.openai.com/v1/chat/completions "200 OK" Headers({'date': 'Sat, 18 Oct 2025 14:05:49 GMT', 'content-type': 'application/json', 'transfer-encoding': 'chunked', 'connection': 'keep-alive', 'access-control-expose-headers': 'X-Request-ID', 'openai-organization': 'xxx', 'openai-processing-ms': '788', 'openai-project': 'xxx', 'openai-version': '2020-10-01', 'x-envoy-upstream-service-time': '1050', 'x-ratelimit-limit-requests': '10000', 'x-ratelimit-limit-tokens': '200000', 'x-ratelimit-remaining-requests': '9998', 'x-ratelimit-remaining-tokens': '199603', 'x-ratelimit-reset-requests': '14.981s', 'x-ratelimit-reset-tokens': '119ms', 'x-request-id': 'req_9651a3691a144dd388e97066ad67a49c', 'x-openai-proxy-wasm': 'v0.1', 'cf-cache-status': 'DYNAMIC', 'strict-transport-security': 'max-age=31536000; includeSubDomains; preload', 'x-content-type-options': 'nosniff', 'server': 'cloudflare', 'cf-ray': '990897b6f8d270d7-ARN', 'content-encoding': 'gzip', 'alt-svc': 'h3=":443"; ma=86400'})
2025-10-18 17:05:49,611 - DEBUG - request_id: req_9651a3691a144dd388e97066ad67a49c
According to the document, Python is considered the best programming language in the world due to its flexibility, robust ecosystem, open-source licensing, and support from thousands of contributors.
```
## Complex documents
Haiku RAG can also handle types beyond plain text, including PDF, DOCX, HTML, and 40+ other file formats.
Here we add research papers about Python from [arxiv](https://arxiv.org/search/?query=python&searchtype=all&source=header) using URL retriever.
```shell
# Better Python Programming for all: With the focus on Maintainability
haiku-rag add-src --meta collection="Interesting Python papers" "https://arxiv.org/pdf/2408.09134"
# Interoperability From OpenTelemetry to Kieker: Demonstrated as Export from the Astronomy Shop
haiku-rag add-src --meta collection="Interesting Python papers" "https://arxiv.org/pdf/2510.11179"
```
Then we can query this:
```shell
haiku-rag ask "Who wrote a paper about OpenTelemetry interoperability, and what was his take"
```
We should get something along the lines:
```
Answer:
David Georg Reichelt from Lancaster University wrote a paper titled "Interoperability From OpenTelemetry to Kieker: Demonstrated as Export from the Astronomy Shop." In his work, he indicates that there is a structural difference between Kiekers synchronous traces and OpenTelemetrys asynchronous traces, leading to limited compatibility between the two systems. This highlights the challenges of interoperability in observability frameworks.
```
We can also add offline files, like PDFs. Here we add a local file to ensure OpenAI does not cheat - a file we know that should not be very well known in Internet:
```shell
# This static file is supplied in haiku.rag repo
haiku-rag add-src "examples/samples/PyCon Finland 2025 Schedule.html"
```
And then:
```shell
haiku-rag ask "Who were presenting talks in Pycon Finland 2025? Can you give at least five different people."
```
```
The following people are presenting talks at PyCon Finland 2025:
1 Jeremy Mayeres - Talk: The Limits of Imagination: An Open Source Journey
2 Aroma Rodrigues - Talk: Python and Rust, a Perfect Pairing
3 Andreas Jung - Talk: Guillotina Volto: A New Backend for Volto
4 Daniel Vahla - Talk: Experiences with AI in Software Projects
5 Andreas Jung (also presenting another talk) - Talk: Debugging Python
```
## Configuration
See [Configuration page](./configuration.md) for complete documentation on YAML configuration and all available options.

View file

@ -1,4 +1,4 @@
# haiku.rag - Evaluations
# Haiku RAG - Evaluations
Internal benchmarking and evaluation scripts for haiku.rag.
@ -6,99 +6,6 @@ This package is not published to PyPI and is only used for development and testi
## Overview
Contains evaluation scripts for benchmarking RAG retrieval and QA performance. Available datasets:
- HotpotQA (`hotpotqa`) — multi-hop QA over Wikipedia paragraphs (distractor validation split, 7,405 questions, two gold documents per question)
- MTRAG ClapNQ (`mtrag_clapnq`, `mtrag_clapnq_rewrite`) — IBM's multi-turn RAG benchmark, ClapNQ (Wikipedia) domain: 183,408 passages, 208 retrieval queries with binary qrels, 224 generation tasks. The base key retrieves with the raw last user turn; the `_rewrite` variant uses the human standalone rewrites (both share one database). Retrieval reports Recall@5/@10, nDCG@5/@10, and MAP against IBM's published setup. QA replays each task's reference conversation prefix as message history and answers the final turn; the judge sees the conversation as a transcript, citation MAP is scored only on turns with gold passages, and refusal precision/recall is reported against the answerability labels. Generation scores are internal (our judge and rubric), not comparable with IBM's published generation numbers. The `mtrag_clapnq_live` key replays whole conversations (one case per conversation, `--limit` counts conversations) through a single capability session, carrying the model's own answers and tool history across turns; it reports the same outcomes per turn plus micro (per-turn) and macro (per-conversation) aggregates.
- FRAMES (`frames`) — multi-hop QA (822 questions, 2-23 gold Wikipedia articles per question; 2 of the original 824 questions are excluded because a linked article has been deleted from Wikipedia). The corpus is the union of the 2,521 linked articles, fetched from the Wikipedia REST API at current revision (revision id and fetch date recorded in the article cache) with navigation chrome stripped. There is no official FRAMES evaluation setup; numbers here correspond to the paper's multi-step retrieval setting (fixed corpus, agentic retrieval, judged accuracy) and are not comparable to its closed-book, oracle-prompt, or web-search settings. Answers were authored against ~2024 revisions and may have drifted with article content.
- OpenRAG Bench, two variants:
- `orb_text` — text embedder (`qwen3-embedding:4b`, 2560-dim) with VLM picture descriptions baked into chunk content at ingest. Use for text-only retrieval/QA against figure-rich corpora.
- `orb_multimodal` — multimodal embedder (`qwen3-vl-embedding-8b`, 4096-dim) with picture vectors in the same space as text. Use for cross-modal retrieval (text-as-query → figure hits, image-as-query) and vision QA where the figure itself is the answer.
## Usage
After installing the package, you can run evaluations using the `evaluations` command:
```bash
# Run retrieval + QA benchmarks
evaluations run hotpotqa
evaluations run orb_text
# Use a custom config file
evaluations run hotpotqa --config /path/to/haiku.rag.yaml
# Override the database path
evaluations run hotpotqa --db /path/to/custom.lancedb
# Skip database population and run only benchmarks
evaluations run hotpotqa --skip-db
# Skip specific benchmarks
evaluations run hotpotqa --skip-retrieval
evaluations run hotpotqa --skip-qa
# Limit the number of test cases
evaluations run hotpotqa --limit 100
```
### Choosing the target
`evaluations run` benchmarks `--target rag-capability` by default. Use
`--target analysis-capability` to benchmark the analysis capability against the same
datasets and judge:
```bash
evaluations run hotpotqa --target rag-capability
evaluations run hotpotqa --target analysis-capability --capability-model ollama:qwen3.8
```
`--capability-model "provider:name"` overrides the capability model independently from
the judge (defaults to `qa.model`, or `analysis.model` when set for the
analysis-capability target). A citation retrieval metric (`cited_map`) is computed
alongside QA accuracy from the URIs the capability registered via the `cite` tool.
### Debugging runs in Logfire
With `LOGFIRE_TOKEN` set, runs ship spans under `service_name = 'evals'`. The
`debug-evals` skill in `.claude/skills/` turns these into ready-made Logfire
queries (recent runs, per-case pass rate and `cited_map`, failing and slowest
cases) for use from Claude Code.
### Pre-built Databases
Download pre-built evaluation databases from HuggingFace:
```bash
evaluations download hotpotqa
evaluations download all
evaluations download hotpotqa --force
```
Upload databases (maintainer only):
```bash
evaluations upload hotpotqa
evaluations upload all
```
## Database Storage
By default, evaluation databases are stored in the haiku.rag data directory:
- **Linux**: `~/.local/share/haiku.rag/evaluations/dbs/`
- **macOS**: `~/Library/Application Support/haiku.rag/evaluations/dbs/`
- **Windows**: `C:/Users/<USER>/AppData/Roaming/haiku.rag/evaluations/dbs/`
You can override this with the `--db` option.
### Evaluating over Multiple Databases
With [`lancedb.databases`](https://ggozad.github.io/haiku.rag/configuration/storage/#multiple-databases) configured, `evaluations run <dataset> --skip-db` benchmarks the full set. Retrieval, QA, and live conversations preserve the database name on results and citations. A configured set of one follows the same path and retains its name.
Population writes one database and therefore requires `--db`:
```bash
evaluations run hotpotqa --db /path/to/one.lancedb # populate, then benchmark
evaluations run hotpotqa --skip-db # benchmark the configured set
```
`--db` overrides the configured set for both population and benchmarks.
Contains evaluation scripts for benchmarking RAG performance using datasets like:
- RepliQA
- WiX

View file

@ -1,52 +0,0 @@
# Reference config for the `frames` evaluation database.
# FRAMES (google/frames-benchmark): 824 multi-hop questions over a corpus of
# the ~2.5k Wikipedia articles linked per question, fetched at current
# revision (revid + fetch date recorded in the article cache).
# Run: evaluations run frames --config configs/frames.yaml
# base_url uses the `vllm` host serving each model over an OpenAI-compatible API.
environment: development
storage:
auto_vacuum: false
embeddings:
model:
provider: openai
name: qwen3-embedding-4b
vector_dim: 2560
base_url: http://vllm:11431/v1
reranking:
model:
provider: vllm
name: Qwen/Qwen3-Reranker-4B
base_url: http://vllm:11455
analysis:
# Bounds per-execution sandbox output so accumulated code returns cannot
# outgrow the model's input budget.
max_output_chars: 20000
qa:
model:
provider: openai
name: gemma4-26b
base_url: http://vllm:11432/v1
# vLLM reserves max_tokens out of max_model_len; a large value starves
# the input budget and 400s long agentic contexts.
max_tokens: 8192
evaluations:
judge:
provider: openai
name: Inferact/Qwen3.8-27B-NVFP4
base_url: http://vllm:11439/v1
temperature: 0.6
max_tokens: 16384
extra_body:
top_p: 0.95
top_k: 20
min_p: 0
chat_template_kwargs:
reasoning_effort: low

View file

@ -1,37 +0,0 @@
# Reference config for the `hotpotqa` pre-built evaluation database.
# HotpotQA (distractor validation split) multi-hop QA over wiki paragraphs.
# Run: evaluations run hotpotqa --config configs/hotpotqa.yaml
# base_url uses the `vllm` host serving each model over an OpenAI-compatible API.
environment: development
storage:
auto_vacuum: false
embeddings:
model:
provider: openai
name: qwen3-embedding-4b
vector_dim: 2560
base_url: http://vllm:11431/v1
qa:
model:
provider: openai
name: gemma4-26b
base_url: http://vllm:11432/v1
max_tokens: 49152
evaluations:
judge:
provider: openai
name: Inferact/Qwen3.8-27B-NVFP4
base_url: http://vllm:11439/v1
temperature: 0.6
max_tokens: 16384
extra_body:
top_p: 0.95
top_k: 20
min_p: 0
chat_template_kwargs:
reasoning_effort: low

View file

@ -1,56 +0,0 @@
# Reference config for the `mtrag_clapnq` pre-built evaluation database.
# IBM MTRAG, ClapNQ (Wikipedia) domain: multi-turn retrieval and QA over
# 183,408 passages. Also serves mtrag_clapnq_rewrite, mtrag_clapnq_live and
# mtrag_clapnq_live_uncompacted.
# Run: evaluations run mtrag_clapnq --config configs/mtrag_clapnq.yaml
# base_url uses the `vllm` host serving each model over an OpenAI-compatible API.
# The corpus is text-only: no multimodal embedder, no vision paths. This eval
# cannot exercise image or vision turn-boundary behavior.
environment: development
storage:
auto_vacuum: false
embeddings:
model:
provider: openai
name: qwen3-embedding-4b
vector_dim: 2560
base_url: http://vllm:11431/v1
reranking:
model:
provider: vllm
name: Qwen/Qwen3-Reranker-4B
base_url: http://vllm:11455
qa:
model:
provider: openai
name: RedHatAI/Muse-Glimmer-30B-NVFP4
base_url: http://vllm:11450/v1
# vLLM enforces input + max_tokens <= max_model_len, so a large output
# budget silently shrinks the input budget. MTRAG answers are sentences.
max_tokens: 8192
extra_body:
chat_template_kwargs:
# Part of the measured baseline. vLLM's reasoning parser consumes
# enable_thinking before the chat template sees it; reasoning_strength
# is the knob Muse Glimmer templates honour, and a template that
# defaults it to low silently changes search behavior.
reasoning_strength: high
evaluations:
judge:
provider: openai
name: Inferact/Qwen3.8-27B-NVFP4
base_url: http://vllm:11439/v1
temperature: 0.6
max_tokens: 16384
extra_body:
top_p: 0.95
top_k: 20
min_p: 0
chat_template_kwargs:
reasoning_effort: low

View file

@ -1,41 +0,0 @@
# Reference config for the `orb_multimodal` pre-built evaluation database.
# OpenRAG Bench with a multimodal embedder; picture vectors share the text space.
# Run: evaluations run orb_multimodal --skip-db --config configs/orb_multimodal.yaml
# base_url uses the `vllm` host serving each model over an OpenAI-compatible API.
environment: development
storage:
auto_vacuum: false
embeddings:
model:
provider: vllm
name: qwen3-embedding-v-8b
vector_dim: 4096
multimodal: true
base_url: http://vllm:11433/v1
reranking:
model: null
qa:
model:
provider: openai
name: gemma4-26b
base_url: http://vllm:11432/v1
vision: true
evaluations:
judge:
provider: openai
name: Inferact/Qwen3.8-27B-NVFP4
base_url: http://vllm:11439/v1
temperature: 0.6
max_tokens: 16384
extra_body:
top_p: 0.95
top_k: 20
min_p: 0
chat_template_kwargs:
reasoning_effort: low

View file

@ -1,42 +0,0 @@
# Reference config for the `orb_multimodal_nemotron` pre-built evaluation database.
# OpenRAG Bench with the nvidia/llama-nemotron-embed-vl-1b-v2 multimodal embedder,
# the embedder behind the published headline benchmark numbers.
# Run: evaluations run orb_multimodal_nemotron --skip-db --config configs/orb_multimodal_nemotron.yaml
# base_url uses the `vllm` host serving each model over an OpenAI-compatible API.
environment: development
storage:
auto_vacuum: false
embeddings:
model:
provider: vllm
name: nvidia/llama-nemotron-embed-vl-1b-v2
vector_dim: 2048
multimodal: true
base_url: http://vllm:11438/v1
reranking:
model: null
qa:
model:
provider: openai
name: gemma4-26b
base_url: http://vllm:11432/v1
vision: true
evaluations:
judge:
provider: openai
name: Inferact/Qwen3.8-27B-NVFP4
base_url: http://vllm:11439/v1
temperature: 0.6
max_tokens: 16384
extra_body:
top_p: 0.95
top_k: 20
min_p: 0
chat_template_kwargs:
reasoning_effort: low

View file

@ -1,43 +0,0 @@
# Reference config for the `orb_text` pre-built evaluation database.
# OpenRAG Bench with a text embedder and VLM picture descriptions baked into chunks.
# Run: evaluations run orb_text --skip-db --config configs/orb_text.yaml
# base_url uses the `vllm` host serving each model over an OpenAI-compatible API.
environment: development
storage:
auto_vacuum: false
embeddings:
model:
provider: openai
name: qwen3-embedding-4b
vector_dim: 2560
base_url: http://vllm:11431/v1
reranking:
model:
provider: vllm
name: Qwen/Qwen3-Reranker-4B
base_url: http://vllm:11455
qa:
model:
provider: openai
name: gemma4-26b
base_url: http://vllm:11432/v1
vision: true
evaluations:
judge:
provider: openai
name: Inferact/Qwen3.8-27B-NVFP4
base_url: http://vllm:11439/v1
temperature: 0.6
max_tokens: 16384
extra_body:
top_p: 0.95
top_k: 20
min_p: 0
chat_template_kwargs:
reasoning_effort: low

View file

@ -1,49 +0,0 @@
# Reference config for the `t2_finqa` pre-built evaluation database.
# T²-RAGBench (FinQA) financial QA, scored by exact numeric match.
# No `evaluations.judge` block: the spec sets `NumberMatchEvaluator`, which
# replaces the evaluator list, so no LLM judge is constructed for this dataset.
# Run: evaluations run t2_finqa --skip-db --target analysis-capability --config configs/t2_finqa.yaml
# base_url uses the `vllm` host serving each model over an OpenAI-compatible API.
environment: development
storage:
auto_vacuum: false
embeddings:
model:
provider: openai
name: qwen3-embedding-4b
vector_dim: 2560
base_url: http://vllm:11431/v1
processing:
converter: docling-local
pictures: none # ~4% of pages carry a figure/chart; dropped as non-essential to the numeric QA
chunking_use_markdown_tables: true
reranking:
model:
provider: vllm
name: Qwen/Qwen3-Reranker-4B
base_url: http://vllm:11455
qa:
model:
provider: openai
name: RedHatAI/Qwen3.6-35B-A3B-NVFP4
base_url: http://vllm:11430/v1
temperature: 0.3
max_tokens: 16384
extra_body:
chat_template_kwargs:
enable_thinking: true
prompts:
domain_preamble: |
Use search() to find the relevant documents. Do not iterate over all of
/documents or read every document's content — that will time out.
For questions with a numeric answer, end your response with a final line
formatted exactly as `ANSWER: <number>`, containing a single number. Keep a
percent sign if the answer is a percentage.

View file

@ -1,106 +0,0 @@
"""Pre-built evaluation databases on HuggingFace."""
import os
import shutil
import tempfile
from pathlib import Path
from huggingface_hub import HfApi, snapshot_download
from rich.console import Console
from evaluations.config import DatasetSpec
console = Console()
HF_REPO_ID = "ggozad/haiku-rag-eval-dbs"
def download_dataset_db(spec: DatasetSpec, force: bool = False) -> None:
"""Fetch one dataset's database from HuggingFace into its local path."""
db = spec.db_path()
if db.exists() and not force:
console.print(
f"[yellow]Skipping {spec.key}: database already exists at {db}[/yellow]"
)
console.print("Use --force to overwrite.")
return
console.print(f"[blue]Downloading {spec.key}...[/blue]")
try:
downloaded_path = snapshot_download(
repo_id=HF_REPO_ID,
repo_type="dataset",
allow_patterns=f"{spec.db_filename}/*",
)
except Exception as e:
console.print(f"[red]Failed to download {spec.key}: {e}[/red]")
return
source_path = Path(downloaded_path) / spec.db_filename
if not source_path.exists():
console.print(f"[red]Database {spec.key} not found in HuggingFace repo.[/red]")
console.print(
f"[yellow]The database may not have been uploaded yet. "
f"Try running 'evaluations build {spec.key}' to create it locally.[/yellow]"
)
return
if db.exists():
shutil.rmtree(db)
db.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(source_path, db)
console.print(f"[green]Downloaded {spec.key} to {db}[/green]")
def upload_dataset_db(spec: DatasetSpec) -> None:
"""Push one dataset's database to HuggingFace (maintainer only).
Uses ``upload_large_folder`` for resumable, parallel transfer important
for the multi-GB ORB databases which would otherwise abort on any transient
network failure under plain ``upload_folder``.
``upload_large_folder`` has no ``path_in_repo`` it ships the contents of
``folder_path`` to the repo root. Stage the db under a temp parent with
hardlinks so the basename becomes the remote path, leaving everything else
at the root undisturbed.
"""
db = spec.db_path()
if not db.exists():
console.print(f"[red]Database not found at {db}[/red]")
return
api = HfApi()
# Wipe the existing remote path so we don't accumulate orphaned files from
# prior uploads. upload_large_folder doesn't accept delete_patterns, so we
# do this as a separate commit. Safe to run if the path is missing.
try:
api.delete_folder(
path_in_repo=spec.db_filename,
repo_id=HF_REPO_ID,
repo_type="dataset",
)
except Exception:
pass
with tempfile.TemporaryDirectory() as staging:
target = Path(staging) / spec.db_filename
target.mkdir()
for src in db.rglob("*"):
if not src.is_file():
continue
dest = target / src.relative_to(db)
dest.parent.mkdir(parents=True, exist_ok=True)
os.link(src, dest)
console.print(f"[blue]Uploading {spec.key} ({db})...[/blue]")
api.upload_large_folder(
folder_path=staging,
repo_id=HF_REPO_ID,
repo_type="dataset",
)
console.print(f"[green]Uploaded {spec.key} to {HF_REPO_ID}[/green]")

View file

@ -1,237 +1,338 @@
import asyncio
from collections.abc import Mapping
from pathlib import Path
from typing import cast
from typing import Any, cast
import logfire
import typer
from dotenv import find_dotenv, load_dotenv
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
from pydantic_evals import Dataset as EvalDataset
from pydantic_evals.evaluators import IsInstance, LLMJudge
from pydantic_evals.reporting import ReportCaseFailure
from rich.console import Console
from rich.progress import Progress
from evaluations.artifacts import download_dataset_db, upload_dataset_db
from evaluations.config import DatasetSpec
from evaluations.population import populate_db
from evaluations.qa import TARGETS, Target, run_live_qa_benchmark, run_qa_benchmark
from evaluations.retrieval import run_retrieval_benchmark
from evaluations.datasets import DATASETS
from evaluations.llm_judge import ANSWER_EQUIVALENCE_RUBRIC
from evaluations.prompts import WIX_SUPPORT_PROMPT
from haiku.rag.client import HaikuRAG
from haiku.rag.config import AppConfig, find_config_file, load_yaml_config
from haiku.rag.config.models import ModelConfig
from haiku.rag.logging import configure_cli_logging
from haiku.rag.telemetry import configure as configure_telemetry
from haiku.rag.utils import parse_model_option
from haiku.rag.qa import get_qa_agent
QA_JUDGE_MODEL = "qwen3"
load_dotenv(find_dotenv(usecwd=True))
# Scrubbing off: eval outputs are financial answers with words like "authorized"
# that trip Logfire's secret scrubber and redact the model's answer text.
configure_telemetry(service_name="evals", scrubbing=False)
logfire.configure(send_to_logfire="if-token-present", service_name="evals")
logfire.instrument_pydantic_ai()
configure_cli_logging()
console = Console()
async def populate_db(spec: DatasetSpec, config: AppConfig) -> None:
spec.db_path.parent.mkdir(parents=True, exist_ok=True)
corpus = spec.document_loader()
if spec.document_limit is not None:
corpus = corpus.select(range(min(spec.document_limit, len(corpus))))
with Progress() as progress:
task = progress.add_task("[green]Populating database...", total=len(corpus))
async with HaikuRAG(spec.db_path, config=config) as rag:
for doc in corpus:
doc_mapping = cast(Mapping[str, Any], doc)
payload = spec.document_mapper(doc_mapping)
if payload is None:
progress.advance(task)
continue
existing = await rag.get_document_by_uri(payload.uri)
if existing is not None:
assert existing.id
chunks = await rag.chunk_repository.get_by_document_id(existing.id)
if chunks:
progress.advance(task)
continue
await rag.document_repository.delete(existing.id)
await rag.create_document(
content=payload.content,
uri=payload.uri,
title=payload.title,
metadata=payload.metadata,
)
progress.advance(task)
async def run_retrieval_benchmark(
spec: DatasetSpec, config: AppConfig
) -> dict[str, float] | None:
if spec.retrieval_loader is None or spec.retrieval_mapper is None:
console.print("Skipping retrieval benchmark; no retrieval config.")
return None
corpus = spec.retrieval_loader()
recall_totals = {
1: 0.0,
3: 0.0,
5: 0.0,
}
success_totals = {
1: 0.0,
3: 0.0,
5: 0.0,
}
total_queries = 0
with Progress() as progress:
task = progress.add_task(
"[blue]Running retrieval benchmark...", total=len(corpus)
)
async with HaikuRAG(spec.db_path, config=config) as rag:
for doc in corpus:
doc_mapping = cast(Mapping[str, Any], doc)
sample = spec.retrieval_mapper(doc_mapping)
if sample is None or sample.skip:
progress.advance(task)
continue
matches = await rag.search(query=sample.question, limit=5)
if not matches:
progress.advance(task)
continue
total_queries += 1
retrieved_uris: list[str] = []
for chunk, _ in matches:
if chunk.document_id is None:
continue
retrieved_doc = await rag.get_document_by_id(chunk.document_id)
if retrieved_doc and retrieved_doc.uri:
retrieved_uris.append(retrieved_doc.uri)
# Compute metrics for each cutoff
for cutoff in (1, 3, 5):
top_k = set(retrieved_uris[:cutoff])
relevant = set(sample.expected_uris)
if relevant:
matched = len(top_k & relevant)
# Recall: fraction of relevant docs retrieved
recall_totals[cutoff] += matched / len(relevant)
# Success: binary - did we get at least one relevant doc?
success_totals[cutoff] += 1.0 if matched > 0 else 0.0
progress.advance(task)
if total_queries == 0:
console.print("No retrieval cases to evaluate.")
return None
recall_at_1 = recall_totals[1] / total_queries
recall_at_3 = recall_totals[3] / total_queries
recall_at_5 = recall_totals[5] / total_queries
success_at_1 = success_totals[1] / total_queries
success_at_3 = success_totals[3] / total_queries
success_at_5 = success_totals[5] / total_queries
console.print("\n=== Retrieval Benchmark Results ===", style="bold cyan")
console.print(f"Total queries: {total_queries}")
console.print("\nRecall@K (fraction of relevant docs retrieved):")
console.print(f" Recall@1: {recall_at_1:.4f}")
console.print(f" Recall@3: {recall_at_3:.4f}")
console.print(f" Recall@5: {recall_at_5:.4f}")
console.print("\nSuccess@K (queries with at least one relevant doc):")
console.print(f" Success@1: {success_at_1:.4f} ({success_at_1 * 100:.1f}%)")
console.print(f" Success@3: {success_at_3:.4f} ({success_at_3 * 100:.1f}%)")
console.print(f" Success@5: {success_at_5:.4f} ({success_at_5 * 100:.1f}%)")
return {
"recall@1": recall_at_1,
"recall@3": recall_at_3,
"recall@5": recall_at_5,
"success@1": success_at_1,
"success@3": success_at_3,
"success@5": success_at_5,
}
async def run_qa_benchmark(
spec: DatasetSpec, config: AppConfig, qa_limit: int | None = None
) -> ReportCaseFailure[str, str, dict[str, str]] | None:
corpus = spec.qa_loader()
if qa_limit is not None:
corpus = corpus.select(range(min(qa_limit, len(corpus))))
cases = [
spec.qa_case_builder(index, cast(Mapping[str, Any], doc))
for index, doc in enumerate(corpus, start=1)
]
judge_model = OpenAIChatModel(
model_name=QA_JUDGE_MODEL,
provider=OllamaProvider(base_url=f"{config.providers.ollama.base_url}/v1"),
)
evaluation_dataset = EvalDataset[str, str, dict[str, str]](
cases=cases,
evaluators=[
IsInstance(type_name="str"),
LLMJudge(
rubric=ANSWER_EQUIVALENCE_RUBRIC,
include_input=True,
include_expected_output=True,
model=judge_model,
assertion={
"evaluation_name": "answer_equivalent",
"include_reason": True,
},
),
],
)
total_processed = 0
passing_cases = 0
failures: list[ReportCaseFailure[str, str, dict[str, str]]] = []
with Progress(console=console) as progress:
qa_task = progress.add_task(
"[yellow]Evaluating QA cases...",
total=len(evaluation_dataset.cases),
)
async with HaikuRAG(spec.db_path, config=config) as rag:
system_prompt = WIX_SUPPORT_PROMPT if spec.key == "wix" else None
qa = get_qa_agent(rag, system_prompt=system_prompt)
async def answer_question(question: str) -> str:
return await qa.answer(question)
for case in evaluation_dataset.cases:
single_case_dataset = EvalDataset[str, str, dict[str, str]](
cases=[case],
evaluators=evaluation_dataset.evaluators,
)
report = await single_case_dataset.evaluate(
answer_question,
name="qa_answer",
max_concurrency=1,
progress=False,
)
total_processed += 1
if report.cases:
result_case = report.cases[0]
equivalence = result_case.assertions.get("answer_equivalent")
if equivalence is not None:
if equivalence.value:
passing_cases += 1
if report.failures:
failures.extend(report.failures)
failure = report.failures[0]
progress.console.print(
"[red]Failure encountered during case evaluation:[/red]"
)
progress.console.print(f"Error: {failure.error_message}")
progress.console.print("")
progress.update(
qa_task,
description="[yellow]Evaluating QA cases...[/yellow] "
f"[green]Accuracy: {(passing_cases / total_processed):.2f} "
f"{passing_cases}/{total_processed}[/green]",
)
progress.advance(qa_task)
total_cases = total_processed
accuracy = passing_cases / total_cases if total_cases > 0 else 0
console.print("\n=== QA Benchmark Results ===", style="bold cyan")
console.print(f"Total questions: {total_cases}")
console.print(f"Correct answers: {passing_cases}")
console.print(f"QA Accuracy: {accuracy:.4f} ({accuracy * 100:.2f}%)")
if failures:
console.print("[red]\nSummary of failures:[/red]")
for failure in failures:
console.print(f"Case: {failure.name}")
console.print(f"Question: {failure.inputs}")
console.print(f"Error: {failure.error_message}")
console.print("")
return failures[0] if failures else None
async def evaluate_dataset(
spec: DatasetSpec,
config: AppConfig,
skip_db: bool,
skip_retrieval: bool,
skip_qa: bool,
limit: int | None,
name: str | None,
db_path: Path | None,
vacuum_interval: int = 100,
multimodal_only: bool = False,
judge_model: ModelConfig | None = None,
target: Target = "rag-capability",
capability_model: ModelConfig | None = None,
case_ids: set[str] | None = None,
document_filter: str | None = None,
qa_limit: int | None,
) -> None:
if document_filter is not None:
console.print(f"Document filter: {document_filter}", style="dim")
if db_path is not None and config.lancedb.databases:
raise ValueError(
"--db PATH places the database where the configuration places none, "
f"and this configuration names {', '.join(config.lancedb.databases)} "
"in lancedb.databases. Drop --db to evaluate the configured set."
)
if not skip_db:
if spec.uses_configured_databases(config, db_path):
raise ValueError(
"lancedb.databases places the databases this run reads, and "
"population writes to one, so it would ingest into a database "
"the run does not read. Pass --skip-db to evaluate the "
"configured set, or --db PATH to populate and evaluate one."
)
console.print(f"Using dataset: {spec.key}", style="bold magenta")
await populate_db(
spec, config, db_path=db_path, vacuum_interval=vacuum_interval
)
await populate_db(spec, config)
if not skip_retrieval:
console.print("Running retrieval benchmarks...", style="bold blue")
await run_retrieval_benchmark(
spec,
config,
limit=limit,
name=name,
db_path=db_path,
multimodal_only=multimodal_only,
document_filter=document_filter,
)
await run_retrieval_benchmark(spec, config)
if not skip_qa:
console.print(
f"\nRunning QA benchmarks (target={target})...", style="bold yellow"
)
qa_benchmark = run_live_qa_benchmark if spec.live else run_qa_benchmark
await qa_benchmark(
spec,
config,
limit=limit,
name=name,
db_path=db_path,
judge_model=judge_model,
target=target,
capability_model=capability_model,
case_ids=case_ids,
document_filter=document_filter,
)
console.print("\nRunning QA benchmarks...", style="bold yellow")
await run_qa_benchmark(spec, config, qa_limit=qa_limit)
app = typer.Typer(help="Run retrieval and QA benchmarks for configured datasets.")
def _load_config(config_path: Path | None) -> AppConfig:
"""Load AppConfig from a file path or standard search path."""
if config_path:
if not config_path.exists():
raise typer.BadParameter(f"Config file not found: {config_path}")
console.print(f"Loading config from: {config_path}", style="dim")
yaml_data = load_yaml_config(config_path)
return AppConfig.model_validate(yaml_data)
found = find_config_file(None)
if found:
console.print(f"Loading config from: {found}", style="dim")
yaml_data = load_yaml_config(found)
return AppConfig.model_validate(yaml_data)
console.print("No config file found, using defaults", style="dim")
return AppConfig()
def _load_case_ids(path: Path | None) -> set[str] | None:
"""Read a newline-delimited case-id file into a set (None when no path)."""
if path is None:
return None
return {line.strip() for line in path.read_text().splitlines() if line.strip()}
def _resolve_dataset(dataset: str) -> DatasetSpec:
"""Resolve a dataset key to a DatasetSpec or raise BadParameter."""
spec = DATASETS.get(dataset.lower())
if spec is None:
valid_datasets = ", ".join(sorted(DATASETS))
raise typer.BadParameter(
f"Unknown dataset '{dataset}'. Choose from: {valid_datasets}"
)
return spec
def _resolve_datasets(dataset: str) -> list[DatasetSpec]:
"""Resolve 'all' or a single dataset key to a list of DatasetSpecs.
'all' yields one spec per database: query variants sharing a db_filename
would otherwise be downloaded/uploaded twice.
"""
if dataset.lower() == "all":
seen: set[str] = set()
specs: list[DatasetSpec] = []
for spec in DATASETS.values():
if spec.db_filename in seen:
continue
seen.add(spec.db_filename)
specs.append(spec)
return specs
return [_resolve_dataset(dataset)]
@app.command()
def run(
dataset: str = typer.Argument(..., help="Dataset key to evaluate."),
config: Path | None = typer.Option(
None, "--config", help="Path to haiku.rag YAML config file."
),
db: Path | None = typer.Option(
None,
"--db",
help="Database path, where the configuration places no database.",
),
skip_db: bool = typer.Option(
False, "--skip-db", help="Skip updating the evaluation db."
False, "--skip-db", help="Skip updateing the evaluation db."
),
skip_retrieval: bool = typer.Option(
False, "--skip-retrieval", help="Skip retrieval benchmark."
),
skip_qa: bool = typer.Option(False, "--skip-qa", help="Skip QA benchmark."),
limit: int | None = typer.Option(
None, "--limit", help="Limit number of test cases for both retrieval and QA."
),
name: str | None = typer.Option(None, "--name", help="Override evaluation name."),
vacuum_interval: int = typer.Option(
100, "--vacuum-interval", help="Vacuum every N documents during DB population."
),
multimodal_only: bool = typer.Option(
False,
"--multimodal-only",
help="Only evaluate queries requiring image understanding.",
),
target: str = typer.Option(
"rag-capability",
"--target",
help="What to benchmark: rag-capability | analysis-capability.",
),
capability_model: str | None = typer.Option(
None,
"--capability-model",
help=(
"Capability model as 'provider:name'. Defaults to qa.model (or "
"analysis.model when --target is analysis-capability) from the config."
),
),
document_filter: str | None = typer.Option(
None,
"--filter",
"-f",
help=(
"SQL WHERE clause over document columns (id, uri, title, "
"created_at, updated_at, metadata) restricting every benchmark "
"search, e.g. \"uri LIKE '%arxiv%'\". metadata is stored as a "
"string, so match it with LIKE."
),
),
filter_ids: Path | None = typer.Option(
None,
"--filter-ids",
help=(
"Path to a newline-delimited file of QA case ids to run "
"(failure-subset rerun). Filters QA only; retrieval is unaffected."
),
qa_limit: int | None = typer.Option(
None, "--qa-limit", help="Limit number of QA cases."
),
) -> None:
spec = _resolve_dataset(dataset)
app_config = _load_config(config)
if target not in TARGETS:
spec = DATASETS.get(dataset.lower())
if spec is None:
valid_datasets = ", ".join(sorted(DATASETS))
raise typer.BadParameter(
f"Unknown target {target!r}. Choose from: {', '.join(TARGETS)}"
f"Unknown dataset '{dataset}'. Choose from: {valid_datasets}"
)
target_value = cast(Target, target)
judge_model_config = app_config.evaluations.judge
capability_model_config = (
parse_model_option(capability_model) if capability_model else None
)
# Load config from file or use defaults
if config:
if not config.exists():
raise typer.BadParameter(f"Config file not found: {config}")
console.print(f"Loading config from: {config}", style="dim")
yaml_data = load_yaml_config(config)
app_config = AppConfig.model_validate(yaml_data)
else:
# Try to find config file using standard search path
config_path = find_config_file(None)
if config_path:
console.print(f"Loading config from: {config_path}", style="dim")
yaml_data = load_yaml_config(config_path)
app_config = AppConfig.model_validate(yaml_data)
else:
console.print("No config file found, using defaults", style="dim")
app_config = AppConfig()
asyncio.run(
evaluate_dataset(
@ -240,38 +341,10 @@ def run(
skip_db=skip_db,
skip_retrieval=skip_retrieval,
skip_qa=skip_qa,
limit=limit,
name=name,
db_path=db,
vacuum_interval=vacuum_interval,
multimodal_only=multimodal_only,
judge_model=judge_model_config,
target=target_value,
capability_model=capability_model_config,
case_ids=_load_case_ids(filter_ids),
document_filter=document_filter,
qa_limit=qa_limit,
)
)
@app.command()
def download(
dataset: str = typer.Argument(..., help="Dataset key or 'all' to download all."),
force: bool = typer.Option(False, "--force", help="Overwrite existing database."),
) -> None:
"""Download pre-built evaluation database from HuggingFace."""
for spec in _resolve_datasets(dataset):
download_dataset_db(spec, force=force)
@app.command()
def upload(
dataset: str = typer.Argument(..., help="Dataset key or 'all' to upload all."),
) -> None:
"""Upload evaluation database to HuggingFace (maintainer only)."""
for spec in _resolve_datasets(dataset):
upload_dataset_db(spec)
if __name__ == "__main__":
app()

View file

@ -1,296 +0,0 @@
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, NamedTuple
from pydantic_ai import Agent
from pydantic_ai.messages import (
ModelMessage,
ModelRequest,
ModelResponse,
RetryPromptPart,
TextPart,
ToolCallPart,
ToolReturnPart,
UserPromptPart,
)
from pydantic_ai.models import Model
from pydantic_ai.capabilities import AbstractCapability
from evaluations.config import Turn
from haiku.rag.capabilities import EvidenceState, RAGCapabilityBase
from haiku.rag.capabilities.compaction import create_capability as create_compaction
from haiku.rag.capabilities.ledger import citation_status
from haiku.rag.config.models import AppConfig
CapabilityFactory = Callable[..., RAGCapabilityBase[Any]]
def prefix_to_messages(turns: Iterable[Turn]) -> list[ModelMessage]:
"""Render a conversation prefix as pydantic-ai message history."""
messages: list[ModelMessage] = []
for turn in turns:
if turn.speaker == "user":
messages.append(ModelRequest(parts=[UserPromptPart(content=turn.text)]))
else:
messages.append(ModelResponse(parts=[TextPart(content=turn.text)]))
return messages
@dataclass
class CapabilityRunResult:
answer: str
cited_uris: list[str] = field(default_factory=list)
cited_chunk_ids: list[str] = field(default_factory=list)
# The database each cited chunk came from, in the order they were cited.
# Empty string for a citation built without a source.
cited_sources: list[str] = field(default_factory=list)
searched_uris: list[str] = field(default_factory=list)
n_searches: int = 0
n_executions: int = 0
n_search_calls: int = 0
n_rejected_searches: int = 0
n_failed_tools: int = 0
n_requests: int = 0
citation_status: str | None = None
class ToolTraffic(NamedTuple):
n_search_calls: int
n_rejected_searches: int
n_failed_tools: int
n_requests: int
def _count_tool_traffic(
messages: list[ModelMessage], namespace: str, tool_names: frozenset[str]
) -> ToolTraffic:
"""Count search calls, failed calls and model requests in a run.
The history is the only source: ``state.searches`` is keyed by query so it
hides repeats and refusals, and ``for_run`` hands the run a ``replace()``
copy, leaving the outer capability's counters at zero.
Only search failures mean an exhausted budget. A failed code call may be
either the execution budget or any error in model-written Python, so
``n_failed_tools`` covers both without claiming to tell them apart. It counts
``RetryPromptPart`` too, since ``_cite`` rejects with ``ModelRetry`` and only
``ToolFailed`` sets ``outcome="failed"``. Both are restricted to
``tool_names``, excluding host tools and output-validation retries.
``n_requests`` counts the run's requests, which matches the capability's own
budget only while it stays loaded a deferred capability skips hooks until
it loads.
"""
search_tool = f"{namespace}_search"
search_calls = 0
rejected_searches = 0
failed_tools = 0
requests = 0
for message in messages:
if isinstance(message, ModelResponse):
requests += 1
search_calls += sum(
1
for part in message.parts
if isinstance(part, ToolCallPart) and part.tool_name == search_tool
)
continue
for part in message.parts:
if not isinstance(part, RetryPromptPart | ToolReturnPart):
continue
if part.tool_name not in tool_names:
continue
if isinstance(part, RetryPromptPart):
failed_tools += 1
elif part.outcome == "failed":
failed_tools += 1
if part.tool_name == search_tool:
rejected_searches += 1
return ToolTraffic(
n_search_calls=search_calls,
n_rejected_searches=rejected_searches,
n_failed_tools=failed_tools,
n_requests=requests,
)
@dataclass
class _EvalDeps:
state: dict[str, Any] = field(default_factory=dict)
def _prepare_agent(
capability_factory: CapabilityFactory,
db_path: Path | None,
config: AppConfig,
capability_model: str | Model,
document_filter: str | None,
request_limit: int | None,
compaction: bool = False,
) -> tuple[RAGCapabilityBase[Any], _EvalDeps, Agent[_EvalDeps, str]]:
capability = capability_factory(
db_path=db_path,
config=config,
defer_loading=False,
)
if request_limit is not None:
capability.request_limit = request_limit
state = capability.state_type()
if document_filter is not None:
state.document_filter = document_filter
capabilities: list[AbstractCapability] = [capability]
if compaction:
capabilities.append(create_compaction())
deps = _EvalDeps(state={capability.state_namespace: state.model_dump(mode="json")})
agent = Agent(
capability_model,
deps_type=_EvalDeps,
capabilities=capabilities,
)
return capability, deps, agent
def _state_after_run(
capability: RAGCapabilityBase[Any], deps: _EvalDeps
) -> EvidenceState:
return capability.state_type.model_validate(deps.state[capability.state_namespace])
async def run_capability_question(
capability_factory: CapabilityFactory,
db_path: Path | None,
config: AppConfig,
question: str,
capability_model: str | Model,
document_filter: str | None = None,
request_limit: int | None = None,
message_history: list[ModelMessage] | None = None,
) -> CapabilityRunResult:
"""Run a single question through a capability and return answer + retrieval data.
Builds a native capability via ``capability_factory(db_path=..., config=...)``.
After the run, citations and searched documents
are extracted from the state for downstream eval scoring.
The capability must produce a state with RAG-capability-shaped fields (citation
index, searches, optional document filter) i.e. ``RAGState`` or
``AnalysisState`` from ``haiku.rag.capabilities``.
"""
capability, deps, agent = _prepare_agent(
capability_factory,
db_path,
config,
capability_model,
document_filter,
request_limit,
)
agent_result = await agent.run(question, deps=deps, message_history=message_history)
traffic = _count_tool_traffic(
agent_result.new_messages(), capability.state_namespace, capability.tool_names
)
return _result_from_run(
agent_result.output, _state_after_run(capability, deps), traffic
)
async def run_capability_conversation(
capability_factory: CapabilityFactory,
db_path: Path | None,
config: AppConfig,
questions: list[str],
capability_model: str | Model,
document_filter: str | None = None,
compaction: bool = False,
) -> list[CapabilityRunResult]:
"""Run a conversation's user turns sequentially through one capability.
Each turn runs with the previous turn's full ``all_messages()`` as history
(tool calls and returns included) and the same state dict, which is what
lets ``EvidenceCompactionCapability`` (registered when ``compaction`` is
True) replace earlier questions' evidence on the request. Per-invocation
state (citations, searches) is cleared by the capability on every run, so
each returned result reflects only its turn.
"""
capability, deps, agent = _prepare_agent(
capability_factory,
db_path,
config,
capability_model,
document_filter=document_filter,
request_limit=None,
compaction=compaction,
)
history: list[ModelMessage] | None = None
results: list[CapabilityRunResult] = []
for question in questions:
agent_result = await agent.run(question, deps=deps, message_history=history)
history = agent_result.all_messages()
traffic = _count_tool_traffic(
agent_result.new_messages(),
capability.state_namespace,
capability.tool_names,
)
results.append(
_result_from_run(
agent_result.output, _state_after_run(capability, deps), traffic
)
)
return results
def _result_from_run(
answer: str, typed: EvidenceState, traffic: ToolTraffic
) -> CapabilityRunResult:
cited_chunk_ids: list[str] = list(typed.citations)
seen_cited: set[str] = set()
cited_uris: list[str] = []
cited_sources: list[str] = []
for chunk_id in cited_chunk_ids:
citation = typed.citation_index.get(chunk_id)
if citation is None:
continue
cited_sources.append(citation.source or "")
if citation.document_uri not in seen_cited:
seen_cited.add(citation.document_uri)
cited_uris.append(citation.document_uri)
seen_searched: set[str] = set()
searched_uris: list[str] = []
for results in typed.searches.values():
for search_result in results:
uri = search_result.document_uri
if uri and uri not in seen_searched:
seen_searched.add(uri)
searched_uris.append(uri)
executions = getattr(typed, "executions", None)
n_executions = len(executions) if executions is not None else 0
record = typed.evidence
status = (
citation_status([record], question=record.question)
if record.question is not None
else None
)
return CapabilityRunResult(
answer=answer,
cited_uris=cited_uris,
cited_chunk_ids=cited_chunk_ids,
cited_sources=cited_sources,
searched_uris=searched_uris,
# Distinct search keys, not searches. Analysis files every in-code
# `search()` under one "_sandbox" key, so twenty sandbox searches read
# as one here; `n_search_calls` is the true count of search *tool*
# calls, and in-code searches are not counted anywhere.
n_searches=len(typed.searches),
n_executions=n_executions,
n_search_calls=traffic.n_search_calls,
n_rejected_searches=traffic.n_rejected_searches,
n_failed_tools=traffic.n_failed_tools,
n_requests=traffic.n_requests,
citation_status=status,
)

View file

@ -1,52 +1,18 @@
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
from typing import Any
from datasets import Dataset
from pydantic import BaseModel, model_validator
from pydantic_evals import Case
from pydantic_evals.evaluators import Evaluator
from haiku.rag.config.models import AppConfig
class Turn(BaseModel):
speaker: Literal["user", "agent"]
text: str
class ConversationInput(BaseModel):
"""A conversation prefix plus the final user question (the last turn)."""
turns: list[Turn]
@model_validator(mode="after")
def _ends_with_user_turn(self) -> "ConversationInput":
if not self.turns or self.turns[-1].speaker != "user":
raise ValueError("conversation must end with a user turn")
return self
@property
def question(self) -> str:
return self.turns[-1].text
@property
def prefix(self) -> list[Turn]:
return self.turns[:-1]
@property
def transcript(self) -> str:
return "\n".join(f"{turn.speaker}: {turn.text}" for turn in self.turns)
@dataclass
class DocumentPayload:
uri: str
content: str | None = None
content: str
title: str | None = None
metadata: dict[str, Any] | None = None
format: str = "md"
source_path: Path | None = None
@dataclass
@ -54,14 +20,13 @@ class RetrievalSample:
question: str
expected_uris: tuple[str, ...]
skip: bool = False
source_type: str | None = None
DocumentLoader = Callable[[], Dataset]
DocumentMapper = Callable[[Mapping[str, Any]], DocumentPayload | None]
RetrievalLoader = Callable[[], Dataset]
RetrievalMapper = Callable[[Mapping[str, Any]], RetrievalSample | None]
CaseBuilder = Callable[[int, Mapping[str, Any]], Case[Any, Any, dict[str, Any]]]
CaseBuilder = Callable[[int, Mapping[str, Any]], Case[str, str, dict[str, str]]]
@dataclass
@ -74,40 +39,8 @@ class DatasetSpec:
qa_case_builder: CaseBuilder
retrieval_loader: RetrievalLoader | None = None
retrieval_mapper: RetrievalMapper | None = None
retrieval_evaluators: list[Evaluator] | None = None
citation_evaluator: Evaluator | None = None
qa_evaluator: Evaluator | None = None
document_limit: int | None = None
retrieval_limit: int = 5
ingest_batch_size: int | None = None
live: bool = False
compaction: bool = False
experiment_metadata: dict[str, Any] | None = None
def uses_configured_databases(
self, config: AppConfig, override_path: Path | None = None
) -> bool:
"""Whether `lancedb.databases` places the databases to evaluate over.
`--db PATH` places the database where the configuration places none;
`evaluate_dataset` refuses the two together. True for a mapping of one,
which is a configured database like any other and keeps its name.
"""
return bool(config.lancedb.databases) and override_path is None
def db_path(self, override_path: Path | None = None) -> Path:
"""Get the database path.
Args:
override_path: Optional path to override the default database location.
Returns:
The database path to use.
"""
if override_path is not None:
return override_path
from haiku.rag.utils import get_default_data_dir
data_dir = get_default_data_dir()
return data_dir / "evaluations" / "dbs" / self.db_filename
@property
def db_path(self) -> Path:
return Path(__file__).parent / "data" / self.db_filename

View file

@ -1,35 +1,8 @@
from evaluations.config import DatasetSpec
from .frames import FRAMES_SPEC
from .hotpotqa import HOTPOTQA_SPEC
from .mtrag import (
MTRAG_CLAPNQ_LIVE_SPEC,
MTRAG_CLAPNQ_LIVE_UNCOMPACTED_SPEC,
MTRAG_CLAPNQ_REWRITE_SPEC,
MTRAG_CLAPNQ_SPEC,
)
from .open_rag_bench import (
ORB_MULTIMODAL_NEMOTRON_SPEC,
ORB_MULTIMODAL_SPEC,
ORB_TEXT_SPEC,
)
from .t2_ragbench import T2_FINQA_SPEC, T2_TATDQA_SPEC
from .repliqa import REPLIQ_SPEC
from .wix import WIX_SPEC
DATASETS: dict[str, DatasetSpec] = {
spec.key: spec
for spec in (
FRAMES_SPEC,
HOTPOTQA_SPEC,
MTRAG_CLAPNQ_SPEC,
MTRAG_CLAPNQ_REWRITE_SPEC,
MTRAG_CLAPNQ_LIVE_SPEC,
MTRAG_CLAPNQ_LIVE_UNCOMPACTED_SPEC,
ORB_TEXT_SPEC,
ORB_MULTIMODAL_SPEC,
ORB_MULTIMODAL_NEMOTRON_SPEC,
T2_FINQA_SPEC,
T2_TATDQA_SPEC,
)
}
DATASETS: dict[str, DatasetSpec] = {spec.key: spec for spec in (REPLIQ_SPEC, WIX_SPEC)}
__all__ = ["DATASETS"]

View file

@ -1,331 +0,0 @@
"""FRAMES benchmark (google/frames-benchmark).
824 multi-hop questions, each grounded in two or more Wikipedia articles. The
corpus is the union of the articles linked per question, fetched from the
Wikipedia REST API at current revision and cached locally with the revision id
and fetch date.
"""
import ast
import json
import logging
import re
import time
from collections.abc import Mapping
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, quote, unquote, urlsplit
import httpx
from bs4 import BeautifulSoup
from datasets import Dataset, load_dataset
from pydantic_evals import Case
from evaluations.config import DatasetSpec, DocumentPayload, RetrievalSample
from evaluations.evaluators import CitationMAPEvaluator, MAPEvaluator
logger = logging.getLogger(__name__)
USER_AGENT = "haiku.rag-evaluations (https://github.com/ggozad/haiku.rag)"
FETCH_ATTEMPTS = 3
THROTTLE_SECONDS = 1.0
RATE_LIMIT_BACKOFF_SECONDS = 60.0
# Articles deleted from Wikipedia since FRAMES was authored; the questions
# linking them have lost their evidence and are excluded from the benchmark.
_DELETED_ARTICLES = frozenset(
{
"https://en.wikipedia.org/wiki/Nemanja_Marković",
"https://en.wikipedia.org/wiki/Jack_Vance_(tennis)",
}
)
def load_frames_test() -> Dataset:
return load_dataset("google/frames-benchmark")["test"]
def question_is_answerable(doc: Mapping[str, Any]) -> bool:
return not _DELETED_ARTICLES & set(question_expected_uris(doc))
def load_frames_questions() -> Dataset:
"""Answerable questions with a stable `id` (the dataset row number)."""
dataset = load_frames_test().filter(question_is_answerable)
return dataset.map(lambda row: {"id": str(row["Unnamed: 0"])})
def parse_wiki_links(raw: str) -> list[str]:
"""Extract URLs from a `wiki_links` value.
The value is a Python-list-repr string. A single list element may pack
several comma-separated URLs, and may carry trailing prose annotations;
titles themselves can contain commas, so elements are split only where a
new URL starts.
"""
links: list[str] = []
for element in ast.literal_eval(raw):
for part in re.split(r",\s*(?=http)", element):
tokens = part.split()
if not tokens:
continue
url = tokens[0].strip(", ")
if url:
links.append(url)
return links
def normalize_wiki_url(url: str) -> str | None:
"""Canonical article URL, used both as document uri and expected uri.
Strips fragments, decodes percent-escapes, folds mobile hosts, resolves
`index.php?title=` and `Special:Search` forms, and applies MediaWiki title
canonicalization (underscores, first letter uppercased). Returns None for
strings that don't point to an article.
"""
url = url.strip()
if not url:
return None
if "://" not in url:
url = "https://" + url
parts = urlsplit(url)
host = parts.netloc.replace(".m.wikipedia.org", ".wikipedia.org")
if host == "w.wiki":
return url
if parts.path.startswith("/wiki/"):
title = parts.path[len("/wiki/") :]
elif parts.path.startswith("/w/index.php"):
query = parse_qs(parts.query)
title = query.get("title", [""])[0]
if not title or title.startswith("Special:"):
title = query.get("search", [""])[0]
else:
return None
title = unquote(title).replace(" ", "_").strip("_")
if not title:
return None
return f"https://{host}/wiki/{title[0].upper() + title[1:]}"
def parse_revid(etag: str | None) -> str | None:
"""Revision id from a Wikipedia REST ETag header (`W/"<revid>/<uuid>"`)."""
if not etag:
return None
match = re.search(r'"([^/"]+)/', etag)
return match.group(1) if match else None
def strip_navigation(html: str) -> str:
"""Drop navigation chrome (navboxes, succession boxes) from parsoid HTML.
These render as link-spam tables naming hundreds of related articles,
polluting retrieval. Infoboxes carry no navigation role and are kept.
"""
soup = BeautifulSoup(html, "html.parser")
for element in soup.find_all(attrs={"role": "navigation"}):
element.decompose()
return str(soup)
def get_cache_dir() -> Path:
cache_dir = Path.home() / ".cache" / "haiku.rag" / "evaluations" / "frames_articles"
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir
def _fetch_category_page(
host: str, title: str, client: httpx.Client
) -> tuple[str, str, str | None]:
"""Category pages render empty via page/html; synthesize a members list."""
response = client.get(
f"https://{host}/w/api.php",
params={
"action": "query",
"list": "categorymembers",
"cmtitle": title,
"cmlimit": "500",
"format": "json",
},
)
response.raise_for_status()
members = [m["title"] for m in response.json()["query"]["categorymembers"]]
display = title.replace("_", " ")
content = f"# {display}\n\nPages in this category:\n"
content += "\n".join(f"- {member}" for member in members) + "\n"
return content, "md", None
def _fetch_article_page(
uri: str, client: httpx.Client
) -> tuple[str, str, str | None, str]:
"""Fetch parsoid HTML for an article; returns (content, format, revid, title)."""
parts = urlsplit(uri)
host = parts.netloc
if host == "w.wiki":
resolved = urlsplit(str(client.get(uri).url))
host = resolved.netloc
title = unquote(resolved.path[len("/wiki/") :])
else:
title = unquote(parts.path[len("/wiki/") :])
response = client.get(
f"https://{host}/api/rest_v1/page/html/{quote(title, safe='')}"
)
response.raise_for_status()
revid = parse_revid(response.headers.get("etag"))
return response.text, "html", revid, title
def _backoff_seconds(error: Exception, attempt: int) -> float:
if isinstance(error, httpx.HTTPStatusError) and error.response.status_code == 429:
retry_after = error.response.headers.get("retry-after")
return float(retry_after) if retry_after else RATE_LIMIT_BACKOFF_SECONDS
return 5.0 * attempt
def fetch_article(
uri: str, cache_dir: Path, client: httpx.Client | None
) -> dict[str, Any] | None:
"""Return a corpus row for `uri`, fetching and caching it if needed.
The cache holds the raw page plus a JSON sidecar with title, format,
revision id, and fetch date; a present sidecar marks a complete entry and
is served without network access.
"""
base = quote(uri, safe="")
meta_path = cache_dir / f"{base}.json"
if meta_path.exists():
row = json.loads(meta_path.read_text())
row["path"] = str(cache_dir / f"{base}.{row['format']}")
return row
assert client is not None
title = unquote(urlsplit(uri).path[len("/wiki/") :])
# Wikimedia throttles sustained bot traffic; pace uncached fetches.
time.sleep(THROTTLE_SECONDS)
for attempt in range(1, FETCH_ATTEMPTS + 1):
try:
if title.startswith("Category:"):
content, format, revid = _fetch_category_page(
urlsplit(uri).netloc, title, client
)
else:
content, format, revid, title = _fetch_article_page(uri, client)
break
except Exception as e:
if attempt == FETCH_ATTEMPTS:
logger.warning(f"Failed to fetch {uri}: {e}")
return None
logger.info(f"Retrying {uri} after error: {e}")
time.sleep(_backoff_seconds(e, attempt))
row: dict[str, Any] = {
"uri": uri,
"title": title.replace("_", " "),
"format": format,
"revid": revid,
"fetched_at": datetime.now(UTC).date().isoformat(),
}
content_path = cache_dir / f"{base}.{format}"
content_path.write_text(content)
meta_path.write_text(json.dumps(row))
row["path"] = str(content_path)
return row
def question_expected_uris(doc: Mapping[str, Any]) -> tuple[str, ...]:
uris: list[str] = []
for link in parse_wiki_links(doc["wiki_links"]):
normalized = normalize_wiki_url(link)
if normalized is not None and normalized not in uris:
uris.append(normalized)
return tuple(uris)
_cached_corpus: list[dict[str, Any]] | None = None
def load_frames_corpus() -> list[dict[str, Any]]:
"""Fetch (or read from cache) every article linked by any question."""
global _cached_corpus
if _cached_corpus is None:
uris: dict[str, None] = {}
for doc in load_frames_questions():
for uri in question_expected_uris(doc):
uris.setdefault(uri)
cache_dir = get_cache_dir()
rows: list[dict[str, Any]] = []
with httpx.Client(
headers={"User-Agent": USER_AGENT}, follow_redirects=True, timeout=60.0
) as client:
for index, uri in enumerate(uris, start=1):
row = fetch_article(uri, cache_dir, client)
if row is not None:
rows.append(row)
if index % 100 == 0:
logger.info(f"Fetched {index}/{len(uris)} articles")
logger.info(f"Fetched {len(rows)}/{len(uris)} articles")
if len(rows) < len(uris):
raise RuntimeError(
f"Fetched only {len(rows)}/{len(uris)} FRAMES articles; "
"refusing to build a partial corpus. Re-run to resume from cache."
)
_cached_corpus = rows
return _cached_corpus
def document_loader() -> Dataset:
return Dataset.from_list(load_frames_corpus())
def map_frames_document(doc: Mapping[str, Any]) -> DocumentPayload:
content = Path(doc["path"]).read_text()
if doc["format"] == "html":
content = strip_navigation(content)
metadata: dict[str, str] = {"fetched_at": doc["fetched_at"]}
if doc.get("revid"):
metadata["revid"] = doc["revid"]
return DocumentPayload(
uri=doc["uri"],
content=content,
title=doc["title"],
metadata=metadata,
format=doc["format"],
)
def map_frames_retrieval(doc: Mapping[str, Any]) -> RetrievalSample | None:
uris = question_expected_uris(doc)
if not uris:
return None
return RetrievalSample(question=doc["Prompt"], expected_uris=uris)
def build_frames_case(
index: int, doc: Mapping[str, Any]
) -> Case[str, str, dict[str, str]]:
return Case(
name=f"{index}_{doc['id']}",
inputs=doc["Prompt"],
expected_output=doc["Answer"],
metadata={
"question_id": str(doc["id"]),
"reasoning_types": str(doc["reasoning_types"]),
"case_index": str(index),
},
)
FRAMES_SPEC = DatasetSpec(
key="frames",
db_filename="frames.lancedb",
document_loader=document_loader,
document_mapper=map_frames_document,
qa_loader=load_frames_questions,
qa_case_builder=build_frames_case,
retrieval_loader=load_frames_questions,
retrieval_mapper=map_frames_retrieval,
retrieval_evaluators=[MAPEvaluator()],
citation_evaluator=CitationMAPEvaluator(),
)

View file

@ -1,109 +0,0 @@
from collections.abc import Mapping
from typing import Any, cast
from datasets import Dataset, load_dataset
from pydantic_evals import Case
from evaluations.config import DatasetSpec, DocumentPayload, RetrievalSample
from evaluations.evaluators import CitationMAPEvaluator, MAPEvaluator
def load_hotpotqa_validation() -> Dataset:
dataset_dict = load_dataset("hotpotqa/hotpot_qa", "distractor")
return dataset_dict["validation"]
def extract_unique_documents(dataset: Dataset) -> list[dict[str, Any]]:
"""Extract unique documents from all context paragraphs, deduplicated by title."""
seen_titles: set[str] = set()
documents: list[dict[str, Any]] = []
for sample in dataset:
sample = cast(Mapping[str, Any], sample)
context = sample["context"]
titles = context["title"]
sentences_list = context["sentences"]
for title, sentences in zip(titles, sentences_list):
if title in seen_titles:
continue
seen_titles.add(title)
content = " ".join(sentences)
documents.append({"title": title, "content": content})
return documents
_cached_documents: list[dict[str, Any]] | None = None
def load_hotpotqa_documents() -> list[dict[str, Any]]:
"""Load and cache unique documents from HotpotQA."""
global _cached_documents
if _cached_documents is None:
dataset = load_hotpotqa_validation()
_cached_documents = extract_unique_documents(dataset)
return _cached_documents
def document_loader() -> Dataset:
"""Return documents as a Dataset-like iterable."""
docs = load_hotpotqa_documents()
return Dataset.from_list(docs)
def map_hotpotqa_document(doc: Mapping[str, Any]) -> DocumentPayload:
return DocumentPayload(
uri=doc["title"],
content=doc["content"],
title=doc["title"],
)
def map_hotpotqa_retrieval(doc: Mapping[str, Any]) -> RetrievalSample | None:
supporting_facts = doc["supporting_facts"]
titles = supporting_facts["title"]
if not titles:
return None
unique_titles = tuple(dict.fromkeys(titles))
return RetrievalSample(
question=doc["question"],
expected_uris=unique_titles,
)
def build_hotpotqa_case(
index: int, doc: Mapping[str, Any]
) -> Case[str, str, dict[str, str]]:
question_id = doc["id"]
question_type = doc["type"]
level = doc["level"]
case_name = f"{index}_{question_id}"
return Case(
name=case_name,
inputs=doc["question"],
expected_output=doc["answer"],
metadata={
"question_id": str(question_id),
"type": str(question_type),
"level": str(level),
"case_index": str(index),
},
)
HOTPOTQA_SPEC = DatasetSpec(
key="hotpotqa",
db_filename="hotpotqa.lancedb",
document_loader=document_loader,
document_mapper=map_hotpotqa_document,
qa_loader=load_hotpotqa_validation,
qa_case_builder=build_hotpotqa_case,
retrieval_loader=load_hotpotqa_validation,
retrieval_mapper=map_hotpotqa_retrieval,
retrieval_evaluators=[MAPEvaluator()],
citation_evaluator=CitationMAPEvaluator(),
)

View file

@ -1,304 +0,0 @@
import json
import zipfile
from collections.abc import Iterable, Mapping
from functools import partial
from pathlib import Path
from typing import Any
import httpx
from datasets import Dataset
from pydantic_evals import Case
from evaluations.config import (
ConversationInput,
DatasetSpec,
DocumentPayload,
RetrievalSample,
Turn,
)
from evaluations.evaluators import (
CitationMAPEvaluator,
MAPEvaluator,
NDCGEvaluator,
RecallEvaluator,
)
REPO_SHA = "cc5b1d481b391181b89f7ced860308482e785463"
_BASE_URL = f"https://raw.githubusercontent.com/IBM/mt-rag-benchmark/{REPO_SHA}"
_CORPUS_FILE = "corpora/passage_level/clapnq.jsonl.zip"
_QRELS_FILE = "mtrag-human/retrieval_tasks/clapnq/qrels/dev.tsv"
_QUERY_FILES = {
"lastturn": "mtrag-human/retrieval_tasks/clapnq/clapnq_lastturn.jsonl",
"rewrite": "mtrag-human/retrieval_tasks/clapnq/clapnq_rewrite.jsonl",
}
_GEN_TASKS_FILE = "mtrag-human/generation_tasks/reference.jsonl"
_CLAPNQ_COLLECTION = "mt-rag-clapnq-elser-512-100-20240503"
def get_cache_dir() -> Path:
cache_dir = Path.home() / ".cache" / "haiku.rag" / "evaluations" / "mtrag"
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir
def _download(rel_path: str) -> Path:
dest = get_cache_dir() / rel_path.replace("/", "_")
if dest.exists():
return dest
with httpx.stream(
"GET", f"{_BASE_URL}/{rel_path}", timeout=120.0, follow_redirects=True
) as response:
response.raise_for_status()
tmp = dest.with_suffix(dest.suffix + ".part")
with tmp.open("wb") as fh:
for data in response.iter_bytes():
fh.write(data)
tmp.rename(dest)
return dest
def _parse_qrels(lines: Iterable[str]) -> dict[str, list[str]]:
"""Group qrel corpus-ids by query-id, preserving file order."""
qrels: dict[str, list[str]] = {}
rows = iter(lines)
next(rows) # header: query-id / corpus-id / score
for line in rows:
if not line.strip():
continue
query_id, corpus_id, _score = line.rstrip("\n").split("\t")
qrels.setdefault(query_id, []).append(corpus_id)
return qrels
def _validate_qrels_resolve(
corpus_ids: set[str], qrels: Mapping[str, list[str]]
) -> None:
unresolved = sorted(
{cid for ids in qrels.values() for cid in ids if cid not in corpus_ids}
)
if unresolved:
raise ValueError(
f"{len(unresolved)} qrel corpus-ids do not resolve to corpus "
f"passages, e.g. {unresolved[:3]}"
)
def _join_queries_qrels(
queries: Iterable[Mapping[str, Any]], qrels: Mapping[str, list[str]]
) -> list[dict[str, Any]]:
records = []
for query in queries:
query_id = query["_id"]
expected = qrels.get(query_id)
if expected is None:
raise ValueError(f"query {query_id} has no qrels")
records.append(
{
"query_id": query_id,
"question": query["text"],
"expected_uris": expected,
}
)
return records
def _load_qrels() -> dict[str, list[str]]:
path = _download(_QRELS_FILE)
return _parse_qrels(path.read_text().splitlines())
def load_clapnq_corpus() -> Dataset:
path = _download(_CORPUS_FILE)
records: list[dict[str, str]] = []
with zipfile.ZipFile(path) as zf:
with zf.open(zf.namelist()[0]) as fh:
for line in fh:
rec = json.loads(line)
records.append(
{"_id": rec["_id"], "title": rec["title"], "text": rec["text"]}
)
_validate_qrels_resolve({rec["_id"] for rec in records}, _load_qrels())
return Dataset.from_list(records)
def map_mtrag_document(doc: Mapping[str, Any]) -> DocumentPayload:
return DocumentPayload(uri=doc["_id"], content=doc["text"], title=doc["title"])
def load_clapnq_retrieval(variant: str) -> Dataset:
path = _download(_QUERY_FILES[variant])
queries = [json.loads(line) for line in path.read_text().splitlines() if line]
return Dataset.from_list(_join_queries_qrels(queries, _load_qrels()))
def map_mtrag_retrieval(doc: Mapping[str, Any]) -> RetrievalSample | None:
return RetrievalSample(
question=doc["question"],
expected_uris=tuple(doc["expected_uris"]),
)
def _task_to_record(
task: Mapping[str, Any], qrels: Mapping[str, list[str]]
) -> dict[str, Any] | None:
"""Reduce a reference.jsonl generation task to the fields QA cases need.
Task `contexts` are the original system's retrievals, never gold relevance;
gold passages come from the qrels keyed by task_id.
"""
if task["Collection"] != _CLAPNQ_COLLECTION:
return None
return {
"id": task["task_id"],
"turn": task["turn"],
"turns": [
{"speaker": message["speaker"], "text": message["text"]}
for message in task["input"]
],
"answer": task["targets"][0]["text"],
"answerability": task["Answerability"][0],
"multi_turn_type": task["Multi-Turn"][0],
"question_type": list(task["Question Type"]),
"relevant_uris": qrels.get(task["task_id"]),
}
def _qa_records() -> list[dict[str, Any]]:
path = _download(_GEN_TASKS_FILE)
qrels = _load_qrels()
records = []
for line in path.read_text().splitlines():
if not line.strip():
continue
record = _task_to_record(json.loads(line), qrels)
if record is not None:
records.append(record)
return records
def load_clapnq_qa() -> Dataset:
return Dataset.from_list(_qa_records())
def build_mtrag_case(
index: int, doc: Mapping[str, Any]
) -> Case[ConversationInput, str, dict[str, Any]]:
metadata: dict[str, Any] = {
"task_id": doc["id"],
"turn": doc["turn"],
"answerability": doc["answerability"],
"multi_turn_type": doc["multi_turn_type"],
"question_type": list(doc["question_type"]),
}
if doc["relevant_uris"]:
metadata["relevant_uris"] = list(doc["relevant_uris"])
return Case(
name=f"{index}_{doc['id']}",
inputs=ConversationInput(
turns=[Turn(**turn) for turn in doc["turns"]],
),
expected_output=doc["answer"],
metadata=metadata,
)
def _group_conversations(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Group per-turn generation records into full conversations.
Turns are ordered numerically within each conversation; each turn carries
its user question, reference answer, answerability label, and gold
passages when the turn has qrels.
"""
grouped: dict[str, list[dict[str, Any]]] = {}
for record in records:
conversation_id = record["id"].split("<::>")[0]
grouped.setdefault(conversation_id, []).append(record)
conversations = []
for conversation_id, tasks in grouped.items():
tasks.sort(key=lambda record: int(record["turn"]))
turns = []
for task in tasks:
turn: dict[str, Any] = {
"task_id": task["id"],
"turn": task["turn"],
"question": task["turns"][-1]["text"],
"reference": task["answer"],
"answerability": task["answerability"],
"multi_turn_type": task["multi_turn_type"],
"question_type": list(task["question_type"]),
"relevant_uris": list(task["relevant_uris"] or []),
}
turns.append(turn)
conversations.append({"id": conversation_id, "turns": turns})
return conversations
def load_clapnq_conversations() -> Dataset:
return Dataset.from_list(_group_conversations(_qa_records()))
def build_mtrag_live_case(
index: int, doc: Mapping[str, Any]
) -> Case[list[str], list[str], dict[str, Any]]:
questions = [turn["question"] for turn in doc["turns"]]
metadata_turns = [
{key: value for key, value in turn.items() if key != "question"}
for turn in doc["turns"]
]
return Case(
name=f"{index}_{doc['id']}",
inputs=questions,
metadata={"conversation_id": doc["id"], "turns": metadata_turns},
)
def _mtrag_spec(key: str, variant: str) -> DatasetSpec:
return DatasetSpec(
key=key,
db_filename="mtrag_clapnq.lancedb",
document_loader=load_clapnq_corpus,
document_mapper=map_mtrag_document,
qa_loader=load_clapnq_qa,
qa_case_builder=build_mtrag_case,
retrieval_loader=partial(load_clapnq_retrieval, variant),
retrieval_mapper=map_mtrag_retrieval,
retrieval_evaluators=[
RecallEvaluator(k=5),
RecallEvaluator(k=10),
NDCGEvaluator(k=5),
NDCGEvaluator(k=10),
MAPEvaluator(),
],
citation_evaluator=CitationMAPEvaluator(),
retrieval_limit=10,
ingest_batch_size=512,
experiment_metadata={"mtrag_mode": "gold_prefix"},
)
MTRAG_CLAPNQ_SPEC = _mtrag_spec("mtrag_clapnq", "lastturn")
MTRAG_CLAPNQ_REWRITE_SPEC = _mtrag_spec("mtrag_clapnq_rewrite", "rewrite")
def _mtrag_live_spec(key: str, compaction: bool) -> DatasetSpec:
return DatasetSpec(
key=key,
db_filename="mtrag_clapnq.lancedb",
document_loader=load_clapnq_corpus,
document_mapper=map_mtrag_document,
qa_loader=load_clapnq_conversations,
qa_case_builder=build_mtrag_live_case,
ingest_batch_size=512,
live=True,
compaction=compaction,
experiment_metadata={"mtrag_mode": "live_session", "compaction": compaction},
)
MTRAG_CLAPNQ_LIVE_SPEC = _mtrag_live_spec("mtrag_clapnq_live", compaction=True)
MTRAG_CLAPNQ_LIVE_UNCOMPACTED_SPEC = _mtrag_live_spec(
"mtrag_clapnq_live_uncompacted", compaction=False
)

Some files were not shown because too many files have changed in this diff Show more