Compare commits

...

1993 commits
0.15.0 ... main

Author SHA1 Message Date
Yiorgis Gozadinos
d2af1a2e62
Wait for drained jobs instead of sleeping in the breaker isolation test
Some checks failed
build-docs / build (push) Has been cancelled
Tests / lint (push) Has been cancelled
Tests / lint-frontend (push) Has been cancelled
Tests / URI paths (macos-latest, py3.13) (push) Has been cancelled
Tests / URI paths (macos-latest, py3.14) (push) Has been cancelled
Tests / URI paths (ubuntu-latest, py3.13) (push) Has been cancelled
Tests / URI paths (ubuntu-latest, py3.14) (push) Has been cancelled
Tests / URI paths (windows-latest, py3.13) (push) Has been cancelled
Tests / URI paths (windows-latest, py3.14) (push) Has been cancelled
build-docs / deploy (push) Has been cancelled
Tests / test (push) Has been cancelled
test_breaker_isolates_sources snapshotted job status after a fixed 0.2s
sleep and failed on a loaded runner while two good jobs were still in
flight. Poll for the drained jobs under a 5s deadline.

Assert the paused source's jobs are unattempted: the queued-uri assertion
alone passes with source isolation disabled, since a retried job returns
to QUEUED.
2026-09-07 14:42:03 +03:00
Yiorgis Gozadinos
5cecd7b50e
Merge pull request #600 from ggozad/feat/mcp-revisited
Make the MCP server read-only and multi-database; replace ask_question and analyze with execute_code; ship a Claude Code and Codex plugin with the haiku-rag skill
2026-09-07 05:50:09 -05:00
Yiorgis Gozadinos
4b1ec096c6
One error contract for the MCP server
Every failure reaches the client as an MCP error carrying its message;
mask_error_details is set off explicitly, since FastMCP also reads it from
the environment. The masking goes, and with it the filter pre-check that
ran a count before every filtered call and the UnknownDatabaseError
translations that existed only to survive it. Explicit domain errors stay.
2026-09-07 13:14:16 +03:00
Yiorgis Gozadinos
95fdb46c3a
Check the sandbox deadline on every host call, one error contract
analysis.code_timeout was enforced only in _run_on_loop, the bridge for
database-bound reads; metadata.json, the cached JSONL files and in-code
search() and list_documents() never looked at the clock, and Monty's
watchdog counts compute only. Every host call now checks the deadline
before it starts.

Host errors keep their message for every caller: the masking added for the
MCP server goes, and the sandbox is one path for the capability and the
server alike.
2026-09-07 12:47:28 +03:00
Yiorgis Gozadinos
bae359e3bf
Align the MCP guidance with the analysis instructions and move to Monty 0.0.23
The execute_code description gains the toc.json node shape, the patterns
the evals put into the analysis instructions (one list_documents call to
map a title to an id, files carry no source, toc before search for a known
document, doc_item_refs are items self_refs, chunk ids join files and are
not citations) and the sandbox's read-only, no-network, time-limit and
output facts, which the analysis instructions now state too. The skill
gains pictures as images, image search when offered, and citing chunk
metadata locators. docs/mcp.md lists the interpreter's limits under Code.

pydantic-monty>=0.0.23 brings collections, itertools, functools,
dataclasses, function decorators and str.format into the sandbox; every
layer names the same modules, and a test imports them. Monty now caps host
callbacks per checkout at 1000 by default; the sandbox raises it out of
reach, since the time budgets govern.
2026-09-07 12:02:33 +03:00
Yiorgis Gozadinos
d7c131bc28
Package the plugin for Codex from the same bundle
The Claude Code plugin moves to plugins/haiku-rag/ and gains a Codex
manifest at .codex-plugin/plugin.json; both share one .mcp.json and one
haiku-rag Agent Skill. .agents/plugins/marketplace.json serves Codex, the
Claude marketplace points at the new path, and scripts/bump_version.py
bumps both manifests. The skill declares its compatibility.
2026-09-07 11:21:25 +03:00
Yiorgis Gozadinos
110adf23ee
Make toc.json item_range the line slice it documents
build_toc stored item positions, while the sandbox instructions describe
item_range as a slice into items.jsonl; a gap in positions pulled the next
heading into a section. Ranges are now indices into the position-ordered
items, and get_document_section slices by index too. docs/mcp.md names the
tools that take sources.
2026-09-07 10:27:02 +03:00
Yiorgis Gozadinos
b374d5eb83
Replace ask_question and analyze with execute_code
In Claude Code the client is the model, so the server no longer runs one.
execute_code runs a Python program per call in the analysis sandbox over
the selected documents and returns what it printed; the sandbox is created
and closed per call so Monty's cumulative budget and a frozen mount never
outlive a program. --no-agents goes with the two tools, and format_citations
in haiku.rag.utils goes with its only caller.

The sandbox exposes chunk metadata to code: chunk_meta on search results,
metadata on list_documents rows and in metadata.json, and chunks.jsonl per
document. A host-side failure inside a program, a document read or an
in-code search raising, reaches the program by exception type only and is
logged with its traceback. recovery_hint moves to haiku.rag.sandbox.

Closes #604.
2026-09-07 10:02:42 +03:00
Yiorgis Gozadinos
0026142e7d
Give MCP search results one channel and tidy two messages
Search results carry text and image blocks only. Claude Code and the
Agent SDK do not forward text blocks when structuredContent is present
and Desktop forwards both, so sending both either hid the rendering or
doubled it. The invalid-filter error keeps the engine's diagnosis and
lists our columns instead of lance's internals. format_citations no
longer repeats the URI of an untitled document.

Refs #599
2026-09-04 15:50:24 +03:00
Yiorgis Gozadinos
2582f2c05a
Expand MCP search results and render the matched chunk's metadata
Both search tools pass their results through HaikuRAG.expand_context, as
every other consumer of search results already did, so a client reads
the hit in its section rather than the chunk that matched. The rendering
gains an opt-in include_chunk_meta that shows the metadata stored with
the matched chunk beyond haiku.rag's structural keys, labelled as the
matched chunk's because an expanded passage spans several chunks and
only the anchor's metadata survives expansion. The capabilities' rendering
is unchanged.

Refs #599
2026-09-04 14:12:12 +03:00
Yiorgis Gozadinos
494294046f
Move to fastmcp 4 and MCP SDK 2
fastmcp>=4.0.2,<5.0.0. Protocol types are snake_case (read_only_hint,
mime_type, input_schema, server_info); the camelCase names warn and go
in fastmcp 5, so the next major is an explicit upgrade. The client
defaults to the sessionless protocol, where initialize_result is None,
so the tests read client.instructions and client.server_info, which both
protocol modes populate. The server answers either mode.
2026-09-04 13:11:06 +03:00
Yiorgis Gozadinos
eb9995e9b7
Ship a Claude Code plugin with the haiku-rag skill
claude-plugin/ holds the plugin manifest, the server configuration
(haiku-rag mcp --stdio, the configuration decides the database) and a
skill that says when to reach for the knowledge base and how to move
from a search result to a document, a section, an answer or a
computation. A repo-root marketplace manifest makes
`claude plugin marketplace add ggozad/haiku.rag` work. The skill
pre-approves every tool the server registers, and a test keeps the two
in step.

The manifest carries the package version, which bump_version.py now
rewrites: a versioned plugin updates only on a bump, so the installed
skill stays in step with the haiku-rag release the user has.

Refs #599
2026-09-04 13:00:15 +03:00
Yiorgis Gozadinos
f45ed90b33
Add --no-agents to leave the model-backed MCP tools out
haiku-rag mcp --no-agents reaches create_mcp_server(agents=False):
ask_question and analyze are not registered and the instructions drop
the line describing them. qa.model always has a default, so a server
without a usable model cannot be detected from configuration; the flag
is how an operator says so.

Refs #599
2026-09-04 13:00:15 +03:00
Yiorgis Gozadinos
5653e876a8
Return search results as agent text, images and structured content
search_documents and search_documents_by_image return a ToolResult: the
format_for_agent rendering with rank, Document ID and Collection so the
text alone drives the document tools; one ImageContent per distinct
picture, labelled with its result; and the SearchResult list without
image_data as structured content. format_for_agent gains an opt-in
include_document_id, so the capabilities' rendering is unchanged.
collect_pictures is the one place pictures are deduplicated and
validated for both wire formats.

Refs #599
2026-09-04 13:00:15 +03:00
Yiorgis Gozadinos
15afb97a6e
Navigate documents by outline and section from the MCP server
build_toc moves from the sandbox into haiku.rag.context; the sandbox
keeps its toc.json unchanged. get_document_outline returns the heading
tree with page numbers and get_document_section one section's text,
subsections included, both resolved in the database holding the
document. Chunk ids never leave the server. ask_question drops `cite`
and always appends its citations.

Refs #599
2026-09-04 13:00:15 +03:00
Yiorgis Gozadinos
2a6d72171d
Make MCP failures errors on the wire
FastMCP masks unexpected exceptions and logs the traceback server-side,
so paths and provider URLs never cross the transport. Expected failures
raise ToolError with a message: unknown document, unknown collection,
invalid filter, invalid base64, and agent failures naming only the
exception type. No tool returns an empty value or an error string on
failure any more.

A filter is validated on its own before the read that would use it, with
a filtered count on one of the selected databases: that is the query
engine rejecting the filter and nothing else, so its message (columns and
the statement) can be forwarded, while a ValueError raised later in the
read stays masked and no database outside the selection is opened.

Refs #599
2026-09-04 13:00:15 +03:00
Yiorgis Gozadinos
88cd2b1d6c
Describe the MCP server and its tools to clients
FastMCP gets instructions, the haiku.rag-slim version and a lifespan.
Every tool carries read-only ToolAnnotations with a title, a description
that says when to use it, and a description on every parameter. The
filter description lists the document columns from DocumentMetaRecord
and how to match metadata; filter is accepted by both search tools.
One strict base64 decoder serves every image parameter.
DocumentInfo carries metadata.

Refs #599
2026-09-04 13:00:15 +03:00
Yiorgis Gozadinos
40d40bcbf2
Cover the configured database set from the MCP server
`haiku-rag mcp` passes covers_set=True and _covering no longer refuses a
scope over several databases. search_documents, search_documents_by_image,
ask_question and analyze take `sources`; get_document takes `source`;
DocumentInfo carries `source`. format_citations gains include_source, which
ask_question sets from covers_multiple so citations name their database
only when the server covers several.

Refs #599
2026-09-04 13:00:14 +03:00
Yiorgis Gozadinos
ce69a8c989
Remove the MCP write tools; the server opens read-only
add_document_from_file, add_document_from_url, add_document_from_text
and delete_document are gone. create_mcp_server and _covering lose
read_only; the client always opens with read_only=True, so the
--read-only flag before `mcp` is redundant and the docs, Dockerfiles and
compose example drop it.

Ingestion is `haiku-rag add`/`add-src`/`delete` and haiku-ingester. No
known consumer used the write tools; one stdio server per client window
made multi-writer the accidental default, and streamable HTTP has no auth.

The test_app stub drops a comment and __init__ that described run_mcp
constructing the client positionally; every path goes through _covering.

Refs #599
2026-09-04 12:59:48 +03:00
Yiorgis Gozadinos
7eeddd1a7c
Start MCP server revisited (#599) 2026-09-04 12:59:08 +03:00
Yiorgis Gozadinos
cf674b93ce
Merge pull request #601 from ggozad/chore/update-default-models
Update default models to qwen3.8, disable telemetry in tests.
2026-09-04 04:53:08 -05:00
Yiorgis Gozadinos
4290111333
Stop tests from sending telemetry to logfire
telemetry.configure() passes send_to_logfire="if-token-present", so on a
machine with a logfire token the CLI tests configured a live exporter:
spans and the periodic metrics export went to the developer's project, and
where the export fired while a cassette was open, VCR rejected it with
CannotOverwriteExistingCassetteException in record mode none.

A token resolves from LOGFIRE_TOKEN or from logfire_credentials.json under
LOGFIRE_CREDENTIALS_DIR (default .logfire, relative to the working
directory), so an authenticated developer uploads with the variable unset.
conftest drops the variable and points credentials discovery at an empty
temporary directory, alongside the existing HAIKU_RAG_CONFIG_PATH setup and
before any haiku.rag import. The explicit send_to_logfire argument beats
LOGFIRE_SEND_TO_LOGFIRE, so denying the token is what disables the exporter.
2026-09-04 12:36:36 +03:00
Yiorgis Gozadinos
a18bfd9781
Anchor the doclaynet fixture path on the test file
test_split_and_merge_matches_single_pass resolved tests/data/doclaynet.pdf
relative to the working directory, so it failed with FileNotFoundError for
any invocation outside the repository root. conftest.py resolves the same
file as Path(__file__).parent / "data"; this was the only site that did not.
2026-09-04 12:36:36 +03:00
Yiorgis Gozadinos
7f7223e0ac
Default to ollama:qwen3.8
Replaces gpt-oss on ModelConfig, qa.model and processing.title_model, and
ministral-3 on the picture-description model. qa.model.vision follows the
model and is now true.

enable_thinking was gated on the gpt-oss name, so it did nothing for
qwen3.8. With title_model's max_tokens of 100 the reasoning consumed the
whole budget and title generation returned an empty string. The mapping
now applies to any ollama model via reasoning_effort(): false sends
"none", true sends "high". Measured on qwen3.8:27b-mlx, "low" does not
disable thinking and "none" does; gpt-oss is the inverse, its template
has no "none" level, so it keeps "low".

Picture description bypasses get_model -- docling posts the request
itself from a params dict -- so the flag was inert on that path too.
vlm_api_params() carries reasoning_effort into both converters' request
bodies. At max_tokens 200 the description survived either way, but the
switch cut completion tokens from 141 to 45.

test_search_tool_skips_binary_content_when_qa_model_is_text_only asserted
the vision default rather than setting it; it now configures vision=False
itself.

docs/benchmarks.md keeps ministral-3: those are recorded measurements.
2026-09-04 12:36:36 +03:00
Yiorgis Gozadinos
660c86f991
vb 2026-09-03 18:00:43 +03:00
Yiorgis Gozadinos
376dcb2e6f
Merge pull request #596 from lawrenceakka/tz-fix
Fix for missing timezone info on ingested docs. mcp schema requires TZ
2026-09-03 09:59:14 -05:00
Yiorgis Gozadinos
4bf4699ee0
Note that offsetless stored timestamps are read as host-local time 2026-09-03 17:48:18 +03:00
Lawrence Akka
880551deba
Use astimezone, add timezone tests, linting 2026-09-03 17:48:09 +03:00
Lawrence Akka
9c56f56660
Fix missing timezone info on ingested docs. mcp schema requires TZ 2026-09-03 17:48:01 +03:00
Yiorgis Gozadinos
f4c657e094
Document LanceDB Cloud region as required 2026-09-03 17:44:32 +03:00
Yiorgis Gozadinos
5bf4cb50aa
vb 2026-09-03 15:43:56 +03:00
Yiorgis Gozadinos
75547f1cbd
Merge pull request #598 from ggozad/chore/db-entry-refactor
Replace lancedb.uri with lancedb.databases; name every database; remove HAIKU_RAG_DB and DB_PATH
2026-09-03 07:42:53 -05:00
Yiorgis Gozadinos
187946a7ae
Name the remedy for a missing configured database and list the databases an unknown name could have meant
A configured or default database that does not exist raises
SourceUnavailableError with the way to create it and without its location.
An unknown --db-name lists the databases there are, configured or default.
The CHANGELOG records Store.db_path, SingleDatabaseSession(ref, config),
the empty lancedb.uri migration and the error-type change.
2026-09-03 15:26:42 +03:00
Yiorgis Gozadinos
3814b2bc78
Report a stemless --db path as a usage error and separate the two lancedb.uri migrations
An empty lancedb.uri, which init-config used to emit, is told to remove the
key; only a non-empty value is told the databases spelling. haiku-rag --db
and haiku-ingester --db turn a path with no stem into typer.BadParameter.
2026-09-03 15:12:09 +03:00
Yiorgis Gozadinos
a04a16c717
Remove the environment overrides and the last unnamed-database wording
HAIKU_RAG_DB and DB_PATH are gone: a capability covers what the
configuration places or the db_path it is given, and the app backend and
the AG-UI example load their configuration as the CLI does. The compose
files point HAIKU_RAG_CONFIG_PATH at the mounted haiku.rag.yaml, which
places the database at /data where DB_VOLUME is mounted; the backend
refuses a configured set since it serves one database. The chat scopes a
selection by source only over a set and names databases on filter rows
only across several. Docstrings, docs and test fixtures stop describing an
unnamed database; every database a search, listing or citation reports
carries a name.
2026-09-03 15:12:09 +03:00
Yiorgis Gozadinos
34180a0fd1
One reference and one placement for a database
DatabaseRef is a name and a location. The configuration places databases
through lancedb.databases alone; with none configured the default is the
entry haiku.rag under storage.data_dir, selectable like any other.
lancedb.uri is removed, and a config carrying it fails to load with the
replacement spelled out. A path passed from Python is valid where the
configuration places nothing and raises AmbiguousDatabaseError beside
lancedb.databases; haiku-rag --db and haiku-ingester --db construct the
scope directly, so a human's override keeps working. Every database
answers to a name, and a database given as a path keeps its own errors.
2026-09-03 15:12:08 +03:00
Yiorgis Gozadinos
9baa213b34
Hand storage the database location, not the configuration that placed it
Store, connect_lancedb, gather_database_info and run_doctor take a
location, a path or a URI, and classify it with ConnectionMode.of.
SingleDatabaseSession owns the resolved DatabaseRef and passes its
location down. This removes DatabaseRef.connection(), default_db_path,
the placeholder path for URI-backed databases and the per-database
config copies, so the configuration a client holds is the one the caller
gave it. The chat hands its capabilities the scope it opened along with
the client it lends, and the v0.58.0 migration no longer checks local
free disk for a database behind a URI.
2026-09-03 15:11:37 +03:00
Yiorgis Gozadinos
4f7f69aaf0
Merge pull request #597 from ggozad/feat/search-fanout-units
Price search budget in units and deduplicate fan-out results
2026-09-03 07:09:13 -05:00
Yiorgis Gozadinos
31fc8aba29
Guard dedup against compaction and document search units
A picture deduplicated within a burst must still reach the model once in
its own question and, when cited, again via the capsule re-fetch after
compaction; uncited it is dropped. Two wire tests pin both paths. The
harness builds the rag capability with defer_loading=False: a deferred
evidence capability the model has not loaded presents an empty record,
so compaction computes boundary 0 and rewrites nothing.

qa.md describes max_searches in units.
2026-09-02 12:29:12 +03:00
Yiorgis Gozadinos
d9f489dcc8
Deduplicate search results within one model response
Sibling searches emitted in one response overlap heavily (40.6% of
returned chunk slots on Glimmer ORB fan-out cases). A result whose
rendered evidence a sibling already showed keeps its rank slot but
collapses to a reference line, and a picture attaches once per response
keyed on (source, document_id, self_ref). Equivalence is the
format_for_agent rendering at neutral rank/total plus picture keys,
bucketed under the qualified chunk id, so another database's copy or a
different expansion of the same anchor formats in full.

Search state now commits only after formatting and image construction
succeed: a raising image build no longer leaves results citable that
the model never saw, notes evidence for them, or suppresses a later
sibling.
2026-09-02 12:29:12 +03:00
Yiorgis Gozadinos
ddac328d05
Price qa.max_searches in search units
Searches a model emits in one response share a budget unit, up to
FREE_SIBLINGS_PER_ROUND (3) per unit; sequential searches pay one unit
each, as before. Grouping keys on RunContext.run_step, which pydantic-ai
increments once per model request. A budget-rejected round fails all its
remaining siblings, and tracking resets per run.

Glimmer opens most questions with a burst of ~3 rephrasings in a single
response (95.8% of its three-search ORB cases are one-response bursts),
spending 3 of 5 searches before reading anything. Pass rate at 3 calls
equals 1 call, so a burst is priced as one probe.
2026-09-02 12:29:12 +03:00
Yiorgis Gozadinos
f63dec5f75
vb 2026-09-01 16:07:03 +03:00
Yiorgis Gozadinos
2bb169867c
Merge pull request #595 from ggozad/feat/vector-nprobes
Expose search.vector_nprobes
2026-09-01 08:05:19 -05:00
Yiorgis Gozadinos
2d8b2cb9ed
Expose search.vector_nprobes
The IVF probe count was the one vector search parameter with no
setting, and it is what bounds recall on a large indexed corpus:
partitions grow with the corpus, so a fixed probe count covers less of
it, and vector_refine_factor can only re-score what the probes
returned. Defaults to 20, matching lance, so search is unchanged.

Also corrects the create-index docs, which predate the measurement:
optimize() covers new chunks, so a rebuild is about retraining
centroids rather than reaching unindexed rows.
2026-09-01 15:54:36 +03:00
Yiorgis Gozadinos
f7e9535d7b
Merge pull request #593 from ggozad/fix/cross-database-fusion
Order cross-database fusion by cosine similarity to the query
2026-09-01 07:36:12 -05:00
Yiorgis Gozadinos
7be71ebbac
Order cross-database fusion by cosine similarity to the query
Retrieval scores are each database's own rank arithmetic; the databases
in a selection share an embedder, so similarity in that one space is the
signal comparable across databases by construction. Measured product to
product against score ordering: +8.3 to +16.6pp recall@5 across five
cells on two corpora, flat in collection count and corpus shape where
score ordering dips with both, closing roughly 60% of the gap to a
reranker; order-sensitivity residual 0.00pp in every cell. Exact ties
collapse from 51-81% of candidates to under 1%. Full-text-only searches
keep retrieval-score order, having no query vector. The vector column
already travels with every search result, so the similarity costs no
additional transfer; per-chunk embeddings are materialized only for the
federated path that reads them.
2026-09-01 15:26:42 +03:00
Yiorgis Gozadinos
82fe91bb76
Order cross-database fusion by retrieval score
Rank interleaving guarantees every database slots regardless of content;
on domain-split collections it allocates no better than chance and costs
4.7pp recall@5 at four collections against score ordering (7.1pp at
eight). Hybrid scores are each database's own vector/FTS rank agreement,
which carries across databases; equal scores resolve by within-database
rank, and only a tie on both falls to configured order, leaving
permutation sensitivity at 0.02-0.26pp. Fused results carry the
candidate's own retrieval score, so the context-expansion re-sort
preserves fused order.
2026-09-01 15:26:41 +03:00
Yiorgis Gozadinos
d06277408b
Break cross-database RRF rank ties by retrieval score
Disjoint corpora give every database's rank-r candidate the same RRF
score, and the stable sort resolved those ties to lancedb.databases
declaration order, discarding the retrieval scores entirely. Ties now
break on the raw retrieval score, which is uncalibrated across indexes
but only ever orders candidates within one rank tier: the databases in
a fusion share an embedder and ran the same search type, and it can
never lift a candidate above another rank. Hybrid per-database scores
are themselves rank-derived, so exact agreement still ties and keeps
configured order, deterministically. The n > limit depth quota is
unchanged, pending the retrieval eval.
2026-09-01 15:26:41 +03:00
Yiorgis Gozadinos
f927998643
Merge pull request #594 from ggozad/docs/vector-index-state
Document vector index state and measured cost
2026-09-01 07:23:50 -05:00
Yiorgis Gozadinos
c0cf612d68
Document vector index state and measured cost
Benchmarks state that every published number is exact brute-force kNN;
no benchmark database carries a vector index. The Vector Indexing
section gains the measured with/without IVF_PQ comparison (hotpotqa,
orb_multimodal_nemotron, frames: free to ~121k chunks, 0.0044 MAP at
426k, ~30 s / 4 GB build) and corrects the re-indexing story: optimize()
folds new chunks into the index as delta parts via auto_vacuum, so a
rebuild is about retraining centroids, not covering new rows.
2026-09-01 15:19:13 +03:00
Yiorgis Gozadinos
a7efd76016
vb 2026-08-31 20:04:16 +03:00
Yiorgis Gozadinos
b0818f4d3d
Merge pull request #591 from ggozad/fix/fts-index-coverage
Guard, prevent, and repair an FTS index that covers no rows
2026-08-31 11:05:30 -05:00
Yiorgis Gozadinos
e49f4264d3
Update lancedb to 0.37.1
The newest release with wheels for every supported platform: 0.38.0
publishes none for x86_64 Linux or Windows. The zero-coverage FTS scan
path is still broken at 0.37.1, so the guard, prevention and repair
carry unchanged. ensure_indexes names the index it declares, keeping
replace deterministic on 0.38+, where an unnamed create_index builds a
suffixed sibling instead of replacing a different-typed index. An
invalid search filter raises ValueError.
2026-08-31 18:54:48 +03:00
Yiorgis Gozadinos
02bccb1b90
Repair an FTS index that covers no rows on write
Deleting or replacing every indexed row while unindexed rows remain
returns lance to the zero-coverage scan path. ensure_indexes rebuilds
a declared FTS index that covers none of a populated table's rows,
and delete_by_document_id runs index maintenance like the other chunk
writes. A legacy database in that state is repaired by its first write.
2026-08-31 18:54:48 +03:00
Yiorgis Gozadinos
35f8d721b1
Build the chunks FTS index on the first write instead of at table creation
An FTS index built over an empty table indexes nothing and lance never
catches it up on add. ensure_indexes skips FTS while the table is
empty; create, replace_for_document and embed-only rebuild ensure
indexes after writing, so the index always covers at least its first
rows. The first write into a fresh table writes one extra chunks table
version for the index build; a failed build fails the write.
2026-08-31 18:42:31 +03:00
Yiorgis Gozadinos
61df6b65a6
Guard against an FTS index that covers no rows
lance serves unsorted results with matching rows dropped when a
declared FTS index has indexed nothing, or when a populated table has
no FTS index at all. doctor fails on both: vacuum remediation for an
existing index, rebuild --embed-only for a missing one, since optimize
never creates an index. The chunk repository warns once per repository
on the first FTS or hybrid search against either state; a failing
coverage check is logged and never fails the search. Removes
_ensure_fts_index, which had no callers.
2026-08-31 18:42:31 +03:00
Yiorgis Gozadinos
2eaf2c6aea
Split the #590 CHANGELOG entry into Removed and Fixed 2026-08-31 18:41:14 +03:00
Yiorgis Gozadinos
d2b2acef2c
Merge pull request #590 from icearia0219/codex/fix-vector-metric-consistency
Honor configured vector metric during search
2026-08-31 10:39:35 -05:00
Yiorgis Gozadinos
07421a7dd6
Merge pull request #589 from ggozad/feat/full-citations
Add --full-citations to ask and analyze
2026-08-31 08:44:41 -05:00
icearia0219
b93270adce Honor configured vector metric during search 2026-08-31 21:38:00 +08:00
Yiorgis Gozadinos
22a5b7d17c
Add --full-citations to ask and analyze 2026-08-31 16:31:09 +03:00
Yiorgis Gozadinos
b87ae16910
Merge pull request #587 from ggozad/fix/v0-38-0-migration-zstd
Accept zstd docling blobs in the 0.38.0 migration
2026-08-31 04:29:51 -05:00
Yiorgis Gozadinos
e0bde9d59a
Test the 0.20.0 and 0.25.0 migrations
Both were exempt from coverage, which is how the 0.38.0 blob-encoding bug
reached a release: nothing exercised the chain that produced it.

Move the historical `documents` shapes into tests/store/legacy_documents.py
so each migration's tests can seed the table as its predecessor left it.
2026-08-31 12:13:12 +03:00
Yiorgis Gozadinos
6133e5ff4a
Accept zstd docling blobs in the 0.38.0 migration
v0.25.0 compresses through compress_json, which switched from gzip to
zstd in 0.38.0 — the same release that added this migration. v0.38.0
decompressed with gzip and fell back to a raw UTF-8 decode, and both
fail on a zstd frame, so upgrading any database older than 0.25.0 has
died with UnicodeDecodeError since the migration shipped.

Reported with a fix by @omaer0 in #586.
2026-08-31 12:13:10 +03:00
Yiorgis Gozadinos
a1738c9d3e
vb 2026-08-28 15:56:00 +03:00
Yiorgis Gozadinos
eb50c9c587
Merge pull request #584 from ggozad/fix/reject-unknown-chat-providers
Reject unknown chat model providers and rename gemini to google
2026-08-28 15:55:09 +03:00
Yiorgis Gozadinos
0c67db4459
Accept a /v1 suffix on the vLLM reranker base_url
vllm_base_url moves to utils.py and is shared with the embedder, so the same
endpoint works written either way. Writing /v1 posted to /v1/v1/rerank.
2026-08-28 15:46:42 +03:00
Yiorgis Gozadinos
ad100ecd4d
Reject unknown chat model providers and rename gemini to google 2026-08-28 15:46:20 +03:00
Yiorgis Gozadinos
d0c66eef8f
Merge pull request #578 from ggozad/feat/multi-db
Use multiple databases simultaneously
2026-08-28 15:44:05 +03:00
Yiorgis Gozadinos
afdef92b5b
Finish the comment pass, and escape document fields everywhere Rich renders
`_rich_print_document` escapes uri, title and metadata, the sibling of
the escaped search-result renderer. The remaining comments and
docstrings that narrated rejected alternatives, consequences or history
now state the current invariant. The Sandbox class docstring names the
held connection close() releases, and wrapped docs paragraphs join to
one line.
2026-08-28 15:34:47 +03:00
Yiorgis Gozadinos
09a7076b7e
State what the code does, not what it replaced
Comments and docstrings across the branch narrated rejected
alternatives, consequences and history; each now states the current
contract. Renames test_a_legacy_uri_client_keeps_its_error to
test_an_unnamed_database_keeps_its_error. Documents the Sandbox
connection paths, the citation header's database segment, both
AmbiguousDatabaseError conditions on create_app, and run_inspector's
scope parameter. Doc paragraphs added by the branch in python.md,
storage.md and cli.md are one physical line each.
2026-08-28 15:13:52 +03:00
Yiorgis Gozadinos
d0df382ce8
Release every sandbox resource, whichever teardown fails
Sandbox.close released the pool and the held federated client only when
the monty session's __aexit__ returned. Each release now runs under
suppress(Exception), matching _discard_session and aclose_quietly, so a
raising step neither masks an unwinding error nor leaks the databases a
federated `_opened` holds.
2026-08-28 15:13:28 +03:00
Yiorgis Gozadinos
ee835b77c2
Select a filtered document from the database that holds it
The chat filter modal keys a selection by (database, id): copies of a
database share document ids, and checking one copy left the other
reading as selected. Applying the filter narrows the question's
`sources` to the databases the selection names. A twin id inside
another selected database still matches there: a serialized id filter
cannot carry a source.
2026-08-28 15:12:55 +03:00
Yiorgis Gozadinos
b601489896
Render titles and database names as text, not markup
A database name, document title, uri or heading containing Rich markup
crashed search output, chat citations and the chat document filter with
MarkupError. Every interpolation into markup-parsed text now escapes.
2026-08-28 15:12:19 +03:00
Yiorgis Gozadinos
2bfb661c10
Pin the over-fetch rule, and assert the type a lookup raises
`_fetch_limit` had no test: a text query over-fetches `limit * 10` only with a
reranker, and an image query keeps its vector ranking either way. The sandbox
test asserted `KeyError`, which `UnknownDatabaseError` subclasses, so it could
not tell the contract from a bare one.

`uses_configured_databases` documents a mapping of one as covered; the test
named for it passed no mapping at all. Its `config` parameter is an `AppConfig`.

`test_an_analysis_capability_mounts_the_configured_set` carried a VCR marker and
no cassette, making no HTTP calls.
2026-08-28 14:03:13 +03:00
Yiorgis Gozadinos
688d7e708e
State what comments guarantee, not what would go wrong without them
Comments that narrated a failure mode across three or four lines say the
invariant they protect instead: the repository's late embedding, one database
keeping its hybrid scores, one over-fetch decision for a selection, and a cite
fallback that covers exactly what the question covers.
2026-08-28 14:03:05 +03:00
Yiorgis Gozadinos
81e05cba7a
Name what a command calls its database, and whether it exists
`tag_restore` reached into `HaikuRAGApp._display_path` and `_path`, and seven
places spelled out `self._is_local and not self._path.exists()`. `display_path`
and `database_missing` say both, and `database_missing` is False for a database
behind a URI, which has no path to check.

`init` keeps its own check: it asks the opposite question.
2026-08-28 14:02:56 +03:00
Yiorgis Gozadinos
1de1b662ae
Render database names and failures verbatim in the inspector
A database name, a location and an exception message all reach the info modal
as configuration-derived text, and Rich reads `[...]` in any of them as markup:
a name like `beta [prod]` disappeared, and a message carrying `[Errno 2]` or a
stray closing tag could break the line it sat in.
2026-08-28 14:02:50 +03:00
Yiorgis Gozadinos
7dc853606f
Name the collection a chat citation came from
Two collections can hold documents with the same title, and the collapsed
citation carried only that title, so the two read as one source repeated. The
name is appended when the client covers a set, the decision already made for
search results and retrieved images.
2026-08-28 13:30:11 +03:00
Yiorgis Gozadinos
590719fca7
Read a document, chunk or picture from the database that names it
`resolve_document` and `find_document` selected a document through a listing,
then dropped its source and looked the id up across the set. Ids repeat between
copies of a database, so a title that matched in one could be answered by
another's document.

`get_document_by_id` and `get_chunk_by_id` join `get_picture_bytes` in taking an
optional `source`, and all three route it through `clients_covering`, so a name
the client does not cover raises `UnknownDatabaseError` rather than being
answered by the database it does cover. Without a source the reads are as they
were, answering from the first database in configured order that holds the id.
2026-08-28 13:30:05 +03:00
Yiorgis Gozadinos
2325f187b5
Fix a client's coverage when it is first entered
`_resolve_scope` returned a scope without keeping it, so a client re-entered
after its configuration was edited covered whatever the configuration then said.
Resolving once is what the rest of the design rests on: the scope is what names
results, citations and errors.

A configuration is still free to change before first entry.
2026-08-28 13:29:16 +03:00
Yiorgis Gozadinos
fa319596cc
Name the collection on a retrieved image, not only in the text
Search results and capsule entries name the collection they came from; the
images attached beside them carried only the chunk id and reference. Two
collections can return the same picture of the same document, so the two
labels were identical and the model could place neither.

The decision is the one already made for the text: `covers_multiple` at the
generic search tool, and the flag `search_corpus` computed for the capability
tools, which it now returns.
2026-08-28 12:02:16 +03:00
Yiorgis Gozadinos
bfd09f6880
Leave no database reading when a federated fan-out fails
`asyncio.gather` propagates the first failure while its siblings run on, and the
caller unwinding from that closes the set through `async with` — so a sibling
still reading reads through a closed session. Seven fan-outs were affected:
lookup, search, image enrichment, multimodal picture loading, context expansion,
document listing and counting, and the sandbox's document load.

`gather_all` cancels and drains the rest, then re-raises the original exception.
A `TaskGroup` would drain them too but raise an `ExceptionGroup`, which every
caller and both CLIs' exception handlers would have to unwrap. The two
`return_exceptions=True` gathers in session opening and teardown already drain
their children and are left alone.
2026-08-28 12:02:07 +03:00
Yiorgis Gozadinos
225a37ca73
Tell one collection's pictures from another's
Three places identified a picture by document and reference alone, and one
identified a citation's images by chunk id alone. Both repeat between copies of
a database, so a search returning a figure from two collections sent one, a
capsule retained one, and a citation rendered the other collection's figures.

Keyed on the source as well: `(source, document_id, self_ref)` for search
pictures, the capability and source for retained ones, and `qualified_id` for
the chat's citation images.
2026-08-28 10:35:46 +03:00
Yiorgis Gozadinos
9dc79e7fda
Name the collection each piece of cited evidence came from
A capsule replaces earlier questions' evidence with what they cited, so the
searches that carried a `Collection:` line are gone by the time the model reads
it. Cited content survived; which collection it came from did not, and a
follow-up question attributed it to whichever the model guessed.

Named the same way a search result names it: the capsule decides, and only when
it spans more than one.
2026-08-28 10:35:38 +03:00
Yiorgis Gozadinos
dafc15978e
Translate every database-opening failure in haiku-ingester
`ConfigMismatchError` and `SourceUnavailableError` escaped the entry point as
tracebacks rather than a message and exit code 1. The entry-point test is
parametrized over the translated types, so the two CLIs' lists cannot drift
apart silently again.
2026-08-28 09:59:37 +03:00
Yiorgis Gozadinos
5e81054a1e
Apply the filter modal's search on enter, not as you type
Typing narrowed the mounted checkboxes without touching the search the listing
was built from, so a term matching more than one page hid rows from whichever
page the user happened to be on, left the rest of the matches a page away, and
paged on the previous search while the term still sat in the box. The term now
applies on enter, and the footer says so until it does.
2026-08-28 09:59:33 +03:00
Yiorgis Gozadinos
bee67e736a
Say what a federated client owns, and trim four comments
The set shares the reranker its facades borrow, not an embedder: each covered
database builds and closes its own. Sharing one needs the lender pattern inside
`Store`, since a store's embedder also serves writes and is closed with the
session, so it is left as follow-up rather than done here.

`reported_location` returned `client.location` and nothing else; its test
asserted that a mock returns what it was given. `list_documents` names its
results through `name_all`.
2026-08-28 09:15:26 +03:00
Yiorgis Gozadinos
249c51c65a
Document which failure a database that will not open raises
Four passages said every missing database raises `FileNotFoundError` and that
errors show locations. A database named in `lancedb.databases` raises
`SourceUnavailableError` instead, naming the database and not its location,
which is the point of naming them. A path you gave keeps `FileNotFoundError` and
still shows the path.
2026-08-28 09:15:26 +03:00
Yiorgis Gozadinos
b5aa0e7122
Serialize the sandbox's shared connection, not its owners
The lock was applied to an owner as well, so every owner-backed file read queued
behind the capability's tool calls to guard state it does not touch. An owner is
a session of its own and is yielded straight through, which is what the
docstring already claimed.
2026-08-28 08:47:37 +03:00
Yiorgis Gozadinos
bd8c1a6d15
Render a citation whose pictures no database claims
Chat asked the covering client for a picture with the citation's source, which
raises when there is none, losing the answer to one figure. It resolves the
reader first and omits the bytes when the database cannot be placed, as the rich
formatter already did.
2026-08-28 08:47:36 +03:00
Yiorgis Gozadinos
202cbd2d3f
Name the database a write wrote to
Reads tagged the document they returned and writes did not, so creating in
`alpha` came back with `source=None` while reading the same document came back
with `"alpha"`. Every outward write result goes through the owning session.
2026-08-28 08:47:36 +03:00
Yiorgis Gozadinos
cad472e999
Let a config-only command work on no database
`settings` and `download-models` read the configuration and open nothing, but
resolved a scope to be constructed, so `--db-name nope` failed them over a
selection they never use. `HaikuRAGApp` takes no scope for those, and asking it
for one is an error rather than a silent default.
2026-08-27 18:04:28 +03:00
Yiorgis Gozadinos
4d7fdd2d26
Record what the multi-database work changes, and what it measured
`--db-name` selects one database, not a subset: it is not repeatable.

The reranker recommendation was one-sided. It reports both measured effects now:
stronger aggregate retrieval across shards, and weaker attribution between
near-identical documents, where fusion keeps twins apart because each database
contributes its own top-ranked result.

Image queries are vector-only and skip the reranker.

The changelog described components of the new feature as fixes to the last
release. They are one Added entry, and the changes a user upgrading does see —
`settings` printing YAML, the document filter paging and searching, `list`
printing only the fields a document has — are listed.
2026-08-27 18:04:28 +03:00
Yiorgis Gozadinos
0dec3d4e63
Make three tests assert what they are about
`test_an_image_query_builds_no_reranker` never submitted an image query: it
opened clients and found nothing built, which is lazy construction. It sends one
now, so consulting the reranker before the query type fails it.

`TestComparingEmbedders` called `_require_one_embedder` and asserted nothing.
It rejects a disagreement and accepts an absent record, and searches through.

Three `ModelRetry` tests took any reason where the reason is the subject.
2026-08-27 18:04:28 +03:00
Yiorgis Gozadinos
95c02addd7
Name a remedy the command being used has
`haiku-ingester` reached the client's refusal, which names `sources=[name]`, a
Python argument no CLI user can pass. It refuses a configured set itself, with
`--db PATH` and the one-ingester-per-database layout.

`haiku-rag`'s own refusal now says where each option goes: `--db-name` is global
and precedes the command, `--db` follows it.
2026-08-27 18:04:28 +03:00
Yiorgis Gozadinos
5ecffdedf2
Read a created database's embedder with its settings
Creating re-read the settings blob and left `stored_embedding` at None, so a
client that created a database compared as though it recorded no embedder.
`_remember_settings` takes both, and the comment no longer says one follows the
other.
2026-08-27 18:04:27 +03:00
Yiorgis Gozadinos
18c22f1ddb
Say what the sandbox's connection lock guards
The lock claimed to serialize a whole read, while `_documents` resolves the
owners under it and then reads through them outside. That is right: resolving
the owners is an operation on the shared connection, reading through them is
not, and each owner is a session of its own. Holding the lock across a read per
database would serialize them against the capability's searches to guard state
none of them touch.
2026-08-27 18:04:27 +03:00
Yiorgis Gozadinos
b6bc54c69e
Answer for an unknown database name with one type
A name nothing covers raised `KeyError` in four places and
`AmbiguousDatabaseError` in a fifth, so a caller had to catch both and neither
name said what happened. `UnknownDatabaseError` is all of them, exported from
`haiku.rag.store` beside the other errors.

It subclasses `KeyError`, since selecting by name is a lookup, and prints its
message plainly rather than quoted as a missing key. Both CLIs turn it into the
same clean exit they already gave the others.
2026-08-27 18:04:27 +03:00
Yiorgis Gozadinos
cb9f945c79
Give every client shape a way to be released
`close()` refused a client covering a set, which left one with no method to
call: `async with` was the only lifecycle it had. `aclose()` runs that teardown
for a caller that owns the client some other way, whatever it covers, and
nothing to release is not an error, so it is safe before entering and after
closing.

`close()` stays what it is, one connection and nothing else, and says so:
draining the background vacuum and releasing the embedder and reranker are
awaitable.
2026-08-27 18:04:27 +03:00
Yiorgis Gozadinos
105b2628de
Page the filter modal, and list the selected
A selection outside the page stayed applied while its checkbox was gone, so
there was no way to remove it. Appending those documents to the page instead
loses the bound the page exists to keep, since selections accumulate across
searches.

Both listings page at `DOCUMENT_PAGE`, and `Selected` switches between them, so
the mounted widgets stay bounded whichever is showing and every selection is a
page away rather than unreachable.

The count reads the checkboxes on screen, so narrowing as the user types reports
what is visible. A listing with nothing in it says so, instead of leaving an
empty box that reads as still loading.
2026-08-27 18:04:27 +03:00
Yiorgis Gozadinos
2fef67d9fd
Name the databases before the model runs
`ask(sources=["typo"])` reached the model, which discovered the name only if it
searched: requests spent on a selection that could never answer, and a run that
never searched answered anyway.

Checked by name, not by opening: a client covering a set opens a database when a
query reaches it, and validating by opening would open every one of them before
any search, letting a database nobody asked about fail the run.
2026-08-27 17:07:41 +03:00
Yiorgis Gozadinos
8791f12c37
Build one reranker for the databases searched together
Every client covering a database built its own, so a set of five loaded the same
local model weights five times over, beside the federator's. A client covering a
database for another borrows that one's, and only its owner closes it.
2026-08-27 17:07:41 +03:00
Yiorgis Gozadinos
64b1096865
Mark a picture no database claims
`format_citations_rich` asked a client covering a set for a picture whose
citation named no database, which raises, losing the whole answer to one figure.
Citations recorded before databases could be named carry no source, so the
figure marker the caller already renders stands in.
2026-08-27 16:01:30 +03:00
Yiorgis Gozadinos
de6da8b375
Pin what reciprocal rank fusion produces
`_fuse` ran in tests without anything asserting its output, so reversing the
sort or dropping the truncation changed nothing. Databases interleave by rank,
the score is the reciprocal of it, ties keep configured order, and the limit
cuts the fused list.

Every native score in one database beats every one in the other, so ranking by
score rather than position fails all four.
2026-08-27 16:01:28 +03:00
Yiorgis Gozadinos
5f795cb0cb
Let the MCP server be told which database once
`create_mcp_server` promised one database and accepted a scope covering a set,
where the write tools exist and fail on use. It refuses that now.

Resolving is the public factory's job, as it is `HaikuRAG`'s: `_covering` takes
a scope someone already resolved, so the configured name survives without a
`DatabaseScope` reaching the public signature.

The test that a scope decides the database asserted `all(...)` over a search
that could return nothing, which held whatever the server read. It reads the
listing instead, so alpha's documents being present and beta's absent both have
to be true.
2026-08-27 15:41:03 +03:00
Yiorgis Gozadinos
acea8cbaac
Prove the lend without reaching the embedder
The test searched through the capability, which embeds the query, so it passed
against a local Ollama and failed CI where there is none. What it exists to show
is that a lent client is what the capability reads through: `_ensure_rag`
returns it, and a full-text search through it names its results.
2026-08-27 15:26:09 +03:00
Yiorgis Gozadinos
c778fbf527
Refuse a path and sources together
`sources` was ignored beside a database path, so `sources=["nope"]` opened the
path and read as though the selection had been honoured. A path and a name
already conflict inside `DatabaseScope.resolve`; this is the same rule where a
caller can reach it.
2026-08-27 15:12:22 +03:00
Yiorgis Gozadinos
db7f0e0af6
Give the MCP server the database, not a description of it
`run_mcp` derived a path and a configuration for the server, and deriving drops
the configured name: MCP results and citations carried `source=None` where the
same database named through any other path carried "alpha".

`create_mcp_server` takes the resolved scope and opens through it, so the name
survives. Passing a path and configuration still works and resolves to the same
place.
2026-08-27 15:12:13 +03:00
Yiorgis Gozadinos
d93cb5c891
Refuse a name the client does not cover, however many it covers
`reader_for` returned itself before looking at the name, so an "alpha" client
answered `reader_for("beta")` with alpha's reader. A citation naming another
database would have been read from the wrong one.

Both paths decide through `clients_covering` now, so one database refuses a
wrong name the way a set already did.

The chat citation test said mounting lends the client without showing it; a
chat test shows it and the docstring points there.
2026-08-27 14:49:40 +03:00
Yiorgis Gozadinos
45a5e68da5
Say what reader_for and an empty sources mean
`reader_for` documented None as covering any database it could not place, but a
name outside the set raises `KeyError` like `clients_for` does: provenance
naming a database this client does not cover is wrong rather than absent. None
means one thing, a federated client given no name.

`sources=[]` means two things. On a search it selects nothing to search; on the
constructor it raises, since a client over no database can do nothing. Both are
written down now.

A capability reads through a lent client, so what a citation records is that
client's database and not the scope the capability was built with. Chat lends
one, and had no test saying so.
2026-08-27 14:41:26 +03:00
Yiorgis Gozadinos
474245ab59
Serve the database the MCP command selected
`run_mcp` passed `_path`, the local stand-in a URI-backed ref resolves to for
display. A path overrides `lancedb.uri`, so `--db-name` on an S3 database
served the local default. It passes the ref's own path now, None where a URI
placed the database, and `create_mcp_server` accepts that.

The client opened around the server is gone. It never served a request, and it
opened the scope rather than the derived path, so startup validated the remote
database while the server read the local one.
2026-08-27 14:41:16 +03:00
Yiorgis Gozadinos
9ed66e3a06
Search one database the ordinary way, however it was selected
`sources=["alpha"]` on a client covering a set went through fusion, which
scores position: a result the database ranked at 0.6549 was reported as
1/(60+rank). Embedding also moved ahead of the repository, so a filter matching
no document embedded the query anyway.

A selection of one now runs the single-database search. Fusion reconciles
rankings from separate indexes, and one ranking has nothing to reconcile.

A reranker that returns chunks it built rather than the ones it was given loses
which database each came from, since ownership is by identity. That is named
now instead of surfacing as a KeyError, and stated on `RerankerBase._rerank`.
2026-08-27 14:41:10 +03:00
Yiorgis Gozadinos
87367759f9
Say what makes a cited chunk id ambiguous
The documented rule was existence in more than one selected database. The rule
is narrower and does not need to be wider: a copy the search never returned
grounded nothing, so the retrieved result is the citation and its database is
recorded rather than guessed.

The page now states the whole rule, including the fallback's own check for an
id no search returned. Two retrieved copies are refused, and so is an id
already cited from another database in the conversation. The case between them
had no test.
2026-08-27 14:09:01 +03:00
Yiorgis Gozadinos
206d29b74a
Let the configuration place the ingester's database
The ingester manufactured `data_dir / haiku.rag.lancedb` whenever `--db` was
absent and passed it to HaikuRAG. A path is now an explicit override that
clears the configured URI, so a `lancedb.uri` deployment wrote to local disk
while the control plane reported on the remote.

`IngesterApp` resolves the databases it works on once, in its constructor, and
both the client it opens and the control plane read that scope. `--db` names
one directly and nothing stands in for it, so a configured set is refused
rather than guessed, and `AmbiguousDatabaseError` joins the errors `cli()`
turns into a clean exit.
2026-08-27 14:00:21 +03:00
Yiorgis Gozadinos
b8bf846bb7
Cite a repeated chunk from its last occurrence
Collision detection replaced the lookup's dict comprehension with
`setdefault`, which also flipped a chunk found by several searches from its
last occurrence to its first. The copies differ in everything the expansion
window decides, figures included, so that silently changed what a citation
renders.

The rules are separate now: a repeated (source, chunk_id) takes the later
result, a chunk_id under two sources is still refused.
2026-08-27 13:25:06 +03:00
Yiorgis Gozadinos
8a7cfb949f
Match capability instructions to the run's collection scope
The collection block was chosen when the capability was built, so a run narrowed
through `state.sources` to one collection was still told how to attribute across
collections it could not reach, while its results correctly carried no
`Collection:` line.

`get_instructions` composes it per run instead, from `state.sources` where the
question narrowed the conversation and from the lent client or the scope
otherwise. Order is preamble, base instructions, collection block.
2026-08-27 12:41:15 +03:00
Yiorgis Gozadinos
0d7810c78a
Render collection identity only for multi-collection searches
`format_for_agent` named the database whenever one was named, so a search over
a single named database carried a line with nothing to distinguish. It now takes
`include_collection` from the caller, which decides from the search selection
rather than from the hits: a search that could have drawn on two collections
names them even when everything came back from one.

`Collection:` at the model boundary, database in configuration and
administration. `source` on results, documents, citations and analysis
dictionaries is unchanged.
2026-08-27 12:41:05 +03:00
Yiorgis Gozadinos
746b663a2f
Correct the multi-database prose
`resolve_citations` documented the behaviour it now rejects: a shared chunk id
resolving to whichever result came last.

The storage page ran two embedding checks together as if the second explained
the first. They are separate: each database against the configuration on open,
and the databases in a selection against each other, which raises in read-only
mode too and does not apply to full-text search.

`several` becomes `multiple` where it names the feature, matching the docs and
`covers_multiple`.
2026-08-27 11:40:52 +03:00
Yiorgis Gozadinos
c8a9b5df50
Keep the sandbox's connection open for the owners it hands out
`_documents` took its owner clients inside an ephemeral connection and used
them after that connection closed, then stored them for the file reads that
follow, so every standalone multi-database read went through a closed database.

A sandbox covering a set now retains the client it opened until `close()`. One
database has no owners and keeps its connection no longer than the read that
opened it, so a write from elsewhere is still visible to the next read.
2026-08-27 11:40:43 +03:00
Yiorgis Gozadinos
9210642f65
Report where a named remote database actually is
A URI-backed database is constructed with a local `db_path` nothing connects
to, and the info modal reported that, so `--db-name` on an S3 database named a
local directory. `location` reads the configuration instead, and the modal
labels it as one.

`InfoModal`'s `db_path` argument is gone: both callers passed None, so only a
test reached the branch that used it.
2026-08-27 11:40:35 +03:00
Yiorgis Gozadinos
cfb880e8a0
Keep every database a cancelled fan-out opened
`sessions_for` recorded what opened from `gather`'s results, which arrive only
when it runs to completion. Cancelling it discarded them, so a database that
opened while a sibling was still pending was never recorded and `aclose` never
closed it. `return_exceptions=True` covers a failing child, not a cancelled
parent.

`_open` now registers its own session, so what opened is reachable however the
fan-out ends.
2026-08-27 11:40:24 +03:00
Yiorgis Gozadinos
b2c617f9f2
Name the example databases as the documentation does
`medic` and `st` become `papers` and `notes`.
2026-08-27 11:06:34 +03:00
Yiorgis Gozadinos
dfa5f8027e
Drop a patch that cannot detect what it guards
`database_lines` reports through the connection the client already holds and
never calls `connect_lancedb`, so patching it detects nothing; `asked ==
[connection]` is what proves the reuse.

It was not merely dead. The patch was installed by name, and the next
`monkeypatch.setattr` triggered the first import of `haiku.rag.store.info`,
whose module body binds `connect_lancedb` — capturing the fake into a
namespace teardown does not restore, failing four `test_info` tests under
`-n0`.
2026-08-27 11:06:29 +03:00
Yiorgis Gozadinos
0e0ce8d3f0
Fix document-filter searches after the initial load
Every load looked up the loading indicator, which only the first page has,
so pressing enter in the search box raised `NoMatches`. The indicator is a
child of the list `remove_children()` already clears.

A document's id and search text travel on a `DocumentCheckbox` instead of
being assigned onto a `Checkbox` behind type suppressions. The positional
widget ids are gone; nothing queried them.
2026-08-27 11:06:22 +03:00
Yiorgis Gozadinos
ea6b864f6e
Mark a truncated preview instead of cutting it silently 2026-08-27 10:31:49 +03:00
Yiorgis Gozadinos
8ed24d0e24
Tighten the multi-database documentation
Restructure the storage page into search and provenance, duplicate ids,
ranking, Python operations and CLI commands, and state each in reference
voice. `Several Databases` becomes `Multiple Databases`, with the anchor
carried through every referrer, and the instruction files follow the same
name. Example databases are `papers`, `wiki` and `notes`.

`AmbiguousCitationError` is raised for a cited chunk id held by more than one
selected database, not for any shared id.
2026-08-26 17:23:02 +03:00
Yiorgis Gozadinos
1995a7da36
Name the database of a result by what the operation covers
`_rich_print_search_result` asked how many databases were configured, so
`--db-name alpha` labelled every line with the database the caller had just
named. Citations already ask the client what it covers, which is the same
question the scope answers.
2026-08-26 16:55:56 +03:00
Yiorgis Gozadinos
b76c5c7db0
Show the configuration in the shape a config file has
`settings` printed each top-level block as one Python dict repr, so nesting
was invisible, long paths wrapped mid-token and `PosixPath(...)` leaked. It
renders as YAML now, which is what `haiku.rag.yaml` is written in.
2026-08-26 16:55:56 +03:00
Yiorgis Gozadinos
ab0bdff14c
Print only the document fields there are
`list` does not load content, which is where the docling blobs live, so the
`content:` header announced a field the command declined to fetch. A document
without a uri or metadata printed `uri: None` and `meta: {}`, where `title`
was already omitted when absent.
2026-08-26 16:48:27 +03:00
Yiorgis Gozadinos
db5f61d740
Rename the database selector to --db-name
`--database` and `--db` read as one word abbreviated but take different
things, a configured name against a filesystem path, and they sit in
different positions: `haiku-rag --db /path info` fails with `No such option`.
The CLI already says "database" to the user everywhere it prints one, so the
name it selects by should say the same. Unreleased, so no deprecation.

Also: without a reranker, raise `search.limit` with the number of databases
searched, since each contributes its best matches to a list truncated back
to the limit.
2026-08-26 16:48:27 +03:00
Yiorgis Gozadinos
8f41d42ab2
Correct what the client and the docs claim
`HaikuRAG.__init__` said an omitted `db_path` uses `storage.data_dir`, which
is the last of three; and that `sources` is ignored for a single `uri`, where
it raises, since only `lancedb.databases` names databases.

The name is not the only identity leaving the configuration: results,
citations, model input and errors opening a named database carry it, while
`info`, `init` and `tag` print the location. `sources=[]` returns no search
results, but `ask` and `analyze` still answer, without evidence.

Trim the comments that still narrated a failure or an alternative to the
invariant they were there for.
2026-08-26 16:47:59 +03:00
Yiorgis Gozadinos
ca2e28559e
Split the multi-database tests by subject
Two files of 1,405 and 814 lines become seven: scope resolution, lifecycle,
search, documents, expansion, citations and capabilities. `_config`, `_seed`
and the rest move to `helpers.py`, importable by the sandbox tests that share
them, and the package points VCR back at `tests/cassettes/multi_db/`.
2026-08-26 13:43:53 +03:00
Yiorgis Gozadinos
4c2bfc4fc1
Drop the indirection around what a client covers
`covers_several_databases` had one line of body and two call sites.
`_db_path_given` guarded a default path manufactured in `__init__` and
overwritten in `__aenter__`; `_requested_db_path` is what the caller asked
for, and the effective path falls out of the resolved scope.
2026-08-26 13:34:09 +03:00
Yiorgis Gozadinos
1b3334b5af
Trim the comments to what a reader needs
One sentence for the contract, one or two more only where an invariant is not
obvious. The reasons that stay are about correctness and ownership: which
database a result belongs to, who closes what, why assembly order is the
tiebreak. The ones that go narrated how the code got here.
2026-08-26 13:29:00 +03:00
Yiorgis Gozadinos
503db3271c
Name the evaluations set check for what it answers
`covers_a_set` is true for a mapping of one, which is a configured database
like any other; `uses_configured_databases` says that. Its population guard
said "several" for the same reason. Document evaluating the configured set
with `--skip-db`, against population, which writes one database and needs a
path.
2026-08-26 13:04:06 +03:00
Yiorgis Gozadinos
2000098e16
Say which commands cover a set, and what a shared id does
Three groups, not two: `search`, `ask`, `analyze` and `chat` cover the set,
`settings`, `init-config` and `download-models` open no database, and every
other command works on one — or on a configured set of one, which is
unambiguous and keeps its name. A database named in `lancedb.databases` keeps
that name whether or not it is the only one covered; only `lancedb.uri` places
one without naming it.

Document ids repeat between copies of a database, where the sandbox refuses a
duplicate but the chat filter's `id IN (...)` matches the document in every
copy. `build_document_id_filter` claimed ids never widen a selection.

Document the facade: `covers_multiple`, `source_names`, `source`,
`reader_for`, `clients_for`, the lifetime of a borrowed client, and
`sources=None` against `sources=[]`.

Drop the vision callout from the README, which the features list already
covers.
2026-08-26 13:03:58 +03:00
Yiorgis Gozadinos
2b2de5a61f
Reuse the database a borrowed client already holds
`clients_for` hands back a client over a database the covering one owns, so
`async with` on it opened a second session and assigned it, and teardown
declined to close what this client did not open. Entry returns the client as
it stands.
2026-08-26 13:03:47 +03:00
Yiorgis Gozadinos
de8075fcd1
Point the S3 integration tests back at S3
They opened a client on `tmp_path / "unused"` with the bucket in the
configuration. An explicit path now selects the database, so the client
tests ran against the local disk and the two app tests failed. Nothing names
a path any more, and both helpers assert the connection is remote before
yielding, so a later precedence change cannot quietly localize them again.
2026-08-26 12:53:52 +03:00
Yiorgis Gozadinos
59e05d2c22
Merge remote-tracking branch 'origin/main' into feat/multi-db
0.78.0 released `api_key` and two authorization fixes out of the section
this branch was still adding to, so the automatic merge filed every
multi-database entry under it and dropped the `0.77.0` heading, which both
sides had de-duplicated. Everything from `0.78.0` down is main's record
verbatim; the multi-database entries stay under Unreleased.
2026-08-26 12:48:58 +03:00
Yiorgis Gozadinos
71e4e4a40a
Resolve a capability's databases once, into a scope
`resolve_db_path` manufactured the default path whenever `lancedb.databases`
was empty, and `covers_several_databases` read coverage back out of the
configuration, so a capability built without a client opened
`storage.data_dir/haiku.rag.lancedb` instead of what `lancedb.uri` placed.
The entry point resolves a `DatabaseScope` instead: instructions ask it what
it covers and `_ensure_rag` opens it through `HaikuRAG._covering`, so
coverage is decided once rather than encoded in a path and re-derived.
`Sandbox._covering` takes the scope the capability already resolved, beside
the public constructor that takes a path. The factory signatures are
unchanged.
2026-08-26 12:20:21 +03:00
Yiorgis Gozadinos
5b420a9b23
Take a schemeless lancedb.uri as a local path
Closes #582. `lancedb.databases` entries already classify a location by
whether it carries a scheme; `lancedb.uri` was taken as a URI whatever it
said, so a local path was opened as object storage and a mistyped one
became a new empty database instead of failing. Both settings now place a
database the same way, and `--db PATH` overrides either.
2026-08-26 12:20:09 +03:00
Yiorgis Gozadinos
49580228c2
Read the document's own database in the sandbox listing
`list_documents()` reached into the ownership map for a name the document
already carries. The map is still built, so a document id two databases
claim is still refused before anything mounts. A listing over one named
database now reports that name, as in-code `search()` already does.
2026-08-26 10:33:29 +03:00
Yiorgis Gozadinos
9a3e2b86ca
Read a database's stored settings once
`Store` already parses the settings blob on open, so keep it: the
inspector's info modal was opening the settings table, querying it and
parsing the JSON a second time, and `doctor` was asking
`SettingsRepository` for it on a store it already held. Creating a
database refreshes it, so a new one reports the version init wrote.

`gather_database_info` keeps its own parse: it goes around `Store` so a
pre-migration database still reports what it can.
2026-08-25 17:38:32 +03:00
Yiorgis Gozadinos
d9ac221ca0
Refuse a chunk id that names a chunk in two databases
A chunk id is unique within a database and says nothing across them, so a
database copied from another holds the same ids. `qualified_id` keys the
two in-memory identity sites on the database and the id together:
`merge_results` was dropping the second database's result when a query
repeated, and the arrival map that breaks fused score ties was ranking one
of the pair as the other.

Everything serialized records the id alone, so there ambiguity is refused
rather than qualified. `resolve_citations` raises `AmbiguousCitationError`
for a cited id held by two of the databases searched, where it used to
resolve to whichever result came last; `_register_citations` raises for one
already cited from another database in an earlier question. `_cite` turns
both into a `ModelRetry` asking for other evidence. The direct-id fallback
asks every database the question covers instead of taking the first that
answers, so an id no search returned is refused on the same terms.
`all_found` collects them and `first_found` reads its first, which document
reads keep doing on purpose.

Also drop a duplicated 0.77.0 heading from the changelog.
2026-08-25 17:38:25 +03:00
Yiorgis Gozadinos
05c204071d
Expand context through the database a result came from
Expansion branched on whether the client covered a set, so the
single-database half reached for repositories through a facade that may
have none. `expand_sources` groups results by database and hands each
group the session that owns it; `expand_context` and `visualize_chunk`
take that session, so `visualize_chunk` stops narrowing to one database
and discarding the result.

Inline `_fetch`, a pass-through to the chunk repository.
2026-08-25 16:11:31 +03:00
Yiorgis Gozadinos
d367b1eb5a
Resolve the databases a command works on, once
The CLI decides only what it knows — that --db and --database are the same
thing said twice, and whether a command reads more than one — and hands the
resolved scope down. Nothing rewrites the configuration, so a named database
keeps the name results and citations carry, and a remote one opens the URI
it was configured with rather than the local path standing in for it.

HaikuRAGApp, ChatApp and InspectorApp take that scope and nothing else.
Selection reaches the client through a private constructor, so the public
signature still takes a path or names.
2026-08-25 15:48:47 +03:00
Yiorgis Gozadinos
8db447e095
Ask the client what it covers
covers_multiple, source_names, source and reader_for replace the private
state seven modules were reading to work out how many databases they had.
The configured selection is kept intact, so entering a client twice derives
the same database rather than the last derivation.
2026-08-25 14:16:22 +03:00
Yiorgis Gozadinos
16b7319c48
Type writing against one database
Every implementation in documents.py and rebuild.py takes the session it
writes to, so a set cannot reach one: the facade narrows once and passes the
database on, rather than checking and carrying a union. Tests calling an
implementation directly go through `writing()`.
2026-08-25 10:09:23 +03:00
Yiorgis Gozadinos
028217b7c0
Ask the session which database an operation works on
One session field, whichever kind it is, with the covered databases derived
from it rather than kept beside it. `_single_session` returns the database a
write works on in place of ten guards that only asserted one existed, and the
single-database operations move to the session that owns them.
2026-08-25 09:28:20 +03:00
Yiorgis Gozadinos
e8390ca747
Compose a set out of single-database sessions
FederatedSession opens the databases a query covers and owns their
teardown; the client keeps the wrappers it hands out. A wrapper releases
what it built and never closes the database it borrowed.
2026-08-25 08:56:09 +03:00
Yiorgis Gozadinos
79a63a46d4
Give one database its own session
SingleDatabaseSession owns the store, the repositories and the vacuum
machinery, so nothing above has to ask whether it has a store. The client
keeps every name callers already use.
2026-08-24 17:13:45 +03:00
Yiorgis Gozadinos
fcfa4aefd8
Resolve the databases an operation covers, once
DatabaseScope.resolve reads configuration and at most one selector; a
DatabaseRef carries the configured name and a location already resolved to
a URI or a path, so a path a caller names is never reinterpreted. Nothing
consumes it yet.
2026-08-24 16:49:54 +03:00
Yiorgis Gozadinos
73d04ddba5
vb 2026-08-24 16:06:03 +03:00
Yiorgis Gozadinos
9fefcdb629
Refuse to create a database without naming one
`create=True` had nothing to act on across a set and was accepted anyway,
leaving the first query to fail on whichever database was missing.
2026-08-24 15:36:09 +03:00
Yiorgis Gozadinos
bddb32b469
Build the embedder for a set from configuration
An embedder is a function of configuration, not of a database, and the
databases in a selection share one, so a client covering a set builds it
on first use and closes it on teardown. Operations that need one database
say so instead of surfacing a missing store.
2026-08-24 15:28:21 +03:00
Yiorgis Gozadinos
a9e66b001b
Drop the database name from single-database document output
Naming one database on the command line points the configuration at it,
so no command that prints a document ever has a name to print.
2026-08-24 15:28:21 +03:00
Yiorgis Gozadinos
c284d86885
Resolve the search type once per search
An image query has no text to match, so it is vector-only whatever the
caller asked for, and full-text search embeds nothing, so it needs no
agreement on embedders.
2026-08-24 15:28:21 +03:00
Yiorgis Gozadinos
f35be76604
Find the owner of an id through one primitive
`first_found` replaces the client's private version and the capability's
sequential one. The info modal reports through the connection the client
already holds.
2026-08-24 15:27:59 +03:00
Yiorgis Gozadinos
307e250f29
Select documents to filter by id, not by displayed name
A title repeats within a corpus and across databases, so a substring
match on the displayed name widened the filter to documents the user did
not pick. The label names the database.
2026-08-24 15:27:59 +03:00
Yiorgis Gozadinos
16e0add64b
Embed a search query once for the whole selection
Each database owns an embedder, so embedding per database cost a round
trip each. One database still embeds inside the repository, which returns
early for a filter that matches nothing.
2026-08-24 15:27:59 +03:00
Yiorgis Gozadinos
c3182d0fb6
Require one embedder across databases searched together
Searching a set embeds the query once, so a database written with another
model answers from a different vector space.
2026-08-24 15:27:59 +03:00
Yiorgis Gozadinos
bc04dfdb7a
Reject a database with no name or location
A blank name is falsy, so source routing reads it as absent, and a blank
location resolves to the working directory. `is_read_only` reports the
mode the client was opened with, which a client covering a set can answer
without a store.
2026-08-24 10:03:47 +03:00
Yiorgis Gozadinos
e94623ec37
Tell a document which database it came from
Document.source names the configured database, as SearchResult and
Citation already do. A listing spanning databases is unreadable
without it, and `--database NAME list` could not name the one it
opened.
2026-08-24 10:03:47 +03:00
Yiorgis Gozadinos
1d09b4e31b
Find a document in whichever database holds it
`get_document_by_id`, `get_document_by_uri` and `get_chunk_by_id` read
through repositories a client covering a set does not have, so a lookup by
identifier raised AttributeError and `resolve_document` with it. An
identifier says nothing about which database holds it, so every database is
asked at once and the first that has it, in configured order, answers.

On the evaluation side, `--db` overrides the configured set as the CLI
documents, and population refuses a set rather than ingesting into a
database the run would not read. A case filter matching nothing raises
instead of reporting 0.0000 as though it were a score.
2026-08-24 10:03:47 +03:00
Yiorgis Gozadinos
e1fd68e8cb
Say that searching several databases wants a reranker
Reciprocal rank fusion compares ranks, so every database contributes its
own best matches whether or not they answer the question, and results from
databases holding nothing relevant displace better ones. Measured on one
corpus split three ways over 3,045 queries: retrieval MAP 0.6044 without a
reranker against 0.9798 for the same corpus in a single database, and
0.9918 against 0.9914 with one.
2026-08-24 10:03:47 +03:00
Yiorgis Gozadinos
2bbd949a29
Record the database each citation came from
A run over several databases could report which documents were cited but
not which database grounded the answer: `_result_from_run` walked the
citation index for `document_uri` and dropped `Citation.source`. The
distribution is not recoverable from the report afterwards, so a sharded
run would have measured everything except attribution.

`cited_sources` is one entry per cited chunk, in citation order, empty
where the database is unnamed.
2026-08-24 10:03:47 +03:00
Yiorgis Gozadinos
eed820df1b
Evaluate over a configured set of databases
The runner passed the dataset's path to every arm, which opens one database
and is what makes `--db` meaningful. A run over `lancedb.databases` has to
pass none instead, so the client resolves the set, and `DatasetSpec.covers_a_set`
is the one place that decides which of the two a run is.
2026-08-24 10:03:47 +03:00
Yiorgis Gozadinos
fa242c1c94
Tell the analysis and RAG capabilities about the databases
A capability covering several databases received `source` on every document
and search result and never used it: asked how many documents were in each
database, the model read the titles and answered that there was one corpus
of 67,581. The instruction files enumerate what a result carries, and both
enumerations had gone stale.

The note follows what the capability opens rather than what the
configuration names, through `covers_several_databases`: an explicit
`db_path` or a lent client covering one database is instructed as before,
as is every `uri` or path deployment and every eval dataset. The analysis
note separates the three interfaces, since they differ: an
`analysis_search` result carries a `Database:` line, in-code `search` and
`list_documents` return `source`, and the mounted files carry neither.
2026-08-24 10:03:47 +03:00
Yiorgis Gozadinos
9a17ff7457
Show one page of documents in the filter modal
The modal listed every document and mounted a checkbox per document, so a
corpus of tens of thousands never finished rendering: 67k sequential
mounts across two databases, and the same for one database that size.
Titles repeat at that scale too, and the ids were derived from the title,
so they collided.

It shows a page of 200 now, mounted in one call and identified by
position, and the search box asks the database for the rest on enter,
matching titles and URIs. Typing still narrows the page on screen, for
feedback while typing. `search_filter` escapes the term, which is
whatever was typed.

A federated listing takes its window across the databases rather than
filling it from the first one: concatenating hid every database after
whichever was listed first, which for a set of a thousand papers and
sixty thousand articles meant a page of papers alone. Sorting would not
have helped, since document ids and article titles sort into separate
runs, so the page is picked by interleaving and the modal sorts it for
display.
2026-08-24 10:03:46 +03:00
Yiorgis Gozadinos
bb92ccf67e
Cover the configured set in the chat TUI
Chat answers with the same capabilities `ask` does, so it federates as
naturally as `ask` and `analyze` — but it went through the one-database
guard and refused a configured set outright, which left no way to chat
across several databases.

The guard was the visible half. `run_chat` also defaulted `db_path` to the
single default path whenever it was None, so lifting the refusal alone
would still have opened one database. It now leaves the path unresolved
when `lancedb.databases` names the set, and the client resolves it.

Listing and counting documents fan out over the set, which is what the
document filter reads, and visual grounding resolves the database holding
the cited chunk through the citation's source: chunks, pages and bounding
boxes all come from that one database. A limit on a listing means that
many documents in total, not that many per database.

The info modal reports every database it covers, each under its
configured name and without its location, since names are the only
identity that leaves the configuration. `database_lines` is what one
database reports about itself, shared by both paths, and it reports a
failure as a line so one unreachable database does not cost the report on
the others.

`inspect` stays a one-database command. It browses one database's
documents and chunks, so a set has nothing to show it.
2026-08-24 10:03:46 +03:00
Yiorgis Gozadinos
5c9639bbbb
Move ConfigMismatchError to the store exceptions module
The CLI reports this error, and importing it from the settings repository
pulled lancedb onto the CLI's import path. Deferring the import into cli()
bought nothing, since cli() runs on every invocation: it cost about 1.9
seconds on a cold start, `--help` included.

It now sits beside the other store exceptions, in a module that imports
nothing, and every importer points there.
2026-08-24 10:03:46 +03:00
Yiorgis Gozadinos
95e38e7137
Print configuration mismatches instead of raising them
A database whose stored embedder disagrees with the configuration raised
ConfigMismatchError through Typer, so the operator got a traceback wrapped
around the one message that says what to run, while every sibling failure
exits with its message. It joins the errors the CLI reports.

Imported inside cli() rather than at module scope: the settings module
pulls in lancedb, and importing the CLI must not pay for it.
2026-08-24 10:03:46 +03:00
Yiorgis Gozadinos
e15f73a387
Document searching several databases
lancedb.databases, the sources argument and the --database selector had no
documentation. Adds a Several Databases section to the storage
configuration page covering the name-to-location map, its mutual
exclusion with uri, the shared embedding configuration the set requires,
and which commands cover the set against which work on one. Adds a
Searching Several Databases section to the Python API page, --database to
the CLI's global options, the key to the sample configuration, and one
README feature line.
2026-08-24 10:03:46 +03:00
Yiorgis Gozadinos
fdb5710491
Ask and analyze across several databases
Chunk 2 gave search a configured set to fan out over. ask and analyze
covered one database still: the RAG capability had no way to be told which
databases a question spanned, and the analysis sandbox mounted one
document tree.

The selection travels as sources on EvidenceState, beside the filter it
scopes with, so both capabilities read it the same way. clients_covering
is the one rule that turns a selection into clients, used by search, the
sandbox mount and the cite fallback, so a question scoped to some
databases cannot search, mount or cite another. Citations carry the
database they came from, and format_for_agent names it, so the model can
attribute evidence while it answers rather than only afterwards.

The sandbox keeps one flat /documents/{id}/ namespace and resolves each id
to the client holding it, which rests on ids being UUID4. A database
copied from another breaks that, so an id held twice is refused rather
than resolved to whichever arrived last.

On the CLI, search, ask and analyze cover the configured set and label
each result with its database. Every other command works on one, named
with --database NAME (a name reaches a database behind a URI, which --db
cannot) or --db PATH, and refuses a set it cannot choose from instead of
silently reading the default database. Cold databases open together, so a
first query costs the slowest open rather than their sum.
2026-08-24 10:03:46 +03:00
Yiorgis Gozadinos
f33b789a31
Ask across databases and name the source of each citation
`ask(sources=[…])` scopes a question to some of the configured databases, carried
on the capability state so its search tool searches those. `Citation.source` names
the database a cited chunk came from, resolved from the search results the model
saw, which already carry it.

Context expansion routes each result through the database it came from: a
federating client has no repositories of its own.

The cite fallback, which looks up an id absent from this run's results, searches
only the selected databases. A chunk id says nothing about which database holds
it, so placing one means asking, and asking outside the selection would let a
question scoped to some databases cite another.

The loosely-specced client mocks in the capability tests now say they stand in for
a single-database client. A bare AsyncMock answers any attribute with a truthy
Mock, so `_federated` sent the fallback down the multi-database branch, and
`_source` reached a validated field.
2026-08-24 10:03:46 +03:00
Yiorgis Gozadinos
397b553528
Search several configured databases and fuse the results
`lancedb.databases` maps a name to a location, mutually exclusive with `uri`.
`search(sources=[…])` selects which to search, `sources=None` searches all of them
and `sources=[]` searches none; `SearchResult.source` carries the configured name,
so a path or URI never leaves the configuration. A database named in config keeps
its name even when it is the only one configured; only a legacy single `uri`
leaves `source` unset.

Databases open on first use, not at entry. Which are searched is a per-query
choice, so a set of 25 queried a few at a time opens a few, and a database nobody
asked for can neither fail a query nor be opened for nothing.

A named database that fails to open raises `SourceUnavailableError` naming it,
raised outside the handler so the original is not attached at all. A local failure
spells out the absolute path and an object-store failure can carry the bucket;
`from None` would only stop that being printed, leaving it on `__context__` for
anything that walks the chain. A legacy `uri` client has no name to report
instead, so its error passes through unchanged.

Candidates are fetched concurrently, then fused before anything is ranked. A
configured reranker scores the union, which is what makes ranking across
databases tractable: it compares query against document and does not care where a
candidate came from. Without one, reciprocal rank fusion over the per-database
rankings, since scores from separate indexes are not comparable. Enrichment then
runs on the survivors through the database each came from, concurrently, so it
costs what a single-database search costs.

The over-fetch decision and the reranker belong to the federating client alone.
Deciding per database would have each consult its own, and a local reranker loads
model weights per instance. It is built only for a text query, and closed once by
the client that owns it.

A location without a scheme is opened as a local path rather than through
`lancedb.uri`. Routing it through `uri` had `ConnectionMode` classify it as object
storage, which opens a missing database instead of reporting it.

With several databases configured, `store` and the repositories are left unset:
they have no unambiguous meaning across a set, and picking one silently would be
worse than the error.
2026-08-24 10:03:45 +03:00
Yiorgis Gozadinos
569947b28d
Separate fetching from ranking in search
`search` fetched, reranked and truncated in one pass, with the reranker's
over-fetch and the reranking itself interleaved in the same branch. Searching
several databases needs to fuse their candidates before anything is ranked, so
the phases have to be separable.

`_fetch` returns one database's candidates, over-fetching only when a reranker
will re-order them. `_rank` orders and cuts them, leaving an image query's vector
ranking alone since there is no text for a reranker to score against. The
over-fetch multiplier is named rather than a literal 10 at the point of use.

Both check the query type before reading `client.reranker`, which is a
cached_property that builds the reranker on first access and loads model weights
for a local one. An image query never used it and must not start.

No behaviour change: the same suite passes, and the search outputs digest
identically to before.
2026-08-24 10:03:45 +03:00
Yiorgis Gozadinos
735489d723
Merge pull request #581 from ggozad/feat/frames-bench
Add the FRAMES benchmark
2026-08-24 09:59:58 +03:00
Yiorgis Gozadinos
ab910eb776
Add FRAMES to the benchmarks page 2026-08-24 09:50:26 +03:00
Yiorgis Gozadinos
4df31ff16e
Point FRAMES at the live reranker and give the judge room to think
Port 11433 serves nothing; Qwen3-Reranker-4B is on 11455. Qwen3.6 spends
its budget reasoning before it answers, and its 131072 window leaves ample
input space at 32768.
2026-08-24 09:03:44 +03:00
Yiorgis Gozadinos
bc6c23cbf3
Bound FRAMES analysis sandbox output at 20k chars 2026-08-24 09:03:44 +03:00
Yiorgis Gozadinos
5e10846292
Raise FRAMES input budget: qa max_tokens 8192, judge 16384 2026-08-24 09:03:44 +03:00
Yiorgis Gozadinos
a57a73b6d0
Add stable ids to FRAMES question rows 2026-08-24 09:03:44 +03:00
Yiorgis Gozadinos
aa49ec6cb3
Exclude FRAMES questions whose articles were deleted from Wikipedia 2026-08-24 08:44:01 +03:00
Yiorgis Gozadinos
c00ccb7322
Harden FRAMES corpus fetching against Wikimedia rate limits 2026-08-24 08:44:01 +03:00
Yiorgis Gozadinos
152d57d6e2
Add frames evaluation dataset 2026-08-24 08:44:01 +03:00
Yiorgis Gozadinos
86f254d5f2
Document the Logfire HTTP query path in the eval-debugging skill
The skill assumed the Logfire MCP is loaded. It is not loaded in every
session, which left no way to inspect a run at all. Document the HTTP query
API as the fallback, including the mandatory min_timestamp, the API keys
replacing read tokens, and the project-scoping trap: a key for the wrong
project authenticates and returns zero rows rather than erroring.

Also record three things that produced wrong readings in practice:
assertions/scores/metrics are keys inside the attributes column rather than
columns, one exception is emitted once per span level so failures must be
counted at case level, and assertion_pass_rate drops unjudged cases from its
denominator so judged and floor rates have to be quoted together.

Add a section for monitoring a run that is still in flight, since an eval
prints nothing until it finishes: case-span progress, the serial-execution
check that makes an ETA valid, and the per-case diagnostic attributes.

Vendor the query helper next to the skill so it does not point at a path
outside the repo, and derive its region from the key prefix.
2026-08-24 00:08:59 +03:00
Yiorgis Gozadinos
959cf700ae
Merge pull request #580 from ggozad/feat/model-api-key
Add per-endpoint api_key to model and embedding config
2026-08-24 00:03:17 +03:00
Yiorgis Gozadinos
2310b7a8b3
Add per-endpoint api_key to model and embedding config 2026-08-23 23:54:40 +03:00
Yiorgis Gozadinos
e9f6fea598
vb 2026-08-21 13:15:50 +03:00
Yiorgis Gozadinos
d7d27cc1a3
Merge pull request #577 from ggozad/feat/from_spec
Support Pydantic AI agent specs via from_spec
2026-08-21 13:14:43 +03:00
Yiorgis Gozadinos
61a756da7f
Support Pydantic AI agent specs via from_spec
`Agent.from_spec` raised `TypeError` on `RAGCapability` and `AnalysisCapability`,
whose constructors take a state class, packaged instruction text and a tool-name
set, and silently omitted both from the generated spec schema. The two
zero-configuration capabilities constructed but with `id=None`, so pydantic-ai's
duplicate-id rejection no longer held and a spec could register two citation
policies, defeating the single-decision-maker invariant.

Override `from_spec` on all four, delegating to `create_capability()` so ids and
instructions come from one place. The spec surface is `db_path`, `config`,
`defer_loading`, `request_limit` and `vision`; a live `HaikuRAG` client stays out
of it, and a `config` mapping is validated through `AppConfig`.
2026-08-21 13:04:09 +03:00
Yiorgis Gozadinos
971ec0a5b0
Coerce a string db_path to Path at the Store and capability boundaries
The documented `HaikuRAG("knowledge.lancedb")` and `rag(db_path="my.lancedb")`
forms both raised `AttributeError: 'str' object has no attribute 'exists'`.
`Store.__init__` assigned its argument to a `Path`-annotated attribute without
coercing, and `resolve_db_path` returned a non-None argument unchanged. Every
runnable example wraps the path in `Path(...)`, which is why it survived.

Coerce in `Store.__init__` and `resolve_db_path`; widen the annotations on
`Store`, `HaikuRAG` and both `create_capability` factories to accept `str`.
2026-08-21 12:49:45 +03:00
Yiorgis Gozadinos
def24a2434
Merge pull request #576 from ggozad/fix/win-file-urls
Resolve file:// URIs to paths through url2pathname
2026-08-21 10:46:43 +03:00
Yiorgis Gozadinos
da6cdfbc51
Resolve file:// URIs to paths through url2pathname
urlparse().path keeps the leading slash in front of a Windows drive, so
file:///C:/docs/a.pdf read as \C:\docs\a.pdf and the ingester reported
"File does not exist" for every file it discovered. url2pathname is the
stdlib conversion that strips it, per platform.

Four sites each decided both "is this local" and "what path is this":
FSSource._uri_to_path and supports, resolve_adhoc_fetcher,
create_document_from_source and check_source_accessible, and convert.
is_local_uri and uri_to_path in haiku.rag.uri own those two decisions now,
which closes two more cases of the same root cause. A bare C:\docs\a.pdf
parses with scheme "c", so add-src raised "No source adapter for URI scheme
'c'" and convert silently treated the path as raw text. And convert and
check_source_accessible never percent-decoded at all, so a file named
a[b] c.md read as missing on Linux and macOS too.

A file URI's host is reattached after conversion rather than passed to
url2pathname, which as of 3.14 rejects a non-local authority off Windows.
file:////server/share is the empty-authority spelling of a UNC path, its
host being the first path segment, so that host is normalised into the
authority before conversion. Output is identical on 3.12, 3.13 and 3.14.

The ad-hoc FS fetcher roots at the path's own anchor rather than "/", which
on Windows is only the current drive.

test_uri.py runs on ubuntu, macos and windows across 3.13 and 3.14 without
the project installed: --noconftest because the repo conftest imports
dependencies that job does not need, and -o addopts= to drop the
repository's -n auto. The Windows legs are what cover the drive conversion.

Fixes #574.
2026-08-21 10:22:26 +03:00
Yiorgis Gozadinos
88925e86d9
Merge pull request #575 from ggozad/fix/capability-search-results
Return "No results found." and accumulate repeated search results
2026-08-21 10:20:47 +03:00
Yiorgis Gozadinos
97d9c6ed49
Return "No results found." and accumulate repeated search results 2026-08-21 10:09:25 +03:00
Yiorgis Gozadinos
2a7c6510d0
vb 2026-08-20 15:48:23 +03:00
Yiorgis Gozadinos
e687c73906
Merge pull request #572 from ggozad/chore/comment-sweep
Delete comments that restate the line below them
2026-08-20 15:39:41 +03:00
Yiorgis Gozadinos
4967765878
Delete comments that restate the line below them
Sixty-three comments said what the next statement already said: # Connect to
LanceDB above connect_lancedb, # Path object above isinstance(source, Path),
# Get page numbers from provenance above the prov loop, # Clear and populate
results above list_view.clear(). They cost a read and carry nothing.

The line is whether a comment restates one statement or labels a phase. Phase
labels stay: the migrations keep # Create staging table with new schema and
# Copy from staging to final table in batches, each heading ten lines of a
long procedure. So do comments carrying a fact the code cannot: the
merge_insert update-only note on document_meta, why the poller builds sources
eagerly, why create_document_from_source returns a list for directories, that
indexes need training data, the field-group markers in the config models, and
the file:// URL-encoding note in create_document_from_source.

capabilities/ is untouched. Its docstrings sit next to prompt surface, and
changing them needs an eval to back it.

The cassette-recording docs were wrong three ways. They named
tests/test_qa.py::test_qa_anthropic, which no longer exists; they targeted
whole modules, so a rewrite would re-record cassettes for services the
recorder is not running; and they used COHERE_API_KEY where the SDK reads
CO_API_KEY. docs/development.md now names exact tests with -n0, and the keyed
example is test_cohere_reranker, which owns the one cassette recording
api.cohere.com.
2026-08-20 15:22:33 +03:00
Yiorgis Gozadinos
476f8d07a0
Merge pull request #569 from ggozad/refactor/benchmark-split
Split the evaluation benchmark by responsibility
2026-08-20 15:21:26 +03:00
Yiorgis Gozadinos
02b6104b4b
Merge pull request #571 from ggozad/docs/route
Give the docs an architecture page and one extras list
2026-08-20 15:15:52 +03:00
Yiorgis Gozadinos
483c0ec354
Give the docs an architecture page and one extras list
overview.md repeated the landing page: the same install-and-ask block and
five of six identical links. It was positioning prose, where the docs had no
page describing how the system works.

Rewrite it as Architecture, following the data through: source adapter,
converter, chunker, embedder, transaction; then storage and its versioning;
then retrieval, with the 10x rerank fetch and section-bounded expansion; then
the two capabilities; then laptop versus ingester. Retitled in the nav and on
the landing page, filename kept so existing links resolve.

Extras were listed in three places and none was complete.
docs/installation.md now carries a table of all fifteen slim extras, what each
provides, and which the full package already includes.
haiku_rag_slim/README.md names them and links there. The claim that other
providers need their own pydantic-ai extra was wrong: haiku.rag-slim defines
anthropic, google, groq, mistral, bedrock and vertexai itself.

configuration/storage.md opens with the four operational constraints, which
were either buried in an S3 section or undocumented: one writer per URI,
reader lag by read_consistency_interval_seconds, migrate after a
schema-changing upgrade, and the fixed embedding dimension with what
ConfigMismatchError means and which rebuild mode resolves it.

The one-writer rule is stated as a haiku.rag constraint, which is what it is:
the multi-table lock, version snapshot and rollback are process-local, so a
second writer can commit inside another's transaction and be reverted by its
rollback. storage.md and ingester.md both claimed it was a LanceDB property
that corrupts manifests. The S3 deployment section now links to the
constraint instead of restating it.

Get started reads index, Quickstart, Installation, Architecture. The landing
page's list was missing Installation.
2026-08-20 15:07:06 +03:00
Yiorgis Gozadinos
d6cfda22b5
Merge pull request #570 from ggozad/refactor/doctor-checks
Make run_db_checks an orchestration list
2026-08-20 14:47:12 +03:00
Yiorgis Gozadinos
0592206f16
Make run_db_checks an orchestration list
At 327 lines it interleaved reading the tables, deriving the lookups every
check needs, and the bodies of ten checks. Six checks were already
functions; the rest were inline, so none of them could be read or tested
without the others around them.

Each one is now a function taking exactly what it needs:
_check_document_meta_parity, _check_orphaned_chunks, _check_orphaned_items,
_check_documents_without_items, _check_dangling_item_refs,
_check_vector_dimension, _check_unembedded_chunks, _check_picture_data,
_check_settings_row and _check_pending_migrations. run_db_checks reads the
tables, then appends results.

_document_centroids takes the vector reduction. Passing the matrix as a
parameter keeps it a local of run_db_checks, so the del before clustering
still drops the last reference — measured at 6.2 MB allocated to reduce a
102 MB matrix, no second copy. There is no snapshot object: one holding
vectors would keep the largest allocation alive past the del.

The reduction also rebound doc_ids from the document-id set to the centroid
id list halfway through the function. The centroid ids have their own name
now.

No test changes: the 80 doctor tests cover these through run_db_checks and
pass unchanged.
2026-08-20 14:22:29 +03:00
Yiorgis Gozadinos
19d5b2e7f6
Split the evaluation benchmark by responsibility
benchmark.py was 1220 lines holding six unrelated jobs: populating a
database, running retrieval, running QA, resolving datasets, moving
databases to and from HuggingFace, and wiring the Typer CLI.

qa.py takes both QA runners with their live summary, refusal metrics and
target resolution. population.py takes populate_db and the batched ingest.
retrieval.py takes run_retrieval_benchmark. artifacts.py takes HF_REPO_ID and
the download/upload bodies. experiment.py takes DEFAULT_JUDGE_MODEL and
build_experiment_metadata, which retrieval and QA both record.

benchmark.py keeps the CLI at 258 lines: the Typer app, config and case-id
loading, dataset resolution, evaluate_dataset, and three commands whose
bodies are now a loop over specs. The module-level side effects stay with it
— load_dotenv before configure_telemetry, so credentials and LOGFIRE_TOKEN
are in the environment before telemetry and model setup read them — so no
importable module carries one.

Test patch targets follow the code. get_model, run_capability_question,
run_capability_conversation, set_eval_attribute and HaikuRAG are patched
inside moved code, so they move with it; run_qa_benchmark,
run_retrieval_benchmark and find_config_file stay patchable on
evaluations.benchmark because evaluate_dataset and _load_config still look
them up there.

One assertion got stronger: a QA test patched benchmark.HaikuRAG to prove the
QA path does not open its own client. qa.py has no HaikuRAG reference at all
now, so the test asserts that instead.
2026-08-20 14:08:09 +03:00
Yiorgis Gozadinos
d429ac4996
Merge pull request #567 from ggozad/chore/coverage-honesty
Measure the CLI and its application layer
2026-08-20 13:32:55 +03:00
Yiorgis Gozadinos
7f54eb4dbc
Measure the CLI and its application layer
cli.py carried 40 pragmas over whole command bodies and app.py a
class-level one over all 412 statements, while tests/test_cli.py already
drove 29 commands through CliRunner. The pragmas hid lines the suite
executed, so the 100% gate understated real coverage and gave new CLI code
no scrutiny.

Both are measured now. 38 CLI tests stub HaikuRAGApp and assert the parsed
arguments reach the right method; 60 app tests stub the client and record
the console, pinning what each command asks for and what it prints. The only
pragma left in either file is cli() under __main__. The omit list is back to
the two Textual TUIs.

Three defects the coverage surfaced:

haiku-rag settings masked only top-level secret-named fields, so nested ones
printed in full — lancedb.api_key, providers.docling_serve.api_key, WebDAV
source passwords. It uses redact_secrets, which walks the dump.

chat guarded the wrong thing: haiku.rag.chat imports without Textual, and
run_chat raises when it imports ChatApp, so the missing extra escaped as an
ImportError. The guard is on the call. inspector raises at module import
instead, so inspect keeps its guard on the import; each has a test that
fails the way the real installation fails.

search --limit/--search-type and history --limit default to None so the
config resolves the default. Now pinned.

CI passed --cov=haiku while pyproject declares source = ["haiku_rag_slim"];
pass --cov and let the config decide. build-docs.yml only ran on push to
main, so a broken docs build merged and failed at deploy: build on pull
requests, with configure-pages, upload-pages-artifact and deploy gated to
push, and a per-ref concurrency group.
2026-08-20 13:23:00 +03:00
Yiorgis Gozadinos
3dcd463792
Merge pull request #566 from ggozad/refactor/store-split
Split the store module by responsibility
2026-08-20 12:26:45 +03:00
Yiorgis Gozadinos
629e1ba4ea
Split the store module by responsibility
engine.py held four unrelated things: what the tables are, how to open a
connection, how to read a database's state, and the Store that coordinates
writes. At 1240 lines the Store's own concerns — locks, migrations, vacuum,
tags — were hard to find among them.

Table records, Arrow schemas, index_specs, ensure_indexes, REQUIRED_TABLES
and query_to_pydantic move to store/schema.py, which imports nothing from
haiku.rag: it describes the tables and never opens or mutates one.

gather_database_info, get_database_stats, DatabaseInfo and its result models
move to store/info.py. Nothing in Store calls them — they are read paths for
the CLI, doctor, inspector and ingester API — so info depends on engine and
not the reverse.

engine.py keeps the Store, ConnectionMode, connect_lancedb, the tag helpers
and the restore-order and retention constants. No re-exports: importers
point at the new modules.

test_app_info_uses_connect_lancedb_for_remote patched
haiku.rag.store.engine.connect_lancedb; gather_database_info now binds that
name in info.py, so the patch targets where the call is looked up.
2026-08-20 12:13:51 +03:00
Yiorgis Gozadinos
7f3590e881
Merge pull request #565 from ggozad/refactor/sources-out-of-ingester
Move source adapters out of the ingester package
2026-08-20 12:00:40 +03:00
Yiorgis Gozadinos
d51356f476
Update benchmarks for qwen3.8 2026-08-20 11:52:40 +03:00
Yiorgis Gozadinos
ab19f78507
Move source adapters out of the ingester package
haiku.rag.ingester.sources was never ingester-only: one-shot client
ingestion resolves adapters through it (create_document_from_source), and
convert() now fetches through HTTPSource, so the core client imported into
the ingester package to reach them.

Move the package to haiku.rag.sources and update every import. No shims:
haiku.rag.ingester.sources is gone.

The haiku.rag.sources plugin entry-point group is unchanged, so third-party
source packages need no edit — the group name now matches the module path it
always implied.

Source unit tests move to tests/sources/. test_source_plugins.py stays in
tests/ingester/: it drives a PeriodicPoller against the job repo, so it is
plugin wiring through ingester machinery rather than a source test.
2026-08-20 11:46:55 +03:00
Yiorgis Gozadinos
a896ac9eec
Merge pull request #564 from ggozad/refactor/prepare-and-acquire
Share document preparation and HTTP acquisition
2026-08-20 11:06:13 +03:00
Yiorgis Gozadinos
d1691d3942
Share document preparation and HTTP acquisition
Five call sites repeated the same post-conversion preparation: store the
Docling representation and resolve a title when none was supplied.
_prepare_and_title now owns that sequence. update_document continues to
call _prepare_document_from_docling directly because an explicit update
must preserve an existing empty title.

create_document, both content-replacement branches of update_document,
and source ingestion embedded eagerly before passing chunks to a
persistence funnel that checked them again. The funnels now own
embedding, including the checks required by import_document and
import_documents for caller-supplied chunks.

Move the document.embed span into ensure_chunks_embedded after its early
return. Every path that performs embedding is now instrumented, while
operations whose chunks are already embedded emit no span.

convert() previously used its own HTTP client and temporary-file path.
Route URL conversion through HTTPSource, matching source ingestion, and
move _write_fetch_body to processing.py so both paths share temporary
file handling without an import cycle.

Add walk_files for filesystem enumeration and use it from both
FSSource.discover and one-shot directory ingestion. Symlink escape
filtering now has one implementation.
2026-08-20 10:50:33 +03:00
Yiorgis Gozadinos
9d64a0d9f0
Merge pull request #563 from ggozad/refactor/evidence-state
Give the evidence capabilities one typed state
2026-08-20 10:27:07 +03:00
Yiorgis Gozadinos
cab510d0d5
Give the evidence capabilities one typed state
RAGState and AnalysisState each declared the same five fields, so the generic
base could not name them: StateT was bound to BaseModel, and every access
went through cast(Any, state), a getattr by string, or a loop clearing fields
by name so it could skip the one only AnalysisState has.

EvidenceState declares them once. RAGState adds nothing, AnalysisState adds
executions and overrides begin_invocation to clear them. StateT binds to
EvidenceState, which removes all ten casts and both state-shape getattrs; the
three getattr(ctx.deps, "state") probes stay, since those check a
host-supplied object rather than our own state.

discover_evidence reached into capability.state for two fields. It now asks
through evidence_record() and citation_index(), alongside the
evidence_tool_names() and cite_available accessors it already used. The eval
runner's _RagLikeState protocol and the chat app's getattr reads described
this shape from outside and are gone.

Compatibility is semantic JSON-object equivalence, not bytes: field names and
nesting are unchanged, so a dict stored by 0.75.0 loads and re-dumps equal,
but deriving from a shared base reorders AnalysisState's keys. Nothing
serializes, hashes or string-compares this state — every carry point
re-validates by key.
2026-08-19 17:08:44 +03:00
Yiorgis Gozadinos
78b3231dcd
Merge pull request #562 from ggozad/docs/config-truth
Make the documented configuration match the code
2026-08-19 16:10:01 +03:00
Yiorgis Gozadinos
48a94f7793
Move unreleased changelog entries out of 0.75.0
The 0.75.0 section was closed after the branch behind #558 was cut, and
every entry since anchored itself to a marker line inside it, so fifteen
entries for unreleased work were filed under a released version: the three
correctness fixes from #558, write_transaction from #559, the Config removal
from #560, the strict-config group from #561, and this PR's four
documentation fixes.

0.75.0 keeps only what it shipped.
2026-08-19 16:00:27 +03:00
Yiorgis Gozadinos
ed5519b38d
Make the documented configuration match the code
search.limit was documented as 10 in three places while the default is 5.
The documented way to disable reranking, provider: "", is a valid
ModelConfig, so it raised "Unknown reranking provider" — disabling means
omitting reranking.model or setting it to null. The inline provider list
named four of the six rerankers. prompts.picture_description: null fails
validation, since the field is a non-optional str.

storage.data_dir: "" coerced to Path("") — the working directory — while two
doc pages promise the platform default and soliplex's example config relies
on it. Empty or whitespace now resolves to the platform directory; an
explicit "." is still honoured, so a config that wants the working directory
says so.

Three tests keep this from drifting again: every fenced yaml block in the
docs validates against AppConfig, every value in the complete example either
equals its default or is listed as a deliberate deviation, and empty
data_dir resolves to the platform default.

init-config's test reimplemented the command body instead of invoking it,
which is why the command carried a coverage pragma. It now goes through
CliRunner, with the refuse-to-overwrite guard covered too.
2026-08-19 15:52:50 +03:00
Yiorgis Gozadinos
058a820e14
Merge pull request #561 from ggozad/feat/strict-config
Reject unknown and out-of-range configuration values
2026-08-19 15:42:36 +03:00
Yiorgis Gozadinos
72ef18e39d
Reject unknown and out-of-range configuration values
Every section inherited plain BaseModel, so unknown keys were dropped
silently: providers.docling_serve.timeout was documented for months while
being ignored, and a typo in any setting took the default. Sections now
derive from ConfigModel, which forbids extras, so a stale or misspelled key
fails with its path. This already found search.context_radius in a live app
config and providers.vllm in soliplex's example.

converter, chunker and chunker_type are Literals. Sizes, limits,
dimensions, token budgets, attempt counts and breaker thresholds must be
positive; retention, delays, intervals and cooldowns non-negative;
similarity_threshold within 0-1; port within 0-65535. port 0 keeps its
OS-assigned meaning and worker_count allows 0 for an API-and-reaper-only
process.

get_reranker caught ImportError and returned None, so a configured reranker
whose extra was missing silently disappeared. It now propagates.
raise_missing_extra names the install command and re-raises when the failure
came from inside an installed package, so a broken transitive import is not
reported as a missing one. zeroentropy imported bare and now guards like the
others.

The haiku.rag package declares the jina extra. jina-local already worked
there through cross-encoder's transitive transformers and torch; the
resolved package set is unchanged, but the support is now promised rather
than inherited.

Provider fields stay unconstrained: get_model ends in a pass-through to
pydantic-ai for any provider it supports, so a Literal there would reject
valid configurations.
2026-08-19 15:32:51 +03:00
Yiorgis Gozadinos
6533c28377
Merge pull request #560 from ggozad/fix/canonical-config
Make get_config the only configuration lookup
2026-08-19 14:54:14 +03:00
Yiorgis Gozadinos
e8f00fcff4
Make get_config the only configuration lookup
haiku.rag.config exported two configuration instances: the lazy _config
behind get_config/set_config, and Config, loaded at import time. Nothing
linked them, and eleven signatures captured Config as a default argument,
so set_config could not reach the factories, the client, the store or the
MCP server. reranking/base.py went further and snapshotted the configured
reranker name into a class attribute at import.

Config is removed. Internal defaults are config: AppConfig | None = None,
resolved through get_config() per call. RerankerBase._model is None and
CohereReranker takes its model name as an argument, like every other
reranker.

The suite patched attributes on Config while production read the instance
get_config() returns, a different object, so those patches were no-ops
waiting to happen. They now go through get_config().
2026-08-19 14:43:40 +03:00
Yiorgis Gozadinos
7465026b6a
Merge pull request #559 from ggozad/fix/store-write-transaction
Put multi-table writes behind one transaction boundary
2026-08-19 14:15:05 +03:00
Yiorgis Gozadinos
2da294b850
Put multi-table writes behind one transaction boundary
Four call sites repeated lock, snapshot, try/except, restore. Each used
restore_table_versions, which restores in _tables() order — documents
first, contradicting RESTORE_TABLE_ORDER — and each caught Exception, so
a cancellation mid-write skipped rollback and left the earlier table
writes committed.

Store.write_transaction() holds the lock, snapshots under it, and rolls
back through _rollback_to_snapshot: RESTORE_TABLE_ORDER, shielded from
cancellation, absorbed cancellation re-delivered, rollback failure raised
with the original as cause. The two single-table update_meta sites keep
the bare lock.

The batch documents write moves inside the guarded body; it was outside
the try, so a failure there was never rolled back. Auto-vacuum is
scheduled after the transaction rather than inside it.

restore_table_versions is removed; those four sites were its only callers.
2026-08-19 14:05:40 +03:00
Yiorgis Gozadinos
489b8a65f0
Merge pull request #558 from ggozad/fix/ingest-rebuild-correctness
correctness defects in source ingestion and rebuild
2026-08-19 14:02:01 +03:00
Yiorgis Gozadinos
15868762b0
Wire providers.docling_serve.timeout through to the client
The setting was documented but did not exist on DoclingServeConfig, so it
was silently dropped, and DoclingServeClient.from_config never forwarded
the timeout parameter it already accepted. The per-request timeout was
therefore pinned at the constructor default of 300s with no way to change
it. Add the field, forward it, and reject a non-positive value.

Also parametrize over the checked-in *.yaml.example files and validate
each through AppConfig, so an example that no longer loads fails a test
rather than a user's first run.
2026-08-19 13:30:45 +03:00
Yiorgis Gozadinos
73944f91ea
Close the source adapter one-shot ingestion builds
create_document_from_source resolves a fetcher per call and never closed
it, so every one-shot URL or WebDAV ingest leaked an httpx connection
pool. Close it, but only when we built it: resolve_adhoc_fetcher returns
a caller-supplied source when one matches the URI, and the ingester keeps
those open across jobs.

Directory ingestion also yielded symlinked files resolving outside the
directory it was given. rglob does not recurse into symlinked
directories, so a symlinked file was the only way out of the tree; skip
those, as FSSource.discover already does.

The chunk repository docstring named client._ensure_chunks_embedded,
which does not exist.
2026-08-19 13:14:21 +03:00
Yiorgis Gozadinos
895b5f0532
Refresh source-backed documents in place on a FULL rebuild
FULL rebuild deleted a document before re-ingesting it from its URI, and
the handler around that logged and continued. A 404, a timeout or any
conversion error therefore removed the document permanently.

Deleting after a successful create is not an alternative:
create_document_from_source resolves the same URI to the existing
document and updates it in place, so a trailing delete would remove the
freshly rebuilt row.

Refresh in place instead. create_document_from_source takes an internal
force flag that skips the revision and MD5 short-circuits, so an
unchanged source is still re-converted, re-chunked and re-embedded into
the existing document, and the document id survives a rebuild.

A failed refresh now falls through to the stored-content path rather
than skipping the document: FULL recreates the chunks table before the
loop, so skipping left the document present but unsearchable until the
next rebuild.

The pending-batch flush moves out of the try. A failed flush is a lost
write and should abort the rebuild, not be logged and skipped.
2026-08-19 13:06:39 +03:00
Yiorgis Gozadinos
2402f324ba
vb 2026-08-19 11:33:56 +03:00
Yiorgis Gozadinos
13bd69b908
Merge pull request #555 from ggozad/feat/batch-enrichment
Batch document-item fetches across documents
2026-08-19 11:04:15 +03:00
Yiorgis Gozadinos
62da6086b8
Stop the reranker fetch reading text it discards
Collapsing the caption text into `get_pictures_grouped` served the enrichment
path, which uses it, but the multimodal reranker discards the second return value
while still paying to read the column. That is the widest fan-out in the codebase,
`limit * 10` candidates, and it previously projected self_ref and picture_data
alone.

`with_text` is opt-in and off by default, so the cheap projection is what a caller
gets unless it asks for more. The reranker test asserts the projection as well as
the query count, since a count alone would not notice the column coming back.
2026-08-18 18:00:00 +03:00
Yiorgis Gozadinos
28217fcf82
Keep result order when expansion is batched
Splitting the assembly into a passthrough pass and an expandable pass reordered
equal-scored results: the score sort that follows is stable, so the order results
arrive in is the tiebreak. Results are assembled in document_groups order again,
after the batched fetch rather than around it.

Also ports the caption negative cases the removed single-document test carried: a
table's caption and an ordinary text reference map to no picture.
2026-08-18 17:41:10 +03:00
Yiorgis Gozadinos
65f6d72cd5
Remove the single-document item accessors the batching replaced
`resolve_refs`, `get_items_in_range`, `get_caption_picture_refs` and
`get_all_items_grouped` have no callers left: the grouped equivalents serve every
path that used them. `get_all_items_grouped` had none even before this branch.

Tests whose subject was a removed method go with it. Tests that only used one to
fetch a fixture now use the grouped call, so what they assert is unchanged.
2026-08-18 17:12:10 +03:00
Yiorgis Gozadinos
460215158d
Batch the multimodal reranker's picture fetch
`_attach_picture_data` fetched picture bytes once per document, over the
`limit * 10` candidates reranking asks for, so it was the per-document fetch with
the most candidates behind it. It now issues one query however many documents the
candidates span: one for ten documents, as for one.

Removes `get_text_for_refs`, whose only caller now gets the text back with the
bytes from `get_pictures_grouped`.

`test_client_search_include_images_false_skips_lookup` returned no search
results, so asserting the picture accessor went uncalled held whatever the code
did. It now returns a picture-carrying result, making "did not fetch" the
assertion rather than "had nothing to fetch".
2026-08-18 16:58:26 +03:00
Yiorgis Gozadinos
5b0444043a
Batch context expansion across documents
`expand_with_items` fetched its own inputs per document: one query to resolve
refs to positions, one for the window of items around them. A result set spanning
N documents cost 2N queries, which was 10 of the 18 measured for a limit=5 search
on a remote object-store corpus.

`expand_context` now does both fetches once for every document it is expanding,
and `expand_with_items` takes the positions and items it needs. Two queries for
one document, and two for five.

Each document keeps its own inclusive window in `get_items_in_ranges`. Positions
repeat across documents, so a shared range would splice one document's items into
another's context.
2026-08-18 16:35:17 +03:00
Yiorgis Gozadinos
af6a6b0bbe
Batch search enrichment across documents
`_populate_image_data` ran its stages once per result document, so a result set
spanning N documents cost 4N `document_items` queries. Measured on a remote
object-store corpus, a limit=5 search with expansion was 18 queries, 16 of them
against `document_items`.

The stages now run once each across every document, and flat in document count:
two queries for the dependent caption-to-picture mapping when results ranked on a
caption, one for the picture bytes. Two queries for a picture-ref result set,
three at most.

Picture text comes back with the bytes rather than from a second query, since it
is on the same rows.

Predicates are per document, `(document_id = 'a' AND self_ref IN (…)) OR (…)`,
rather than `self_ref IN (union)`. self_ref and position values repeat across
documents, so a union predicate would return other documents' rows: for
picture_data that fetches blobs nobody asked for, and it can hand one document
another document's picture.
2026-08-18 16:23:16 +03:00
Yiorgis Gozadinos
d177b5883b
Merge pull request #551 from lawrenceakka/chunk-metadata
Expose all chunk metadata on search results and citations
2026-08-18 15:30:43 +03:00
Lawrence Akka
cdd1b99e6b
Merge branch 'main' into chunk-metadata 2026-08-18 14:15:56 +02:00
Lawrence Akka
4eb72d6a49 Ignore warning caused by logfire
See https://github.com/ggozad/haiku.rag/pull/551#issuecomment-5327750999
2026-08-18 14:11:57 +02:00
Yiorgis Gozadinos
adcf27f650
Merge pull request #553 from ggozad/feat/session-reuse
Share the LanceDB session and open one client per server
2026-08-18 15:11:24 +03:00
Yiorgis Gozadinos
0882dc9fed
Lend the caller's client to the capability
`client.ask` and `client.analyze` built their capability from a db_path, so the
capability opened a second connection to the database the client already had
open, once per call.

Ownership is now explicit rather than inferred. `rag` stays the connection the
capability opened and must close; `borrowed_rag` is a caller's, which
`_ensure_rag` prefers and `_close` never touches. Two fields rather than a flag,
so closing a borrowed connection is not expressible.

`for_run` still clears `rag` per run, since a run owns what it opens. It leaves
`borrowed_rag` alone: that connection belongs to the caller and outlives the run.
2026-08-18 14:58:18 +03:00
Yiorgis Gozadinos
8a4d488a72
Give the MCP server one client for its lifetime
All ten tool bodies opened their own `HaikuRAG`, so every tool call paid a
connection open and, on object storage, refetched the index the previous call had
just cached. The client is now opened once, lazily so that calling a tool
function directly still works, and eagerly from the lifespan so an unopenable
database fails startup instead of every call. Teardown clears the cached client
in a finally, since `_lifespan_manager` can be re-entered and would otherwise
hand out a closed connection, including when the close itself fails.

`delete_document` no longer opens its own connection with `skip_validation=True`.
Keeping it separate broke consistency once connections became long-lived: the
delete committed on one connection while reads served from another, which with a
30s consistency interval showed the deleted document as still present. A
connection always sees its own writes, so sharing one is what makes delete
visible to the next read.

So the server no longer opts out of embedding-config validation. Drift that
validation rejects now fails MCP startup, where before the server started and
only `delete_document` worked while every read returned empty. Same-dimension
identity drift still starts a read-only server, matching every other read verb.
Delete under drift is now a CLI operation; CLAUDE.md and the CHANGELOG record it.
2026-08-18 14:58:18 +03:00
Yiorgis Gozadinos
da207da106
Read the table list and settings once per open
Opening a database ran `list_tables` three times, opened the settings table
twice, and read and parsed the same settings row three times: once for the stored
vector dimension, once for the version behind the migration check, and once for
config validation. On object storage each of those is a round trip.

`_initialize` now reads both once and threads them down. `_init_tables` and
`_check_migrations` take what it read instead of fetching their own copy, and
`validate_config_compatibility` accepts the settings it should compare against,
still reading for itself when called directly.

Passing the pre-init read to validation is equivalent: nothing between the read
and the validation rewrites `embeddings`, which is all it compares.

The settings read no longer swallows every exception. It did before, when the
only consequence was falling back to the configured vector dimension; now the
same empty result feeds the migration check, where it would read as version
0.0.0 and declare every migration pending. Only decode failures are tolerated,
and a decoded non-object normalizes to {} rather than reaching callers that
expect a mapping.
2026-08-18 14:58:17 +03:00
Yiorgis Gozadinos
6f976ef2a9
Share the LanceDB session across connections
Every `Store` built its own connection with its own caches and discarded them on
close, so the index a vector query loads was refetched by the next connection.
On object storage that first fetch dominates: measured on a ~500k-chunk 2560-dim
corpus over a ~200ms link, the first query cost ~41s and the second ~3s, and a
new connection reusing the session cost ~7s instead of ~47s.

`connect_lancedb` now passes a process-wide session, keyed on the configured
cache sizes so a caller asking for different sizes gets its own.

Also sets `read_consistency_interval`, defaulting to 30s. It was None, meaning a
connection never re-checked for other processes' writes. Per-call connections hid
that; a shared session makes connections long-lived enough for a reader to go
stale against the ingester.

All three settings reject negatives at the config boundary. A negative cache size
raises OverflowError and a negative interval panics inside Lance, so neither is
catchable further in. Zero stays valid for both: no cache, and check on every
read.

The routing tests now assert the kwargs they care about rather than the full call
signature, since every connection carries the two new kwargs.
2026-08-18 14:58:17 +03:00
Yiorgis Gozadinos
c5a8e5571d
Merge pull request #552 from ggozad/worktree-visibility-metadata
Fix the MCP registry entry and sharpen discovery metadata
2026-08-18 14:57:12 +03:00
Yiorgis Gozadinos
198d9d7b73
Lead the benchmarks page with results
Current results moved above Methodology and Running Evaluations. A reader arriving from an external link met four screens of CLI flags and download instructions before any number.

Benchmarks is promoted to a top-level nav entry, out of Reference, which holds Development and Changelog.

The page move itself changes no wording or figures.
2026-08-18 14:37:40 +03:00
Yiorgis Gozadinos
4bcfb9cc3b
Lead the README with what haiku.rag does 2026-08-18 14:37:05 +03:00
Yiorgis Gozadinos
d28e2662e5
Fix the MCP registry entry and fill in package and docs metadata 2026-08-18 14:37:05 +03:00
Yiorgis Gozadinos
bd7946178d
Pin the eval judge to qwen3.8 2026-08-18 14:28:16 +03:00
Lawrence Akka
2839a4434a Test that chunk metadata survivies FastMCP's wire serialization 2026-08-18 12:55:04 +02:00
Lawrence Akka
b221b4ac03 Do not duplicate metadata items in inspector 2026-08-18 12:30:13 +02:00
Lawrence Akka
47feeb32ee Linting, doc edits 2026-08-18 12:18:10 +02:00
Yiorgis Gozadinos
a5c4647005
Merge pull request #550 from ggozad/feat/scalar-index-migration
Index every hot lookup key from one shared definition
2026-08-18 12:48:25 +03:00
Yiorgis Gozadinos
11644c7f43
Trim comments, docstrings and docs to what they need to say
Also drops two things that were stale rather than merely verbose: the CLI docs
note for 0.75.0, which was the only release-tagged note in the docs tree while
the CHANGELOG already records that existing databases need `haiku-rag migrate`;
and "(created or corrected)" from the migration log line, left over from the
earlier behaviour that replaced wrong-typed indexes.
2026-08-18 12:38:02 +03:00
Lawrence Akka
3a9ded2218 Display all chunk metadata in inspector 2026-08-17 20:25:36 +02:00
Lawrence Akka
09ff101349 Expose all chunk metadata on search results and citations through SearchResult.chunk_meta and Citation.chunk_meta 2026-08-17 19:42:23 +02:00
Yiorgis Gozadinos
8e93b639bc
Migrate existing databases to the full index set
Adds the 0.75.0 upgrade, which brings a pre-existing database up to the index
set `_init_tables` now creates. It rewrites no table data, so unlike the earlier
data migrations its cost is the index builds alone, each of which reads the
column it indexes.

`ensure_indexes` ensures an index of the declared *type* covers each declared
column, rather than checking that the column is indexed at all. The distinction
is what makes it safe to run against a database of unknown provenance:

- A wrong-typed index no longer satisfies the check. A BTree on `label` covers
  the column while losing the low-cardinality equality lookup the Bitmap is for.
- Nothing is dropped or converted away from. Two index types over one column can
  be deliberate, serving different query shapes, so an index this function did
  not declare survives even on a column it does. The one thing it overwrites is
  an index at LanceDB's default name, `{column}_idx`, which is the name it
  creates itself.
- A column already carrying the declared type is skipped, so a database with the
  full set migrates instantly rather than re-sorting every indexed column.
- Undeclared columns are untouched, so a vector index on `chunks` survives.

It returns the columns it acted on, because a change is not always visible from
outside: adding a Bitmap beside an existing BTree leaves the column indexed
before and after.

The version bump to 0.75.0 is required, not incidental: `_set_initial_version`
stamps a new database with the installed package version, so a migration
numbered above it would be pending the moment the database was created.

`test_client_update_document_replaces_rows_with_bounded_versions` turns
auto_vacuum off. Indexing `documents` means a background vacuum now has an index
to maintain on that table, so `optimize()` writes a version where it previously
had nothing to do, and it landed inside the window the test measures. The
document update itself is still one version, so the bound stays exact.
2026-08-17 16:34:58 +03:00
Yiorgis Gozadinos
c184a25d68
Index every hot lookup key from one shared definition
`_init_tables` left `chunks.id`, `chunks.document_id` and `documents.id`
unindexed, so those lookups scanned the column. On object storage that is
network I/O per query, on paths that run per document: citation lookup,
delete-by-document, the re-ingest merge, and every dedup probe.

Declare the index set per table in `index_specs()` and apply it through
`ensure_indexes()`, which skips a column only when it is already indexed with
the declared type. Both halves of that are load-bearing. Skipping is required
because `create_index(replace=True)` rebuilds an identical index, writing a new
index and a new table version and orphaning the old files until the next vacuum.
Comparing the type is required because column coverage alone would let a
wrong-typed index stand, and a BTree on `label` silently loses the
low-cardinality equality lookup the Bitmap is there for.

Columns not declared for a table are left alone, so an externally created index
such as a vector index on `chunks` survives.

`_init_tables`, `recreate_embeddings_table`, `ChunkRepository.delete_all` and
`DocumentRepository.delete_all` now all route through it instead of repeating
their own subsets.

Also recreate `document_items` from `get_document_items_arrow_schema()` in
`DocumentRepository.delete_all`, which was using the LanceModel and so returned
`picture_data` as 32-bit `binary`.

Existing databases are unchanged; the migration follows separately.
2026-08-17 15:05:37 +03:00
Yiorgis Gozadinos
980b1985ab
Merge pull request #545 from ggozad/feat/mtrag-bench
MTRAG multi-turn evaluation with compaction arms
2026-08-17 11:28:49 +03:00
Yiorgis Gozadinos
73dfc62f33
Exclude fully unjudged conversations from the macro pass rate 2026-08-17 11:19:59 +03:00
Yiorgis Gozadinos
587ba75a62
Thread the document filter through the live QA runner 2026-08-17 11:03:52 +03:00
Yiorgis Gozadinos
3b9d6ae2c6
Publish the paired statistics behind the MTRAG compaction claims 2026-08-17 10:56:27 +03:00
Yiorgis Gozadinos
0634964e64
Point the mtrag reference config at the measured baseline model 2026-08-17 10:56:27 +03:00
Yiorgis Gozadinos
bf94efc655
Simplify the eval harness and share the embed-fill path. 2026-08-17 10:56:26 +03:00
Yiorgis Gozadinos
f1d5918d43
Update MTRAG benchmark numbers to the glimmer baseline 2026-08-17 10:54:20 +03:00
Yiorgis Gozadinos
db2b8fb883
Add compaction arms and grounding status to MTRAG live runs 2026-08-17 10:54:20 +03:00
Yiorgis Gozadinos
73d9d93db9
Add MTRAG ClapNQ multi-turn evaluation
IBM's MTRAG benchmark (ClapNQ domain, pinned repo SHA): retrieval with
Recall@k/nDCG@k against binary qrels, gold-prefix QA replaying reference
conversation prefixes as message history, and live-session replay
carrying the model's own answers and tool history across turns.

Corpus population gains a bounded, resumable batched ingest path.
ConversationInput case type with transcript rendering for the judge,
eligibility-aware citation scoring, refusal precision/recall via a
label-aware RefusalJudge, per-turn verdicts with judged-turn coverage,
and per-turn tool-traffic attributes counted from each turn's new
messages so the arrays survive prior-turn compaction.
2026-08-17 10:53:16 +03:00
Yiorgis Gozadinos
cc04f92f28
Batch embeddings across import_documents batches
_store_documents_with_chunks embedded each document's chunks in its own
embed_chunks call; chunks missing embeddings are now flattened across the
whole batch, embedded in one pass honoring embeddings.batch_size, and
assigned back positionally.
2026-08-17 10:52:11 +03:00
Yiorgis Gozadinos
b07426a883
Merge pull request #543 from Cwiesen/feat-evaluations-search-filter
feat: add search_filter to evaluations
2026-08-17 10:45:43 +03:00
Yiorgis Gozadinos
a75d89122e
Drop the unused per-dataset filter default
No `DatasetSpec` declared `search_filter`, so `resolve_search_filter`
and the `--filter ""` clearing rule reconciled the flag against a
default that never existed. The flag alone covers the case. An empty
clause reaches `ChunkRepository.search`, which already treats it as
unfiltered.

Rename to `document_filter` throughout, matching
`run_capability_question`'s parameter and the metadata key that lands
in Logfire.

`_stub_spec` merges its overrides, so a test can override a loader
instead of rebuilding the whole spec.
2026-08-17 10:29:28 +03:00
Yiorgis Gozadinos
9b2ae347d2
Use filters that match the datasets they document
The `--filter` examples used `uri LIKE '%arxiv%'`, which matches no
`orb_text` document: its URIs are bare arXiv ids such as `2407.01528v3`.
A clause that matches nothing scores MAP 0 rather than erroring, so the
example failed silently.

`await_args` is typed `_Call | None`, so subscripting it fails
`ty check`; `call_args` carries the same call for an AsyncMock.
2026-08-17 10:19:51 +03:00
Yiorgis Gozadinos
90e53b7348
Trim the Muse-Glimmer benchmark footnote to the measured configuration 2026-08-16 14:19:53 +03:00
Yiorgis Gozadinos
92cab7c846
Record the Muse-Glimmer ORB multimodal numbers 2026-08-16 14:16:40 +03:00
cwiesen
db6996b629 fix: revert changes to benchmarks.md file 2026-08-14 16:36:51 -05:00
cwiesen
a68cb23b6e fix: resolve incorrect dataset and column namings 2026-08-14 16:29:51 -05:00
cwiesen
93c21272d1 feat: add search_filter to evaluations 2026-08-14 16:29:51 -05:00
Yiorgis Gozadinos
981353eb98
Merge pull request #544 from ggozad/chore/remove-wix
Remove the wix evaluation dataset
2026-08-14 14:58:17 +03:00
Yiorgis Gozadinos
e522d8cfa8
Remove the wix evaluation dataset 2026-08-14 14:46:51 +03:00
Yiorgis Gozadinos
d864cf8008
vb 2026-08-13 16:16:35 +03:00
Yiorgis Gozadinos
c5ecf718d9
Merge pull request #541 from ggozad/fix/new-question-detection
Decide a question's lifecycle from state, not from the transcript
2026-08-13 16:09:27 +03:00
Yiorgis Gozadinos
d1e3b828f1
Judge each capability's evidence by its own record
The guard accepted one carried record as covering the whole request, so a host
retaining only the RAG namespace had its earlier analysis evidence replaced by
receipts and lost, with the RAG record making the loss look accounted for.

Each capability is now judged on its own, and only when its own evidence is at
stake: another capability's record says nothing about this one's, and requiring
every record would stop a host that registers both capabilities and only ever
uses one.
2026-08-13 15:57:33 +03:00
Yiorgis Gozadinos
e1dd8517f9
Refuse to compact evidence the host kept no record of
Both optional capabilities read what earlier questions retrieved and cited from
the capability's state, so a host that carries only the message history hands
every run an empty record. Compaction then replaced the earlier evidence with
receipts and retained nothing, and the loss was invisible: the citations the host
already displayed were still there. It now refuses when it finds evidence from an
earlier question and no record of what that question cited.

`state_carried` reaches the optional capabilities through discovery, so the
refusal distinguishes a host that never carries state from a question that simply
cited nothing.

The documentation taught the pattern that breaks: the compose example is now
stateful and the requirement is stated where each capability is introduced.

The app's browser storage was doing exactly this, keeping only the fields the UI
reads. It now persists the whole namespace map, so the citation policy's
violations survive a reload as well as the evidence record.
2026-08-13 15:46:23 +03:00
Yiorgis Gozadinos
53bbf52697
Round-trip the capability's whole state through the browser
The session store rebuilt the rag namespace from four known keys, so the
evidence record never survived a turn, let alone a reload from localStorage.
Compaction then ran with an empty ledger: earlier evidence was replaced by
receipts retaining nothing, while the citations already in citation_index kept
the UI looking correct.

The UI still names the fields it reads, but everything else in the namespace
passes through untouched, and seeding the namespace no longer replaces sibling
namespaces.
2026-08-13 15:20:02 +03:00
Yiorgis Gozadinos
771ac9c96c
Register the optional capabilities where agents are composed
The README feature list and the overview stopped at the analysis capability.
Both examples and the app backend composed agents without the capabilities the
documentation recommends alongside an evidence capability.

custom_agent.py ran each input as an independent agent run, so it needed a state
dict and a carried history before compaction could mean anything there: without
state the evidence record is empty, and earlier evidence would reduce to
receipts retaining nothing.
2026-08-13 15:04:05 +03:00
Yiorgis Gozadinos
21e261f608
Stop a partly resolved citation asking to be retried
A cite call naming one good id and one mangled one registered the good one and
then asked the model to cite again. A model that mangles ids obeys, mangles
again, and the run dies on output retries with the answer lost: observed at 22
consecutive cite calls against gemma4-26b, with the citation policy registered
to press for a declaration. The branch is only reachable once something has
registered, so the answer already has grounding and there is nothing to ask for.
2026-08-13 15:03:51 +03:00
Yiorgis Gozadinos
3ec72363bc
Let the record say whether a question is still being answered
Resumption was inferred from the transcript, and no shape says it. A missing
prompt is how UI adapters ask their first question as much as how pydantic-ai
resumes one, so every AG-UI host failed on its first message. Reading the tail
instead moved the error rather than fixing it: a settled structured answer ends
with an output tool's return, indistinguishable from results delivered to a
question still in progress, so following questions inherited the first one's
identity.

`CapabilityEvidenceRecord.in_progress` is now the authority. `begin_question`
sets it, `after_run` clears it unless the run is only pausing for deferred work,
and a run that raised never reaches `after_run`, which is what leaves an
interrupted question claimable. The history is consulted only to catch a host
that dropped the state of a question the model is unmistakably still owed.
2026-08-13 15:03:24 +03:00
Yiorgis Gozadinos
7bdd11db39
Update caps documentation 2026-08-13 14:08:14 +03:00
Yiorgis Gozadinos
0a0ddbd3b6
Merge pull request #538 from ggozad/feat/citation-policy
Citation policy: require a declaration, accept an empty one
2026-08-13 13:55:19 +03:00
Yiorgis Gozadinos
cee0824744
Say precisely which conversations go unenforced
The documentation claimed a conversation that has never cited anything is not enforced,
which reads as though a first question could answer from fresh evidence without
declaring it. Enforcement needs neither condition to hold: no evidence outcome in this
question, and nothing cited earlier.

The structured-output test accepted a redirect or a recorded violation, so a break in
ending detection would still have passed on the backstop alone. The cite tool is
available in that scenario, so it asserts the redirect.
2026-08-13 13:39:43 +03:00
Yiorgis Gozadinos
eb9934a6e5
Notice the endings a structured answer arrives in
An output tool call is a `ToolCallPart` like any other, and treating every tool call as
intermediate meant a model could search, skip citing, emit its structured answer and
finish with neither a redirect nor a record. A response ends the question when it
carries no tool calls, or when one of its calls names an output tool.

Some endings are not visible from a single response — a host running
`end_strategy="early"` can finish on text beside a function call — so `after_run` is
the backstop: it cannot ask the model for anything by then, but it records a question
that reached the end of its run undeclared. That also covers a question that was asked
once, ignored, and finished anyway, which previously returned early on the redirect
marker and went unrecorded.

The capability documentation and the changelog said a question that gathered no
evidence is left alone. That describes neither the code nor the intent: enforcement
applies wherever there is something to declare, which includes a follow-up that reuses
evidence cited earlier without searching again.
2026-08-13 13:39:43 +03:00
Yiorgis Gozadinos
a79f348ac7
Recognise our own redirect by a tag, not by its wording
`_already_asked` matched the redirect's phrasing, so a question that merely contained
it read as a redirect we had sent and switched enforcement off for that question. The
redirect carries `[haiku.rag/citation-redirect]` and detection matches that, the same
way retrieved pictures are identified by a tag rather than by the prose beside them.
2026-08-13 13:39:43 +03:00
Yiorgis Gozadinos
ae2755e88f
Enforce a declaration wherever there is something to declare
Requiring an evidence outcome from the current question exempted the case enforcement
exists for: a follow-up about evidence already cited needs no new search, since that
evidence is still on the wire — in a capsule when a compactor is registered, in full
when not. The condition is now that the conversation has something to declare, either
an outcome in this question or evidence it has already cited, which is independent of
whether anything compacts. A conversation that has neither is still left alone.

Citing again cannot narrow a question at any epoch. Declarations merged only within one
epoch, so an empty second thought a request later replaced the refs with nothing and
reported a grounded question ungrounded. They merge while no evidence outcome has
followed the standing declaration, and only genuinely newer evidence starts one afresh.

Whether a question has already been asked to declare is read from the message history
rather than remembered on the run instance, which a resumption's `for_run` discarded —
the same question was asked twice. Reading the history also makes the right call when a
redirect was enqueued but the run ended before it reached the model: nothing is in the
history, so it is asked again. Violations are recorded once per question for the same
reason.
2026-08-13 13:39:42 +03:00
Yiorgis Gozadinos
0f38417c60
Require an answer to declare what grounds it, once per question
`CitationPolicyCapability` makes the single enforcement decision, whatever mix of
evidence capabilities is registered: two of them must not each demand a citation for
one answer. It decides in `after_model_request`, when a response carries no tool calls
and the question can still be redirected. An explicitly ungrounded answer is a
declaration and is left alone; a question that gathered no evidence is left alone too,
read from the ledger rather than from a searches dict that a new question clears. When
the cite tool is already withdrawn the question is recorded in
`CitationPolicyState.violations` instead of pointing the model at a tool that is gone.

Registering it is the only switch. `DiscoveredEvidence` and discovery move to
`capabilities.evidence` so both optional capabilities share them, and `cite_available`
joins `evidence_tool_names` as public for the same reason.

Measured on Qwen3.6-35B over two arms of 37 questions, 29 of them unanswerable from
the corpus: explicit ungrounded declarations rose from 23 to 26, grounded answers to
unanswerable questions fell from 4 to 2, answerable questions stayed at 8 of 8, and
one redirect fired in the whole arm. Reading every answer found no invented grounding.
2026-08-13 13:39:42 +03:00
Yiorgis Gozadinos
e1e7936d15
Let a model declare that nothing grounds its answer
`rag_cite` and `analysis_cite` accepted only a non-empty `chunk_ids`, so a model with
nothing to cite could comply only by staying silent — indistinguishable from
forgetting. An empty list is now a valid answer to "what grounds this?", recorded as a
declaration with no refs, which derives `ungrounded` rather than leaving the question
undeclared. Citing again cannot narrow it: an empty call after a grounded one leaves
it grounded.

The instructions lose their carve-outs. Refusing for lack of information no longer
exempts the call, and a corpus-level computation cites an empty list instead of
skipping.
2026-08-13 13:39:42 +03:00
Yiorgis Gozadinos
89b3a9186c
Merge pull request #535 from ggozad/feat/evidence-compaction
Record cited evidence, and make history compaction an explicit capability
2026-08-13 13:38:22 +03:00
Yiorgis Gozadinos
f4d8e744a2
Record this release's ORB multimodal numbers
The nemotron rows for retrieval and for both capabilities are re-measured on
this release with no reranker, judged by Qwen3.6-35B with thinking on. Rows for
other embedders and older versions keep their own attribution.
2026-08-13 13:00:02 +03:00
Yiorgis Gozadinos
97a29d168f
Confine the ledger's epochs to the question that recorded them
A question's evidence epoch and declaration describe that question and end with
it, so `begin_question` clears them. Held past it, they let the last question's
citations ground the next one and hold a horizon its own declarations cannot
pass, and they force every question's message count to be comparable to counts
taken over a history that a host may since have rebuilt: a thread reconstructed
without a run that never finished shifts every later position, and the next
question was refused where answering it is correct.

Identities still separate one question from the next. Occurrences outlive the
question that recorded them and carry the identities that cited them, which the
capsule is grouped and ordered by, so reuse merges two questions into one group
and a lower identity renders later evidence as earlier.
2026-08-13 13:00:02 +03:00
Yiorgis Gozadinos
11b1bfbc94
Give the capsule to one return, and the run its own copy of state
A request can carry several of this capability's returns — a model can call search
twice in one response — and the carrier was identified by message alone, so each of
them received the whole capsule. It is selected by message and part now, so exactly
one carries it however many share the request.

The chat TUI passed its persisted state into the run, so tool synchronisation mutated
it in place while the message history was promoted only on success. A cancelled or
failed run therefore kept the evidence the tools had recorded and discarded the
messages that justified it, and the next question derived its identity from the
shorter history: behind the recorded epoch, refused as non-append-only, the
conversation unusable until cleared. The run gets a copy, promoted with the messages
or not at all.

Five decorators had been left attached to a helper by an earlier extraction, which
pytest does not collect, so the resume case they carried was silently untested. The
wire test covers both resume shapes again, no prompt and deferred results.
2026-08-13 13:00:02 +03:00
Yiorgis Gozadinos
a69f3a8a98
Document the evidence record and the compaction capability
The capability pages said tool results from earlier turns are replaced before every
model request. That is now the compaction capability's job, and only when a host
registers it, so both pages point at it instead of describing it as automatic.
`RAGState` gains its `evidence` field, and the note about per-run resets now says
what a resumption keeps.
2026-08-13 13:00:02 +03:00
Yiorgis Gozadinos
2cd568847e
Move the wire rewrite into the compaction capability
`_compact_old_tool_returns`, `PRIOR_TURN_NOTICE` and `turn_start` leave
`RAGCapabilityBase`, along with its `wrap_model_request` hook. The evidence
capabilities now retrieve and validate, and nothing else. Registering the compaction
capability is what rewrites a request; leaving it out sends the transcript untouched,
which was never a choice a host could make before.

The boundary is the recorded question identity rather than message shape, so a
resumption compacts what lies below the question in progress instead of switching
compaction off for the whole run. The newest earlier evidence return carries the
capsule and every other becomes a receipt, so one capsule exists by construction and
every return stays paired with its call.

Pictures of cited evidence are fetched through the capability that retrieved them and
re-attached beside the capsule with fresh labels. Ownership of a picture on the wire
requires the machine tag we write and an image directly after it, since neither
position nor prose is proof: several tools' results can arrive in one request, and a
user quoting our wording above their own picture had it removed. A picture that cannot
be fetched or decoded is emitted with neither its image nor its label.

The chat TUI and the example backend register the compactor, being multi-turn.
`client.ask`, `client.analyze` and the MCP tools do not: a single-shot question has
nothing earlier to compact.
2026-08-13 13:00:02 +03:00
Yiorgis Gozadinos
0aa6d79f88
Build one capsule of cited evidence from the records
`EvidenceCompactionCapability` reads what the evidence capabilities recorded out of
the run registry, and `build_capsule` renders it: every cited item, grouped by the
question that last cited it, newest group first, each rendered once, with the
pictures of cited evidence and the labels that must accompany them. Discovery runs
one way and reads only, so no capability holds a reference to another and a host
running one, both or neither needs no wiring change.

Everything cited is kept whole and everything else is dropped. There is no character
budget, no picture cap and nothing to configure: a cap would only half-rescue models
that fail on long conversations regardless, and a host that needs earlier evidence
pruned can compact its own requests further.

A capability reports which of its tools produce evidence, so a cite acknowledgement
is never mistaken for one. Pictures are identified by owner, document and reference,
so one figure cited through overlapping chunks is attached once while the same
reference in another document stays a different picture.

The builder does no I/O and never sees the message history, so a picture travels with
its label and the caller fetches the bytes. Nothing reaches the wire yet.
2026-08-13 13:00:02 +03:00
Yiorgis Gozadinos
42d923fe4b
Make the ledger's clocks defensible against the host
A question identity is unset until a run establishes it. Testing whether the
record exists cannot stand in for that: a host with no state to send seeds a
default record, and a default record is truthy, so a resumption missing its real
state would have proceeded as question zero.

Every way of recording a message count now refuses one behind what is already
stored, through one shared check: a new identity, an evidence outcome and a
declaration, each against the newest identity, evidence epoch and declaration
epoch. Identities and epochs are only comparable while the conversation grows,
and `before_model_request` results are assigned back onto history, so that is a
constraint on the host rather than a guarantee of the framework. Left unchecked,
an evidence outcome moving backwards freezes every later declaration as stale,
and a declaration moving backwards replaces a newer one with an older one and
revives the answer it grounded.

The searches, citations and executions of a question in progress survive a
resumption. Clearing them cost the results the model was still answering from: a
citation afterwards recorded no provenance and could not resolve against the
expanded result it had seen, falling through to a database lookup.

A code execution counts as evidence when it succeeded or printed something. A
raised error with an empty stdout grounds nothing.
2026-08-13 13:00:02 +03:00
Yiorgis Gozadinos
85594a1fd1
Record what each evidence capability retrieved and cited
`CapabilityEvidenceRecord` holds the relationships a transcript cannot express:
which chunks a capability retrieved, which it cited, in which questions, and at
which point in the conversation. RAG and analysis each own one in their own state
namespace. Nothing is co-written: the host's state is JSON storage, so a shared
record would be overwritten by whichever capability synced last, and merging
happens in transient per-request views instead.

Both clocks are derived from the conversation rather than counted locally, so
every participant computes the same values without sharing a counter. Question
identity is the message count when the question arrived; epoch is the message
count at an outcome. Epochs are therefore globally comparable, which is what
lets `citation_status` require a declaration to follow the newest evidence of
every capability, and what makes equal epochs mean one request.

A declaration is written only after `resolve_citations` succeeds, so a call
naming only unresolvable ids is not a citation. Status is derived, never stored,
so refs and status cannot contradict.

Resuming a question requires the host to carry the capability state from the run
being resumed. Without it the identity of the question in progress is unknowable,
and adopting the current message count would relabel that question as a new one
and judge every declaration in it against the wrong identity.

Nothing reads the records yet and no wire behaviour changes.
2026-08-13 13:00:01 +03:00
Yiorgis Gozadinos
fa0c4a4f50
Treat an unfinished history tail as a continuation too
deferred_tool_results may arrive with a non-empty prompt, so the absence of a
prompt cannot be the only test for a resumption. Reproduced: resuming a live
question that way replaced its search result with the earlier-question notice
while the deferred result arrived alongside it.

_is_resumption accepts either signal — no prompt, or a history ending with a
request the model has not answered or a response whose tool calls have no
returns. A settled history ends with the previous answer, so a genuinely new
question is unaffected.

A new prompt on top of an unanswered tail is ambiguous and now counts as a
continuation: compacting costs the answer if it is one, while not compacting
only costs a larger request.
2026-08-13 13:00:01 +03:00
Yiorgis Gozadinos
8eef0b3734
Leave a resumed run's evidence alone
A run carrying no prompt is continuing a question rather than asking one:
pydantic-ai resumes that way for deferred tool results, interruptions and
suspended responses. len(ctx.messages) then counts the live question's own
messages, so its search result was replaced by the earlier-question notice and
the model was asked to answer with the evidence removed. Reproduced: resuming
with an in-flight history left a notice where the only evidence was.

Switch compaction off for the whole run when ctx.prompt is None. The absence of
a prompt is the signal rather than the message layout — the resume shapes differ
from each other, and deriving the boundary from layout is what broke this to
begin with.
2026-08-13 13:00:01 +03:00
Yiorgis Gozadinos
46f7ab8d97
Name the search result each page image belongs to
ToolReturn.content reaches the model as a user-role message and the pictures
arrive bare, so nothing connects a figure to the chunk it came from:
BinaryContent.identifier does not survive serialization to the vision API, and
the captions in the result text correlate only by position.

Precede each picture with its position, source chunk id and self_ref.
build_binary_parts_from_results becomes build_image_content_from_results and
returns the labels interleaved with the pictures, so both attachment sites emit
them the same way.

This does not stop a model narrating retrieved pictures as user-supplied.
Measured on gemma4-26b with a single note ahead of the batch, and again with
per-image labels: it quotes the label and still says the user provided them.
The message role wins over its text.
2026-08-13 13:00:01 +03:00
Yiorgis Gozadinos
d9bd3a701f
Take the compaction turn boundary from the run, not the message shape 2026-08-13 12:59:22 +03:00
Yiorgis Gozadinos
5d76461b9f
Trim earlier-question evidence off the wire, keeping the images
_compact_old_tool_returns ran in before_model_request, whose result core
assigns back onto ctx.state.message_history, so the trim reached
all_messages() and every host that persists a thread. It now runs in
wrap_model_request, which operates on a detached list: the model sees the
trimmed history, the host keeps what it retrieved.

Content attached to a ToolReturn arrives as its own UserPromptPart in the
same ModelRequest as the ToolReturnPart, so the turn-boundary scan read an
image-bearing search result as a new user turn and discarded evidence
retrieved earlier in the same turn. _is_user_turn() now requires a request
with no tool returns.

Page images on a replaced return stay. Dropping them with their text bounds
context growth, but a follow-up about a figure already shown ("what colour
is that box?") carries no terms that could retrieve it again: measured on
gemma4-26b against two ORB figures, removing the image turned both answers
into "I cannot find enough information", and keeping it answers correctly.
Bounding that growth needs to preserve cited figures, which is a separate
change.

The replacement notice no longer claims citations remain in state;
_clear_invocation_state has cleared them by then.
2026-08-13 12:59:22 +03:00
Yiorgis Gozadinos
8762df5e75
Merge pull request #540 from ggozad/chore/update-deps
Update dependencies for security advisories
2026-08-13 12:54:27 +03:00
Yiorgis Gozadinos
bf744fc72a
Update dependencies for security advisories 2026-08-13 12:44:07 +03:00
Yiorgis Gozadinos
10fd89f547
Merge pull request #536 from ggozad/fix/reuse-docling-converter
Reuse the local docling converter across documents
2026-08-13 12:43:40 +03:00
Yiorgis Gozadinos
af77f836d5
Reuse the local docling converter across documents 2026-08-13 11:39:01 +03:00
Yiorgis Gozadinos
6186dae83f
Move ingester telemetry setup behind argument parsing 2026-08-13 11:37:36 +03:00
Yiorgis Gozadinos
614be8b2a7
Merge pull request #537 from lawrenceakka/help-speedup
Stop eagerly importing Store from haiku.rag.store
2026-08-13 11:34:42 +03:00
Lawrence Akka
d4d6414bc4 Similar fix for haiku.rag.ingester, and formatting
- Add regression tests
- Update Changelog
2026-08-12 15:20:11 +01:00
Lawrence Akka
5e6284fd02 Stop eagerly importing Store from haiku.rag.store
Import Store from haiku.rag.store.engine directly instead of re-exporting it
through the package __init__, and defer HaikuRAGApp's import in cli.py behind
TYPE_CHECKING/lazy imports, avoiding an eager import at CLI startup.
2026-08-11 17:26:51 +01:00
Yiorgis Gozadinos
432c81149c
Merge pull request #529 from ggozad/fix/cross-encoder-activation
Keep cross-encoder rerank scores apart when they saturate
2026-08-07 14:17:49 +03:00
Yiorgis Gozadinos
d63199d96d
Keep cross-encoder rerank scores apart when they saturate
mxbai-rerank-base-v2 ships a Sigmoid activation and evaluates it in bf16, so
every strongly-relevant candidate rounds to exactly 1.0. Ties then leave the
order to the stable sort, which preserves the incoming hybrid ranking: on 100
t2_finqa retrieval cases the reranker scored MAP 0.661 against 0.659 with no
reranker at all, and 0.742 once the scores separate.

Ask the model for logits and apply the sigmoid here, where it runs in float64.
Scores stay 0-1, matching the cohere, vllm and zeroentropy rerankers.

Also drop the remaining pyright references; the project type-checks with ty.
2026-08-07 14:07:53 +03:00
Yiorgis Gozadinos
96475bba8c
vb 2026-08-06 13:55:57 +03:00
Yiorgis Gozadinos
57531f1653
Merge pull request #528 from ggozad/chore/pin-eval-judge
Pin the eval judge sampling and standardise on Qwen3-Reranker
2026-08-06 12:50:56 +02:00
Yiorgis Gozadinos
ba963864f3
Pin the eval judge sampling and standardise on Qwen3-Reranker 2026-08-06 13:17:58 +03:00
Yiorgis Gozadinos
605367ee5a
Merge pull request #526 from ggozad/fix/light-document-lookups
Load document blobs only when asked
2026-08-06 12:15:50 +02:00
Yiorgis Gozadinos
eb11a165b8
Stop loading page rasters on the title and update paths 2026-08-06 13:04:51 +03:00
Yiorgis Gozadinos
ac9b2cbf81
Load document blobs only when asked 2026-08-06 13:04:51 +03:00
Yiorgis Gozadinos
788a2fe731
Score retrieval evals by document URI 2026-08-06 13:04:25 +03:00
Yiorgis Gozadinos
a4839c0ad2
Score retrieval evals from search results 2026-08-06 13:04:24 +03:00
Yiorgis Gozadinos
a2afdddc63
Merge pull request #527 from ggozad/fix/s3-error-classification
Classify obstore config errors as permanent
2026-08-06 12:03:03 +02:00
Yiorgis Gozadinos
292835190a
Classify obstore config errors as permanent 2026-08-06 12:53:45 +03:00
Yiorgis Gozadinos
5a023a24ad
vb 2026-07-31 15:25:28 +03:00
Yiorgis Gozadinos
c1cb9fe6c6
Merge pull request #525 from ggozad/fix/no-page-loading-list
Load only content when listing documents with include_content
2026-07-31 11:07:32 +03:00
Yiorgis Gozadinos
8b45f81464
Load only content when listing documents with include_content 2026-07-31 10:12:19 +03:00
Yiorgis Gozadinos
adae9eff2e
vb 2026-07-30 19:37:45 +03:00
Yiorgis Gozadinos
a9afb176d6
Merge pull request #524 from ggozad/fix/capability-citations
Fix capability citation handling and add eval diagnostics
2026-07-30 19:36:40 +03:00
Yiorgis Gozadinos
c137305468
Scope the limit notice and spend the cite window on own turns only
The request-limit notice said only the cite tool remained available, but
chat registers rag and analysis in one agent, so exhausting analysis
claimed rag_search was gone too. Scoped to the capability's own tools.

The cite window was counted over every model request once loaded, so
turns spent on another capability expired it before the model was ever
placed where citing was the obvious move. Count only requests whose
preceding response called one of this capability's tools; engagement is
also the only thing that can loop, which is all the bound guards against.

Also: _count_tool_traffic returns a named tuple rather than four bare
ints, and counts failures only for this capability's tools, so host-tool
retries and output-validation retries no longer read as its failures.
2026-07-30 19:14:14 +03:00
Yiorgis Gozadinos
27ed0b3bb3
Send analysis to the sandbox when only its search budget is spent
The notice told the model to answer from what it had the moment
qa.max_searches ran out, while up to 15 code executions remained and
in-code search() does not count against that budget. It now names the
spent tool and points at whichever evidence tool still has budget,
falling back to answer-and-cite only when none do.

Also count RetryPromptPart in n_failed_tools: _cite rejects with
ModelRetry, so a run whose every cite attempt was refused reported zero
failures. And note that n_requests is the run's request count, which
tracks a capability's own budget only while it stays loaded.
2026-07-30 18:17:26 +03:00
Yiorgis Gozadinos
b62d6e920b
Drop budget_spent from eval attributes
It was n_rejected_searches > 0 recorded next to the integer it derived
from, and the name overclaimed: analysis_execute_code also raises
ToolFailed when execute_count exceeds max_executions, which the flag
never saw. Callers can compare the counters directly.
2026-07-30 15:57:48 +03:00
Yiorgis Gozadinos
721acbcf38
Address review on PR #524
- _budget_notice no longer names the cite tool after prepare_tools has
  withdrawn it; the post-grace state gets the plain no-tools text back.
- Split search-budget rejections from any failed tool call: the code tool
  raises ToolFailed for every error in model-written Python, so
  budget_spent was true for a ZeroDivisionError.
- docs/capabilities/rag.md described the old single-turn removal.
- Drop the rationale clause from the CHANGELOG entry.
2026-07-30 15:50:06 +03:00
Yiorgis Gozadinos
68238f0b6a
Name the readlines workaround when sandbox code iterates a file
50 executions across 40 cases in an 822-case run died on
'_io.TextIOWrapper' object is not iterable, and those cases scored 37.5%
judged against 60.1% and cited 12.5% against 55.2%.
2026-07-30 14:56:17 +03:00
Yiorgis Gozadinos
485a8f901e
Document the real max_searches default
Both configuration pages said 3; the default has been 5.
2026-07-30 14:56:17 +03:00
Yiorgis Gozadinos
a528ab912f
Record per-case retrieval diagnostics in evaluations
Count tool traffic from the message history, where refused and repeated
calls stay visible, unlike state.searches which is keyed by query.
2026-07-30 14:56:17 +03:00
Yiorgis Gozadinos
5752f61f2c
Recover chunk ids mistyped from search results
Resolve a cited id that misses exactly to the nearest id the run
retrieved, above a 0.75 similarity cutoff.
2026-07-30 14:55:43 +03:00
Yiorgis Gozadinos
685a7c393d
Keep the cite tool past a capability's request limit 2026-07-30 14:55:08 +03:00
Yiorgis Gozadinos
749752820c
Document vacuum memory requirements 2026-07-30 14:32:40 +03:00
Yiorgis Gozadinos
2ac2594d0d
Merge pull request #523 from ggozad/fix/docling-dotted-names
Strip leading dots from names handed to docling
2026-07-30 14:30:37 +03:00
Yiorgis Gozadinos
e2b930d469
Use a neutral dotfile in the docling name examples
.gitignore reads as this repository's own file in the helper docstring,
the tests and the changelog entry.
2026-07-30 14:13:23 +03:00
Yiorgis Gozadinos
8e7e494fd1
Strip leading dots from names handed to docling 2026-07-30 14:07:00 +03:00
Yiorgis Gozadinos
ebd1fc7f3e
vb 2026-07-29 09:06:55 +03:00
Yiorgis Gozadinos
de102b7e34
Merge pull request #521 from ggozad/feat/monty-0.0.19
Update to monty 0.0.19
2026-07-28 19:25:52 +03:00
Yiorgis Gozadinos
d1ad14f44b
Point the limit comments at the right limits
_session_limits still called the session budget the backstop for code that
computes, which stopped being true when the pool gained a request_timeout in the
same commit. The watchdog kills such a call at code_timeout, so it can never
spend a budget of code_timeout * max_executions. The docstring now names the two
per-call limits and claims neither.

The crash test said the discard prevents a RuntimeError escaping execute(). The
handler catches RuntimeError too, so without the discard every later call
returns a failed result instead.

Name MemoryFile's missing write hook as the reason metadata.json takes a reader
and a deny pair. Drop the clause in the CHANGELOG that narrated how the old
overrun accumulated.
2026-07-28 19:11:37 +03:00
Yiorgis Gozadinos
221c90af72
Bound a compute-only call at code_timeout
The pool watchdog counts only the time a worker spends running code, so a read
that blocks the worker never trips it. The margin above code_timeout therefore
guarded a race that cannot happen, and only bought a runaway call 90s where 60s
was configured. Pass code_timeout straight through. The two limits are disjoint:
the watchdog bounds a call that computes, and the read deadline bounds a call
that reads.

Drop the ordering test, which was true for any positive margin. The containment
test no longer sets code_timeout to zero, because that value now also disables
the watchdog and kills the worker before the read guard can refuse. It patches
the guard instead.

Monty 0.0.19 runs a plain class and a class with __enter__ and __exit__. Only
inheritance and metaclasses raise. Say that in the instructions.

MemoryFile is no longer constructed, so drop the import, the annotation, and the
stale references in the sandbox docstring and CLAUDE.md. That CLAUDE.md line
also still claimed a ThreadPoolExecutor, _run_async, and a fresh interpreter per
call.
2026-07-28 19:11:19 +03:00
Yiorgis Gozadinos
d9e4d1e57c
Bound a call that never reads and restore the iteration test
The read deadline only gets control at a read, so code that computes without
reading escaped it. Give the pool a request_timeout above code_timeout. The
watchdog kills the worker, and execute() already replaces a dead session, so a
runaway call fails and the next call recovers. A read refusal keeps the
variables, so it has to win the race whenever code does read.

Restore test_open_file_objects_are_not_iterable. Replacing the neighbouring
write test by text range deleted it, which left the instructions carrying a
prohibition with nothing to signal when monty lifts it.

Reuse the VFS and the pool when a session is replaced, so recovery skips the
document scan. Mount metadata.json with a lambda rather than a factory. Cover
open() in write mode. Fold the crash entry into the pydantic-monty bullet,
because no release shipped the worker without it.
2026-07-28 19:11:19 +03:00
Yiorgis Gozadinos
94befc0dfd
Recover from worker death and deny metadata writes
A crashed worker used to poison the rest of the run. execute() reported the
crash as a failed result, but kept the dead session, and every later call then
raised RuntimeError out of the tool. Clear the session so the next call checks
out a replacement. Keep the session for a syntax or runtime error, which leaves
the worker healthy, and say in the failure text that a restart loses the
variables.

A MemoryFile accepts writes, so metadata.json took them while the other three
document files refused. Mount it through the same read and deny pair. The
write-denial test now covers all four files.
2026-07-28 19:11:19 +03:00
Yiorgis Gozadinos
522959d9b4
Check the code timeout before each document read
The VFS bridge suspends the Monty worker for the length of a read. Monty checks
its duration budget between interpreter steps, so it cannot check while a read
is in flight. Code that reads in a loop overran a 60s budget by minutes. A read
takes about 20ms on a 2789-document corpus, so a full scan spends about 55s in
reads alone.

Check the deadline before each read. Raising from inside the callback answers the
worker's suspension, which keeps the session usable.

Monty also spends max_duration_secs across the session rather than per call, and
the sandbox reuses the session so that variables persist. Budget it for
code_timeout * max_executions. At the old per-call value the first slow call
starved every later one.

Do not wrap feed_run in asyncio.wait_for. Cancelling during pure compute is
clean, but cancelling while a read waits for an answer wedges the session with a
protocol RuntimeError that escapes execute(). A call that computes without
reading stays bounded by the session budget alone.
2026-07-28 19:11:19 +03:00
Yiorgis Gozadinos
feaca386d3
Document that file objects cannot be iterated
Monty 0.0.19 supports open() and with blocks, but a file object is still not
iterable. See pydantic/monty#490, which is still open. The instructions now
state the limitation in three places and give the alternative next to each
one: readlines() or read().split("\n").

Replace chr(10) with "\n" in the prose and in the example. Both work on
0.0.19, and chr(10) implies that the escape is broken.

Add a test that pins the limitation. The test fails when Monty gains
iteration support, which is the signal to relax the instructions.
2026-07-28 19:10:53 +03:00
Yiorgis Gozadinos
5c8df37af1
Support open()/with in the analysis sandbox
Document files can be read with open() and with-blocks (.read(),
.readline(), .readlines()); writes raise PermissionError. File objects
remain non-iterable and the collections module is still unavailable.
2026-07-28 19:10:53 +03:00
Yiorgis Gozadinos
53084d6fdd
Bump pydantic-monty to 0.0.19
0.0.19 runs sandboxed code in a subprocess worker pool (AsyncMonty /
AsyncMontySession) and drops MontyRepl. The sandbox checks out a session,
drives it with feed_run, and closes it to return the worker; close() is
now async. Worker crashes surface as a failed SandboxResult. The document
VFS (OSAccess/CallbackFile/MemoryFile) is unchanged.
2026-07-28 19:10:53 +03:00
Yiorgis Gozadinos
88651a42d2
Fix packaging and settings facts in the docs
`docs/installation.md` claimed the full package contained every extra. It
pulls `docling`, `voyageai`, `cohere`, `zeroentropy`, `cross-encoder` and
`tui`, so `jina`, `s3` and `ingester` need installing separately. Without
`jina`, `provider: jina-local` falls back to no reranking. Anthropic is not
built in, and only the slim Docker image is published.

`docs/tuning.md` pointed at `claim_timeout_s`, which `WorkerConfig` now
rejects.
2026-07-28 18:22:05 +03:00
Yiorgis Gozadinos
47015758a0
Merge pull request #519 from ggozad/chore/update-pydantic-ai
Update pydantic-ai to 2.18 and adopt its unified thinking setting and ToolFailed
2026-07-28 14:32:50 +03:00
Yiorgis Gozadinos
044da7ae99
Open the eval database read-only outside population
Retrieval and QA only read from the database, but the benchmark opened it
writable, where an embedder identity differing from the stored one aborts
instead of warning. Running a pre-built database against a different
serving stack then needed a `rebuild --set-embedder` first.

Correct the debug-evals skill alongside it: the pydantic-ai span names are
`execute_tool {tool_name}` and `invoke_agent agent`, targets are
`{rag,analysis}-capability`, and no `skill_model` metadata key exists.
2026-07-28 11:36:27 +03:00
Yiorgis Gozadinos
f13a3fb677
Report tool failures with ToolFailed 2026-07-27 18:26:42 +03:00
Yiorgis Gozadinos
ae345cc39f
Map thinking onto Pydantic AI's unified setting 2026-07-27 18:26:42 +03:00
Yiorgis Gozadinos
9168bc15ef
Merge pull request #518 from ggozad/chore/coverage-100-and-test-consolidation
Reach and enforce 100% test coverage
2026-07-27 14:09:21 +03:00
Yiorgis Gozadinos
eb09dc6d2c
Pin setup-uv to v9.0.0
There is no floating v9 tag — setup-uv publishes moving majors only up to
v7, so v9 failed to resolve and the lint job never started.
2026-07-27 13:38:02 +03:00
Yiorgis Gozadinos
04ad3823b9
Cover the lazy config init and restore the uv cache in CI
get_config() builds its instance on first use, so the branch was covered
only when a worker happened to call it before set_config(). Under xdist that
depends on how cases shard across workers, which varies with the core count:
covered locally, uncovered on the two-core runner. Assert it directly.

setup-uv v4 bundles a cache client the GitHub cache service now rejects with
400, so the uv cache never restored and every wheel was redownloaded. Bump
to v9 across all four workflows (build-docs was already drifting at v5).

setup-python read requires-python (">=3.12") and installed 3.14, which uv
then ignored in favour of .python-version (3.13) and downloaded itself.
Point it at .python-version so the interpreter it installs is the one used.
2026-07-27 13:35:50 +03:00
Yiorgis Gozadinos
e186720a5e
Report missing lines in the CI coverage output
A coverage-gate failure currently shows only the percentage, which is not
enough to identify the uncovered line from the log.
2026-07-27 13:26:02 +03:00
Yiorgis Gozadinos
ab88188529
Keep the lazy-read test off the embedder
Rewriting the document through client.update_document re-chunks and
re-embeds, so the test needed an embedding endpoint that CI does not have.
Write the row through the repository instead, matching how the rest of the
file avoids the embedder.
2026-07-27 13:17:05 +03:00
Yiorgis Gozadinos
5200b71e2b
Correct two justifications
Drop the coverage-gate CHANGELOG entry: it is dev infrastructure, invisible
to anyone upgrading. Broaden the check_source_accessible entry to the cases
it now covers.

The nameless-attachment guard reads an empty /F out of untrusted PDF bytes,
so it is boundary validation, not a formality; only the reason it goes
uncovered belongs in the pragma.
2026-07-27 12:18:15 +03:00
Yiorgis Gozadinos
a602e0fbf5
Test vacuum against real table state 2026-07-27 12:11:01 +03:00
Yiorgis Gozadinos
f96a428ef1
Fix defects found reviewing the coverage work
check_source_accessible narrowed its handler to ValueError, but Path.exists
re-raises errno values outside its ignored set (EACCES, ENAMETOOLONG). Those
were swallowed before and now escaped into the rebuild sweep the guard exists
to protect. Catch OSError too.

Restore the arity guard in _common_path_prefix: without it an empty list
raises from min() and a single label yields a prefix covering the whole path.

Two tests would have hung rather than failed on regression (the vacuum skip
and the protected-wait cancellation); both are now bounded. The import
vacuum test raced against the done-callback that discards the task, and now
spies on the call instead, with a negative control.

Replace assertions that could not fail: blank-query search against an empty
corpus, a batch flush counted against an empty table, a picture description
asserting its own input state, and an FS scheme check with nothing on disk to
resolve. The get_model matrix asserted only the returned type across 26
cases and now pins the per-provider settings. The three batching tests now
count flushes, which revealed embed-only writes through chunks_table.add
rather than _flush_rebuild_batch.
2026-07-27 10:44:32 +03:00
Yiorgis Gozadinos
7ba78fdde3
Normalize pragma comments to the codebase's single-line form 2026-07-26 20:18:19 +03:00
Yiorgis Gozadinos
f6acb65e95
Reach and enforce 100% coverage
Cover the remaining paths in the client, context, downloads, title
generation, document tools and store models, and add fail_under=100 so
uncovered lines fail CI.

Six lines that no test can reach get a pragma with its reason: the docling
import guard, the nameless PDF attachment, the FS symlink OSError guard that
resolve(strict=False) absorbs, the docling bbox and LanceDB document-id
shape guards, the tag-retention branch vacuum makes unreachable, and Monty's
Rust-thread print callback.

Fix test_find_config_file_user_config, which wrote its config into the cwd it
had chdir'd to, so the cwd branch answered first and the user-directory
lookup it names was never exercised.
2026-07-26 20:11:02 +03:00
Yiorgis Gozadinos
7c120587f0
Cover sandbox VFS reads, binary part dedup and picture spans 2026-07-26 19:41:09 +03:00
Yiorgis Gozadinos
2afe1bd28c
Cover store engine and repository paths
Add vector-index creation tests including the warned failure, chunk
repository get_by_id, list_all pagination, blank-query and precomputed-vector
search, the unknown-score-column guard, settings row recreation, and
replace_for_document with no items.
2026-07-26 19:36:37 +03:00
Yiorgis Gozadinos
dc5ad8f699
Cover the rebuild paths
Add tests for the vacuum-failure warning, documents deleted mid-rebuild,
chunkless documents, batch flushes in the embed-only, descriptions and full
paths, a missing docling blob under rechunk, missing and recoverable picture
bytes, and the source-missing fallback. Direct-call tests cover the staging
helpers and the idempotent phase-1 marker.
2026-07-26 19:32:09 +03:00
Yiorgis Gozadinos
7c76c3399e
Cover get_model provider branches and visualize_chunk edge paths
Parametrize the get_model tests whose only assertion was the returned model
type, extending the table to the reasoning-off, groq-parsed and Bedrock
o-series/qwen/unmapped branches. Fold format_citations_no_title into the
superset test that already covered its scenario.

Add tests for cosine_similarity on parallel vectors, multi-citation Rich
rendering, picture rendering with missing and undecodable bytes, a missing
docling distribution, and the visualize_chunk short-circuits for absent
documents, absent rasters and refless chunks.
2026-07-26 19:23:54 +03:00
Yiorgis Gozadinos
1e8e5e9f6f
Cover MCP, ingester and converter error paths
Add tests for the MCP tools' degradation contracts, malformed WebDAV
multistatus bodies, dry-run poller sweeps including the circuit-open and
discover-failure paths, FS source scheme and symlink handling, docling-serve
zip parsing, and the remaining embedding and reranker helpers. Parametrize
_strip_etag.

Drop the misplaced pragma on the analyze handler, which sat on the return and
left the except uncovered. Add one on the FS symlink OSError guard, which
resolve(strict=False) absorbs for every real link.
2026-07-26 19:14:26 +03:00
Yiorgis Gozadinos
dddaf0f84c
Remove unreachable code and fix two defects it surfaced
Delete repository methods with no callers (SettingsRepository CRUD,
ChunkRepository.update/delete/get_chunks_in_range), the DataFrame branch of
_process_search_results whose only caller always passes a query, and guards
that cannot be reached from their call sites: the rebuild mode=None default,
the staging drop already performed by _resolve_rebuild_recovery, the empty
batch skip, two context fast paths, the doctor prefix guard, the poller
_task attribute that is never assigned, and a docling caption fallback for
a field name no item class defines.

set_haiku_version built a recreated settings row from the process-global
Config rather than the store's own, so a store opened with a custom config
stamped global settings into the database.

check_source_accessible called urlparse outside its try block, so a stored
URI with a malformed IPv6 host raised ValueError instead of reporting the
source as inaccessible, aborting the whole rebuild sweep.
2026-07-26 18:45:48 +03:00
Yiorgis Gozadinos
e2b273ee2a
Consolidate duplicated client-side tests
Parametrize sibling tests that differed only in a literal value, and fold
two strict-subset tests into the survivors that already covered their
scenario. Every case that ran before still runs; the union of assertions
is applied to each case, strengthening list_all, get_pages_data and
resolve_doc_items.

Replace four hand-rolled log-capture handlers with a shared
capture_logs() contextmanager in conftest.

13 fewer test functions, 348 fewer lines.
2026-07-26 13:30:59 +03:00
Yiorgis Gozadinos
94e03d777a
vb 2026-07-25 15:11:54 +03:00
Yiorgis Gozadinos
1e76e66b28
Merge pull request #517 from ggozad/feat/image-in-ask-analysis
Attach images to ask/analyze and chat
2026-07-25 15:09:24 +03:00
Yiorgis Gozadinos
c716be9640
cover the mcp path with no images 2026-07-25 15:00:36 +03:00
Yiorgis Gozadinos
081856c6b1
Instruct capabilities to judge user-attached images 2026-07-25 14:40:51 +03:00
Yiorgis Gozadinos
cdaeaa94e6
Constrain the chat prompt container height 2026-07-25 14:40:40 +03:00
Yiorgis Gozadinos
93824cdee5
Surface database-open errors in the TUIs
ChatApp and InspectorApp assigned self.client before __aenter__
completed, so a failed open was masked by an AttributeError from
on_unmount tearing down the never-opened client.
2026-07-25 11:27:29 +03:00
Yiorgis Gozadinos
5f4c73f89f
Add image attachment to the chat TUI 2026-07-25 10:58:08 +03:00
Yiorgis Gozadinos
c62fd78c7a
Accept images on CLI ask/analyze and MCP tools 2026-07-25 10:35:25 +03:00
Yiorgis Gozadinos
4c5050d161
Accept images on ask/analyze 2026-07-25 10:23:55 +03:00
Yiorgis Gozadinos
acbd66afbd
vb 2026-07-24 17:32:20 +03:00
Yiorgis Gozadinos
98a9c763ba
Merge pull request #506 from ggozad/feat/remove-haiku-skills
Replace haiku.skills with native Pydantic AI capabilities
2026-07-24 17:12:18 +03:00
Yiorgis Gozadinos
55351a8829
Merge leading system messages for OpenAI-compatible endpoints 2026-07-24 16:57:55 +03:00
Yiorgis Gozadinos
629cf665a6
Document vision flag in create_capability 2026-07-24 16:37:39 +03:00
Yiorgis Gozadinos
c2c38dd08b
Fix app Dockerfile 2026-07-24 16:29:28 +03:00
Yiorgis Gozadinos
f87fabe556
Make the capability image-attachment gate a vision bool 2026-07-24 16:00:01 +03:00
Yiorgis Gozadinos
6c5bc0aae1
Gate combined-chat capability vision on the driving model 2026-07-24 15:27:20 +03:00
Yiorgis Gozadinos
597808c56e
Fix chat analysis-model selection, AG-UI example state, and chat docs
Drive analysis-only chat with analysis.model (falling back to qa.model) so
the running model matches the one the analysis capability configures,
including its vision flag; RAG-bearing chats still run on qa.model.

Give the AG-UI example state-bearing deps and a final STATE_SNAPSHOT so
registered citations reach the client, mirroring the app backend.

Update chat docs: haiku-rag chat uses --capability/-c, and drop the removed
"View state" command-palette entry.
2026-07-24 15:27:20 +03:00
Yiorgis Gozadinos
b8dcb066dc
Port cite partial-success feedback to capabilities 2026-07-24 15:27:19 +03:00
Yiorgis Gozadinos
175929f23a
Improve coverage 2026-07-24 15:26:18 +03:00
Yiorgis Gozadinos
77585ef26a
fix per-question capability limits and tool isolation 2026-07-24 15:26:18 +03:00
Yiorgis Gozadinos
43c17a6777
fix capability execution limits and chat loading 2026-07-24 15:26:17 +03:00
Yiorgis Gozadinos
22ffd5e92e
Add max retries=3 back 2026-07-24 15:26:17 +03:00
Yiorgis Gozadinos
9e7db72997
Load capabilities eagerly for dedicated single-agent consumers 2026-07-24 15:26:17 +03:00
Yiorgis Gozadinos
9deb1f2bd4
replace haiku.skills with native Pydantic AI capabilities 2026-07-24 15:26:17 +03:00
Yiorgis Gozadinos
ec78641cc4
vb 2026-07-24 15:15:20 +03:00
Yiorgis Gozadinos
a72c94aa77
Add ORB multimodal-reranker retrieval result to benchmarks 2026-07-24 13:25:07 +03:00
Yiorgis Gozadinos
71737095ea
Merge pull request #513 from ggozad/feat/multimodal-reranker
Multimodal reranking: send picture chunks to vllm rerankers as images
2026-07-24 13:15:25 +03:00
Yiorgis Gozadinos
35b8b046ce
Cover the detached-chunk guard in the multimodal rerank search test 2026-07-24 13:07:39 +03:00
Yiorgis Gozadinos
f31060e741
Set explicit 120s timeout on the vllm reranker HTTP client 2026-07-24 12:34:31 +03:00
Yiorgis Gozadinos
543aba7547
Multimodal reranking: send picture chunks to vllm rerankers as images
reranking.multimodal (vllm provider only) attaches picture bytes to
synthetic picture chunks before rerank; VLLMReranker sends them as
content-parts documents (base64 data URI + description text) in the
same /v1/rerank request as plain text documents.
2026-07-24 12:29:20 +03:00
Yiorgis Gozadinos
29ccb9a035
Merge pull request #512 from ggozad/fix/read-only-verbs
Read verbs skip the embeddings config-compatibility check
2026-07-24 12:24:50 +03:00
Yiorgis Gozadinos
0308220769
Read verbs skip the embeddings config-compatibility check 2026-07-24 12:14:31 +03:00
Yiorgis Gozadinos
9f49bdde6e
vb 2026-07-23 14:28:23 +03:00
Yiorgis Gozadinos
1afd615a60
Merge pull request #510 from ggozad/chore/security-updates
Security dependency updates
2026-07-23 14:27:17 +03:00
Yiorgis Gozadinos
2c5849b5d2
Invalidate CI HuggingFace model cache for transformers 5 2026-07-23 12:16:15 +03:00
Yiorgis Gozadinos
d9cc9a4eea
Narrow AutoTokenizer type for transformers 5 2026-07-23 12:01:28 +03:00
Yiorgis Gozadinos
c9466af986
Security dependency updates 2026-07-23 11:55:27 +03:00
Yiorgis Gozadinos
0de434ab24
Security dependency updates 2026-07-23 11:34:20 +03:00
Yiorgis Gozadinos
294ced57cd
vb 2026-07-23 10:45:48 +03:00
Yiorgis Gozadinos
3cb591f60b
Merge pull request #509 from ggozad/feat/pass-metadata-citations
Search results and citations carry document metadata
2026-07-23 10:38:16 +03:00
Yiorgis Gozadinos
75a21c82c2
Search results and citations carry document metadata 2026-07-23 10:23:48 +03:00
Yiorgis Gozadinos
4f0fb7322e
vb 2026-07-22 13:27:35 +03:00
Yiorgis Gozadinos
82693f7974
Merge pull request #508 from ggozad/fix/cite-partial-feedback
cite reports unresolvable chunk ids on partial success
2026-07-22 13:12:01 +03:00
Yiorgis Gozadinos
8bf569ef89
cite reports unresolvable chunk ids on partial success 2026-07-22 11:56:56 +03:00
Yiorgis Gozadinos
7e4fecc13e
Merge pull request #507 from ggozad/fix/read-only
Skip embedding validation on document deletion
2026-07-22 11:56:03 +03:00
Yiorgis Gozadinos
3034280c78
Skip embedding validation on document deletion 2026-07-22 10:48:00 +03:00
Yiorgis Gozadinos
cb3e30b66a
Merge pull request #500 from ggozad/feat/hotpotqa
Restore hotpotqa evaluation dataset
2026-07-19 12:14:52 +03:00
Yiorgis Gozadinos
3dd234ecfc
Add reranked hotpotqa benchmark results. 2026-07-19 12:08:16 +03:00
Yiorgis Gozadinos
11303aa714
Document hotpotqa benchmark results and finalize the reference config 2026-07-17 16:28:16 +03:00
Yiorgis Gozadinos
0e8fa551f3
Restore hotpotqa evaluation dataset 2026-07-17 16:25:27 +03:00
Yiorgis Gozadinos
b5c0ea630c
vb 2026-07-16 13:50:40 +03:00
Yiorgis Gozadinos
aa7a4ff91a
Merge pull request #505 from ggozad/feat/version-tags-branches
Database tags with restore; remove --before time travel
2026-07-16 13:42:14 +03:00
Yiorgis Gozadinos
1ed424b50d
Serialize metadata-only document updates with the write lock 2026-07-16 13:11:35 +03:00
Yiorgis Gozadinos
f02cde5ddf
Fix same-tick cancellation losing recovery results in _wait_protected 2026-07-16 13:11:35 +03:00
Yiorgis Gozadinos
dd2817ff6d
Protect tag-creation cleanup from cancellation 2026-07-16 13:11:35 +03:00
Yiorgis Gozadinos
1f88944ada
Protect restore rollback from cancellation; report delete_tag listing failures 2026-07-16 13:11:34 +03:00
Yiorgis Gozadinos
ce1b9ac88e
Harden history against tag annotation failures; document tag restore 2026-07-16 13:11:34 +03:00
Yiorgis Gozadinos
5453c00c95
Test restore and migration separation 2026-07-16 13:11:34 +03:00
Yiorgis Gozadinos
4522fbdf1b
Add tag restore CLI 2026-07-16 13:11:34 +03:00
Yiorgis Gozadinos
f41ec379df
Add Store.restore_tag 2026-07-16 13:11:34 +03:00
Yiorgis Gozadinos
a1f3435df3
Vacuum suppresses OSError only 2026-07-16 13:11:34 +03:00
Yiorgis Gozadinos
f997d7adc9
Consolidate tag primitives 2026-07-16 13:11:34 +03:00
Yiorgis Gozadinos
0e271eaf4b
Remove --before/--at time travel 2026-07-16 13:11:33 +03:00
Yiorgis Gozadinos
0813a1c980
Add tag CLI commands and history tag annotations 2026-07-16 13:11:27 +03:00
Yiorgis Gozadinos
0cbde6b7a2
Serialize tag operations, vacuum, and metadata refresh with writes 2026-07-16 12:32:57 +03:00
Yiorgis Gozadinos
69319c9390
Grow vacuum retention to protect tagged versions 2026-07-16 12:32:57 +03:00
Yiorgis Gozadinos
ae603d9b4d
Add database-level tag primitives to Store 2026-07-16 12:32:56 +03:00
Yiorgis Gozadinos
aa620aeb60
Bump lancedb to 0.34.0 2026-07-16 12:32:56 +03:00
Yiorgis Gozadinos
472eb089fe
Merge pull request #502 from ggozad/chore/remote-logfire-mcp-skills
Update debug skills for the remote Logfire MCP tool names
2026-07-15 19:41:46 +03:00
Yiorgis Gozadinos
ee38b3db42
Merge pull request #501 from ggozad/fix/docling-text-format-sniffing
Prevent magic-byte sniffing from misrouting text conversion in docling-local
2026-07-15 19:41:21 +03:00
Yiorgis Gozadinos
5d5eee54bf
Update debug skills for the remote Logfire MCP tool names 2026-07-15 19:24:56 +03:00
Yiorgis Gozadinos
d2f5ecfc58
Prevent magic-byte sniffing from misrouting text conversion in docling-local
Docling guesses a DocumentStream's format from its first bytes before
considering the extension, so markdown/HTML content starting with a binary
magic signature ("BM" = BMP, "ID3" = MP3) was routed to an image or audio
backend and fell back to plain-text conversion. Prefix the encoded text
with a newline so the sniff finds nothing and the extension decides.
2026-07-15 19:20:54 +03:00
Yiorgis Gozadinos
5dd310a9b7
vb 2026-07-14 16:06:50 +03:00
Yiorgis Gozadinos
c1ec13f081
Lower search.max_context_chars default to 5000 2026-07-14 11:58:42 +03:00
Yiorgis Gozadinos
b9434045b9
Merge pull request #498 from ggozad/fix/remove-obsolete-mxbai
Remove the mxbai reranking provider
2026-07-14 11:37:26 +03:00
Yiorgis Gozadinos
144900d385
Remove the mxbai reranking provider 2026-07-14 11:09:55 +03:00
Yiorgis Gozadinos
5e2a928013
Allow transformers 5.x in the mxbai extra 2026-07-14 11:09:55 +03:00
Yiorgis Gozadinos
8f085101fc
Merge pull request #497 from ggozad/fix/context-expansion
Context expansion: never drop a retrieved result when merging and clipping
2026-07-14 10:58:07 +03:00
Yiorgis Gozadinos
82db15cf3c
Fill missing item-table pages from fully-visible inputs in context expansion 2026-07-14 10:32:45 +03:00
Yiorgis Gozadinos
ee5b7b7313
Split merged context-expansion groups that cannot afford every constituent 2026-07-14 10:15:28 +03:00
Yiorgis Gozadinos
4863abc513
Update cl 2026-07-10 15:25:33 +03:00
Yiorgis Gozadinos
5c41f8a0ed
vb 2026-07-10 13:38:24 +03:00
Yiorgis Gozadinos
1287059e76
Merge pull request #493 from ggozad/logfire-debug-skills
Add Logfire debugging skills
2026-07-10 13:36:50 +03:00
Yiorgis Gozadinos
271fdf9b5a
Add Logfire debugging skills and worker-breaker event 2026-07-10 13:23:17 +03:00
Yiorgis Gozadinos
3396394468
Merge pull request #492 from ggozad/feat/telemetry-improvements
Improve logfire telemetry
2026-07-10 11:50:40 +03:00
Yiorgis Gozadinos
a82673a900
Route eval scripts through the shared telemetry configure 2026-07-10 11:38:33 +03:00
Yiorgis Gozadinos
9b3b21b1a4
Document ingestion observability; drop stale logfire install extra 2026-07-10 11:22:23 +03:00
Yiorgis Gozadinos
73ba764922
Emit a docling_serve.request span per instance attempt 2026-07-10 11:16:27 +03:00
Yiorgis Gozadinos
da79f92af1
Honor OTEL_SERVICE_NAME and set service name/version for all processes 2026-07-10 11:11:46 +03:00
Yiorgis Gozadinos
da0748916b
vb 2026-07-09 15:58:58 +03:00
Yiorgis Gozadinos
c8497d7cd1
Merge pull request #489 from ggozad/fix/excessive-citations
Scope citations to the matched content and make visual grounding faithful
2026-07-09 15:58:15 +03:00
Yiorgis Gozadinos
e7d5a0440d
Tune two-tone visualization colors 2026-07-09 15:37:25 +03:00
Yiorgis Gozadinos
18c0f6c5e8
Add --no-expand to the visualize command for chunk-only grounding 2026-07-09 12:49:42 +03:00
Yiorgis Gozadinos
995d081aa1
Visualize the exact context the model saw via Citation.doc_item_refs
visualize_chunk re-expanded chunks from scratch to recover their refs,
which could not faithfully reproduce the original merge, scores, and
clip — so a visualization could highlight different pages than the
citation covered. Carry the cited items on Citation.doc_item_refs and
resolve bounding boxes from them directly; re-expansion remains only as
the fallback for callers with no stored context (CLI, inspector). Chat,
inspector, the app endpoint, and the frontend pass the refs through.
2026-07-09 11:51:37 +03:00
Yiorgis Gozadinos
c424f51056
Anchor and clip merged citations on the highest-scoring chunk
A merged search result took its chunk_id from whichever constituent
sorted earliest in the document, while its score was the max across the
group — so the citation's identity could point at a different, less
relevant chunk. Anchor chunk_id and the content/refs fallbacks on the
max-score constituent, clip the budget window around that same chunk so
its evidence is never trimmed away, and narrow page_numbers, doc_item_refs,
and attached image bytes to the items that survive the clip.
2026-07-09 11:05:41 +03:00
Yiorgis Gozadinos
6d86237dd6
Draw matched content stronger than expanded context in visualizations 2026-07-09 11:05:41 +03:00
Yiorgis Gozadinos
494f774473
Visualize all constituent chunks of a merged citation 2026-07-09 11:05:41 +03:00
Yiorgis Gozadinos
0bcf34363a
Carry merged chunk ids on SearchResult and Citation 2026-07-09 11:05:41 +03:00
Yiorgis Gozadinos
b14152a45b
Keep picture and table hits section-bounded during context expansion 2026-07-09 11:05:40 +03:00
Yiorgis Gozadinos
777ce193f8
Merge pull request #491 from ggozad/fix/image-embeddings-limit
Filter picture chunks at ingest and pool embedding/reranking HTTP clients
2026-07-09 10:55:01 +03:00
Yiorgis Gozadinos
c02bcd5dc9
Pool HTTP clients for vLLM embedding and vLLM/Jina reranking 2026-07-09 10:32:44 +03:00
Yiorgis Gozadinos
43a580afb3
Dedupe picture chunks and skip small pictures at chunking 2026-07-09 10:24:32 +03:00
Yiorgis Gozadinos
0c8ff570d6
vb 2026-07-08 17:04:18 +03:00
Yiorgis Gozadinos
234ece9639
Merge pull request #488 from ggozad/fix/analyst-id-in-filter
Rename document_meta identity column document_id to id
2026-07-08 16:50:47 +03:00
Yiorgis Gozadinos
a1cf405cae
Rename document_meta identity column document_id to id 2026-07-08 16:32:55 +03:00
Yiorgis Gozadinos
d1d06729dc
Merge pull request #487 from ggozad/fix/duplicate-ingestion
Prevent duplicate documents from concurrent same-URI ingestion
2026-07-08 11:50:27 +03:00
Yiorgis Gozadinos
ec787435c8
Bind app example backend to loopback and document its lack of auth 2026-07-08 11:37:03 +03:00
Yiorgis Gozadinos
23bbca5998
Re-check URI under the write lock to prevent duplicate ingestion 2026-07-08 11:26:45 +03:00
Yiorgis Gozadinos
87548770a3
Merge pull request #486 from ggozad/feat/breakers-per-instance
docling-serve client: fail over to another instance with per-instance circuit breaking
2026-07-08 11:24:49 +03:00
Yiorgis Gozadinos
3c7699f156
Fail docling-serve requests over to another instance with per-instance breaking
When a docling-serve instance crashes or returns 5xx, DoclingServeClient now
retries the request on a different instance (up to max_attempts) and trips a
per-instance circuit breaker so subsequent jobs skip a dead instance until its
cooldown elapses. Reuses the shared CircuitBreaker; adds max_attempts and a
nested circuit_breaker to providers.docling_serve.

Co-Authored-By: bryan davis <bryan@monkeytronics.org>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:03:44 +03:00
Yiorgis Gozadinos
d781335868
Move CircuitBreaker to a shared module
Relocate CircuitBreaker from ingester/pollers to haiku/rag/circuit_breaker
so non-ingester callers (docling-serve provider) can reuse it without
depending on the ingester package.

Co-Authored-By: bryan davis <bryan@monkeytronics.org>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:03:30 +03:00
Yiorgis Gozadinos
d71695833d
Merge pull request #485 from ggozad/fix/update-uri
Allow updating a document's uri via update_document
2026-07-08 10:41:24 +03:00
Yiorgis Gozadinos
c2c8c24951
Allow updating a document's uri via update_document 2026-07-08 10:32:14 +03:00
Yiorgis Gozadinos
d84147511a
Drop broken type=gha cache from Docker slim publish 2026-07-03 20:32:51 +03:00
Yiorgis Gozadinos
9a9bea86d3
vb 2026-07-03 13:35:10 +03:00
Yiorgis Gozadinos
8f3cf70eb2
Merge pull request #482 from ggozad/fix/attach-picture-from-caption
Attach figure bytes when a result matches its caption
2026-07-03 13:32:17 +03:00
Yiorgis Gozadinos
d2593304c5
Attach figure bytes when a result matches its caption 2026-07-03 13:22:02 +03:00
Yiorgis Gozadinos
07098d55e4
Merge pull request #481 from ggozad/chore/clean-up
Clean up reranker/converter/embedder layers and cut test runtime
2026-06-29 15:26:07 +03:00
Yiorgis Gozadinos
7f50d3698f
Validate reranker config before importing the provider package 2026-06-29 15:15:26 +03:00
Yiorgis Gozadinos
ac64aa9c17
Build the skills rag_db fixture once per session 2026-06-29 15:15:26 +03:00
Yiorgis Gozadinos
e4b1f16f97
Shrink the docling-serve context-expansion cassette 2026-06-29 15:15:26 +03:00
Yiorgis Gozadinos
afd6e3ae00
Share the VLM URL builder and centralize the embedder empty guard 2026-06-29 15:15:26 +03:00
Yiorgis Gozadinos
654cb2b94c
Collapse get_reranker into a single guard and import-guarded dispatch
Replace the seven repeated `config.reranking.model and ... == provider`
checks and six per-branch ImportError handlers with one None guard and one
try/except around the provider dispatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 15:15:26 +03:00
Yiorgis Gozadinos
38079ff89a
Hoist reranker empty-input guard into base; stop factory tests loading models 2026-06-29 14:20:23 +03:00
Yiorgis Gozadinos
9a85632cf3
vb 2026-06-29 12:36:36 +03:00
Yiorgis Gozadinos
325f4517ba
Update t2_finqa config 2026-06-29 12:22:58 +03:00
Yiorgis Gozadinos
5fc4056dcd
Require haiku.skills>=0.18.0 2026-06-29 11:44:51 +03:00
Yiorgis Gozadinos
3cb229d2e0
Add t2_finqa pre-built evaluation database reference config and docs 2026-06-29 10:52:23 +03:00
Yiorgis Gozadinos
38c62521d8
Merge pull request #480 from ggozad/chore/orb-nemotron-upload
Add nemotron-vl multimodal eval database and pre-built DB reference configs
2026-06-29 10:26:00 +03:00
Yiorgis Gozadinos
4d03b1e669
Add nemotron-vl multimodal eval database and reference configs 2026-06-29 10:14:53 +03:00
Yiorgis Gozadinos
b2abdfaffb
vb 2026-06-28 10:57:26 +03:00
Yiorgis Gozadinos
b707dfe848
Merge pull request #479 from ggozad/feat/speed-up-duplicates
Speed up doctor's duplicate-document detection
2026-06-28 10:51:32 +03:00
Yiorgis Gozadinos
4f8cc4eb30
Show a progress spinner while doctor runs 2026-06-28 10:44:30 +03:00
Yiorgis Gozadinos
b707a70d19
Update docs 2026-06-28 10:24:12 +03:00
Yiorgis Gozadinos
e7b4988d82
Compute document centroids during the vector scan 2026-06-28 10:23:16 +03:00
Yiorgis Gozadinos
15aae0f242
Simplify duplicate detection to whole-document centroid similarity 2026-06-28 10:23:16 +03:00
Yiorgis Gozadinos
c7c3f75508
vb 2026-06-27 09:59:21 +03:00
Yiorgis Gozadinos
bd520cb995
Merge pull request #477 from ggozad/fix/limit-context-blowing-expansion
Hard-cap context expansion at max_context_chars
2026-06-27 09:58:25 +03:00
Yiorgis Gozadinos
b94e13083f
Hard-cap context expansion at max_context_chars
A single oversized document_items row (e.g. a spreadsheet converted to one
table) expanded far past search.max_context_chars and could overflow the
model context window. _expand_outward only used the budget as a soft
accumulation threshold and expand_with_items never capped the joined result.

Add _clip_to_budget to clip each expanded result to max_context_chars,
returning a window centered on the matched chunk (via _evidence_anchors) so
the retrieved evidence survives the cut.
2026-06-27 09:46:36 +03:00
Yiorgis Gozadinos
cd1a73b389
vb 2026-06-26 17:13:03 +03:00
Yiorgis Gozadinos
7cdf93e23a
Merge pull request #475 from ggozad/feat/document-similarity
Detect near-duplicate documents in `doctor`
2026-06-26 17:05:33 +03:00
Yiorgis Gozadinos
73f62ab290
Enforce the duplicate candidate cap during row collection 2026-06-26 16:57:51 +03:00
Yiorgis Gozadinos
f1e1a16f9b
Add doctor --duplicates-out YAML export; summarize terminal report 2026-06-26 16:52:22 +03:00
Yiorgis Gozadinos
044ac62e49
Drop boilerplate handling; make duplicate report readable 2026-06-26 16:52:22 +03:00
Yiorgis Gozadinos
961913dde4
Ignore boilerplate chunks in duplicate-document detection 2026-06-26 16:52:21 +03:00
Yiorgis Gozadinos
43f2130b66
Add near-duplicate document detection to doctor 2026-06-26 16:52:21 +03:00
Yiorgis Gozadinos
ce27a7aae5
Clean up S3 test data per-test to keep SeaweedFS writable 2026-06-26 16:41:20 +03:00
Yiorgis Gozadinos
98f2a088d8
Merge pull request #474 from ggozad/chore/dependabot-alerts
Security dependency updates
2026-06-26 15:28:49 +03:00
Yiorgis Gozadinos
1969c4528e
Fix example app 2026-06-26 13:12:53 +03:00
Yiorgis Gozadinos
5463a07d32
chore(deps): patch Dependabot security alerts 2026-06-26 10:55:26 +03:00
Yiorgis Gozadinos
9f45addbe9
Merge pull request #470 from ggozad/fix/dangling-workers
Lease-renewal reaping for ingester workers
2026-06-26 09:19:43 +03:00
Yiorgis Gozadinos
87f0233bcd
Cover heartbeat health, lease-renewal failure, and breaker recovery 2026-06-25 16:32:56 +03:00
Yiorgis Gozadinos
4333ab4fe2
Document lease-based ingester reaping 2026-06-25 13:28:25 +03:00
Yiorgis Gozadinos
4707db780f
Renew job leases from the worker pool; lease-based reaping 2026-06-25 13:26:09 +03:00
Yiorgis Gozadinos
fd73f57649
Add last_heartbeat_at lease column to the ingester queue 2026-06-25 13:25:37 +03:00
Yiorgis Gozadinos
ba6b318ece
Make ingester worker ids globally unique 2026-06-25 13:25:37 +03:00
Yiorgis Gozadinos
6bbf12e794
Merge pull request #469 from ggozad/fix/minor-issues
Minor config and FTS-index fixes
2026-06-25 13:21:00 +03:00
Yiorgis Gozadinos
b61134b747
Log FTS index build failures at WARNING 2026-06-25 13:00:29 +03:00
Yiorgis Gozadinos
2fd025951c
Treat empty env vars as unset in config expansion 2026-06-25 12:41:04 +03:00
Yiorgis Gozadinos
73beae5a4c
Harden MCP publisher install and bump action versions 2026-06-24 19:38:50 +03:00
Yiorgis Gozadinos
197d58a602
vb 2026-06-24 18:41:05 +03:00
Yiorgis Gozadinos
d66ecb4da2
Merge pull request #468 from ggozad/fix/fastmcp-pydantic-ai-2.0
Depend on fastmcp directly for the MCP server
2026-06-24 18:40:21 +03:00
Yiorgis Gozadinos
b343f3687d
Depend on fastmcp directly for the MCP server 2026-06-24 18:35:00 +03:00
Yiorgis Gozadinos
ab1775dbc6
vb 2026-06-24 16:45:19 +03:00
Yiorgis Gozadinos
af66af45a0
Merge pull request #467 from ggozad/perf/item-text-serializer-reuse
Reuse one MarkdownDocSerializer per document in item extraction
2026-06-24 15:13:06 +03:00
Yiorgis Gozadinos
9f4dda9254
Reuse one MarkdownDocSerializer per document in item extraction 2026-06-24 12:02:13 +03:00
Yiorgis Gozadinos
54ac3450b5
Merge pull request #465 from bd-mkt/perf_redundant_serialization
perf: avoid redundant serialization in docling ingestion path
2026-06-24 10:51:08 +03:00
Yiorgis Gozadinos
2b2b475279
Collapse docling compression to a single function 2026-06-24 10:43:27 +03:00
bryan davis
e95ac2e25d
remove redundant serialization 2026-06-24 10:42:58 +03:00
Yiorgis Gozadinos
e73a4272f8
Merge pull request #464 from bd-mkt/zstd_concurrency
adjust zstd handling to avoid possible core dumps with concurrency
2026-06-24 10:17:34 +03:00
Yiorgis Gozadinos
62d2326933
Use single-threaded zstd compressor 2026-06-24 10:08:25 +03:00
bryan davis
2ca12c0096
adjust zstd handling to avoid possible core dumps with concurrency 2026-06-23 15:26:33 -05:00
Yiorgis Gozadinos
65f1339aa0
vb 2026-06-23 16:59:00 +03:00
Yiorgis Gozadinos
0b822ee402
Merge pull request #463 from ggozad/chore/docling-update
Adapt docling-serve integration to docling-serve 1.25.0
2026-06-23 16:55:10 +03:00
Yiorgis Gozadinos
e1fbaf8aad
Re-record remaining docling-serve cassettes against 1.25.0 2026-06-23 16:44:58 +03:00
Yiorgis Gozadinos
3cc3c98986
Store docling-serve chunk bodies via raw_text 2026-06-23 16:35:21 +03:00
Yiorgis Gozadinos
68715c9657
Request page images explicitly from docling-serve 2026-06-23 16:15:02 +03:00
Yiorgis Gozadinos
f22341f003
Fix doctor's vector-index check to match documented guidance 2026-06-23 15:39:44 +03:00
Yiorgis Gozadinos
195dce7511
Merge pull request #462 from ggozad/fix/multi-modal-embedder-config
Decouple multimodal embedding from the provider name; add VoyageAI & Cohere multimodal embedders
2026-06-23 15:33:57 +03:00
Yiorgis Gozadinos
b098be790b
Document multimodal embedder flag and Voyage/Cohere providers 2026-06-23 15:23:10 +03:00
Yiorgis Gozadinos
3586ac30a7
Add Cohere multimodal embedder 2026-06-23 15:23:10 +03:00
Yiorgis Gozadinos
a1ec310bf4
Add VoyageAI multimodal embedder 2026-06-23 15:23:10 +03:00
Yiorgis Gozadinos
0ea251219c
Add explicit multimodal embedding flag, decoupled from provider name 2026-06-23 15:23:10 +03:00
Yiorgis Gozadinos
2c906b16cd
Merge pull request #461 from ggozad/feat/doctor
Add `haiku-rag doctor` health check
2026-06-23 14:43:22 +03:00
Yiorgis Gozadinos
4fae733a50
Fix doctor provider checks: custom endpoints and processing models 2026-06-23 11:48:21 +03:00
Yiorgis Gozadinos
eb855f827a
Refine doctor's content and coverage checks 2026-06-23 11:31:20 +03:00
Yiorgis Gozadinos
cc1d8d1e4c
Add provider connectivity probes to haiku-rag doctor 2026-06-23 10:34:27 +03:00
Yiorgis Gozadinos
c2cb3cedf3
Add haiku-rag doctor database health check 2026-06-23 10:03:27 +03:00
Yiorgis Gozadinos
8cb52ca1ed
Merge pull request #459 from mcdonc/thread-rebuild-compress
Thread compress_docling_split in rebuild.py off the asyncio event loop
2026-06-23 09:23:53 +03:00
Yiorgis Gozadinos
775dc58b53
Drop redundant _apply_descriptions_sync unit test, annotate the helper 2026-06-23 09:14:36 +03:00
Yiorgis Gozadinos
0ee36a269d
Merge pull request #460 from mcdonc/thread-processing-iterate
Thread picture chunk merging off the asyncio event loop
2026-06-23 09:05:45 +03:00
Yiorgis Gozadinos
141e288b83
Tighten _merge_picture_chunks type annotations
Restore the typing the merge logic carried inline before extraction:
text_chunks: list[Chunk], existing_picture_data: dict[str, bytes] | None,
-> list[Chunk], and first_pos(c: Chunk) -> int.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 08:56:51 +03:00
Yiorgis Gozadinos
fbb63f7e99
Merge pull request #458 from mcdonc/thread-chunker-chunk
Thread chunker.chunk() off the asyncio event loop
2026-06-23 08:50:13 +03:00
Yiorgis Gozadinos
4436673f10
Tighten extract_items hoist annotation in _update_document_with_chunks 2026-06-23 08:47:55 +03:00
Yiorgis Gozadinos
4cb85c5eaf
Merge pull request #454 from mcdonc/thread-extract-items
Thread extract_items off the asyncio event loop
2026-06-23 08:37:08 +03:00
Chris McDonough
633517bf04 Widen chunk() type to accept None, add test for None guard 2026-06-22 14:24:12 -04:00
Chris McDonough
c59f88bcd6 Add test for _merge_picture_chunks no-pictures branch 2026-06-22 14:13:02 -04:00
Chris McDonough
21a8f52893 Use patch.object to satisfy type checker 2026-06-22 14:09:01 -04:00
Chris McDonough
a137b91654 Add test for _apply_descriptions_sync to cover skip branch 2026-06-22 14:08:23 -04:00
Chris McDonough
99a200c9d2 Restore None guard, add thread-safety test for _chunk_sync 2026-06-22 14:05:56 -04:00
Chris McDonough
25b0a155b0 Remove None guard and test — type system guarantees document is not None 2026-06-22 14:04:58 -04:00
Chris McDonough
ccab012747 Thread picture chunk merging off the asyncio event loop
Extracts build_picture_chunks + iterate_items position mapping into a
sync helper and wraps it with asyncio.to_thread so image-heavy
documents don't block the event loop during chunking.

Fixes #456.
2026-06-22 14:01:35 -04:00
Chris McDonough
256fd66b46 Add test for chunker.chunk(None) to cover guard clause 2026-06-22 13:58:37 -04:00
Chris McDonough
cee9484f35 Fix import sort order 2026-06-22 13:49:59 -04:00
Chris McDonough
d160080ff8 Move lazy imports to module scope 2026-06-22 13:48:09 -04:00
Chris McDonough
97748c2fee Fix import sorting lint error 2026-06-22 13:46:21 -04:00
Chris McDonough
22b4397148 Thread compress_docling_split in rebuild.py off the asyncio event loop
Extracts pydantic serialization and zstd compression into a sync
helper and wraps it with asyncio.to_thread so picture description
rebuilds don't block the event loop.

Fixes #457.
2026-06-22 13:42:40 -04:00
Chris McDonough
8fe8b6d291 Thread chunker.chunk() off the asyncio event loop
Extracts the CPU-bound HybridChunker/HierarchicalChunker work into
a sync helper and wraps it with asyncio.to_thread so large documents
don't block the event loop during chunking.

Fixes #455.
2026-06-22 13:37:22 -04:00
Chris McDonough
09e78ef46a Thread extract_items off the asyncio event loop
Moves CPU-bound docling document item extraction into worker threads
via asyncio.to_thread so the event loop stays responsive during
ingestion. The extract_items calls are hoisted out of the write lock
(they are pure computation with no DB I/O) and run with placeholder
document IDs that are patched after the DB create returns.

Fixes #453.
2026-06-22 13:19:38 -04:00
Yiorgis Gozadinos
0fcf91fcf7
vb 2026-06-22 15:18:35 +03:00
Yiorgis Gozadinos
0be4f7e1bd
Merge pull request #452 from ggozad/feat/run-batch-dry-run
Add dry-run manifests for ingestor's run-batch
2026-06-22 15:07:13 +03:00
Yiorgis Gozadinos
463c55673e
improve coverage 2026-06-22 12:59:44 +03:00
Yiorgis Gozadinos
baf8decb27
Show progress for run-batch drains 2026-06-22 12:51:38 +03:00
Yiorgis Gozadinos
6e85bbbafe
Make run-batch CLI validation output stable 2026-06-22 12:32:26 +03:00
Yiorgis Gozadinos
b0706204bd
Allow resuming run-batch manifest replay 2026-06-22 12:22:40 +03:00
Yiorgis Gozadinos
68bbf94577
tighten run-batch manifest replay validation 2026-06-22 11:58:32 +03:00
Yiorgis Gozadinos
f0826abb52
Document run-batch dry-run manifests 2026-06-22 11:43:57 +03:00
Yiorgis Gozadinos
7251d104c4
Add run-batch manifest replay 2026-06-22 11:39:12 +03:00
Yiorgis Gozadinos
a3b542dba8
Add run-batch dry-run manifest output 2026-06-22 11:33:26 +03:00
Yiorgis Gozadinos
4b573bfebd
Add side-effect-free batch dry-run discovery 2026-06-22 11:33:26 +03:00
Yiorgis Gozadinos
6f2ab0963c
cl for #449 2026-06-22 11:33:11 +03:00
Yiorgis Gozadinos
23f5b3c47a
Merge pull request #449 from bd-mkt/bd_concurrency2
perf: move CPU-bound ingest work off the event loop
2026-06-22 11:05:14 +03:00
Yiorgis Gozadinos
ad3b111cf1
test: clarify off-loop thread assertions 2026-06-22 10:51:35 +03:00
Yiorgis Gozadinos
d3a1011baf
write fetched bodies off the event loop 2026-06-22 10:37:48 +03:00
Yiorgis Gozadinos
183d595494
prepare stored Docling blobs off the event loop 2026-06-22 10:30:02 +03:00
Yiorgis Gozadinos
c6514c9df4
compare off-loop work against actual event-loop thread, fix ty 2026-06-22 10:17:02 +03:00
Yiorgis Gozadinos
5c4b37df89
vb 2026-06-19 11:12:45 +03:00
Yiorgis Gozadinos
9e08ec40d5
Merge pull request #450 from ggozad/fix/textual-image-base-deps
Move textual-image to base dependencies
2026-06-19 10:47:38 +03:00
Yiorgis Gozadinos
b67f330031
Move textual-image to base dependencies 2026-06-19 10:37:10 +03:00
Yiorgis Gozadinos
2c55bca8bc
cl for #448 2026-06-19 10:17:18 +03:00
Yiorgis Gozadinos
d0e40d9025
Merge pull request #448 from bd-mkt/pdf_att_lock
improve concurrency management for pdf attachments
2026-06-19 10:15:58 +03:00
bryan davis
fe954a090e
move cpu bound actions off of main loop 2026-06-18 16:35:48 -05:00
bryan davis
faa97f8bc6
improve concurrency management for pdf attachments 2026-06-18 14:30:02 -05:00
Yiorgis Gozadinos
0c33a00b4e
claude code personal notes 2026-06-16 17:06:32 +03:00
Yiorgis Gozadinos
99011755fb
vb 2026-06-16 16:48:36 +03:00
Yiorgis Gozadinos
78f86c81a7
Merge pull request #444 from ggozad/feat/ingester-metadata-fetchresult
Pass fetched FetchResult to ingester metadata providers
2026-06-16 16:46:49 +03:00
Yiorgis Gozadinos
1e65d36766
Pass fetched FetchResult to ingester metadata providers 2026-06-16 16:37:26 +03:00
Yiorgis Gozadinos
3f5141abd0
Merge pull request #446 from ggozad/chore/docling-update
Bump docling deps and relax opencv floor
2026-06-16 16:32:31 +03:00
Yiorgis Gozadinos
89151db52a
Merge pull request #445 from ggozad/fix/queue-retry-idempotent
Make ingester job retry idempotent against a live sibling
2026-06-16 16:23:42 +03:00
Yiorgis Gozadinos
d38f4f9702
Merge pull request #443 from ggozad/fix/queue-reenqueue
Stop re-enqueuing permanently-failed ingester documents
2026-06-16 16:23:21 +03:00
Yiorgis Gozadinos
06a3f365c4
Bump docling deps and relax opencv floor 2026-06-16 16:16:32 +03:00
Yiorgis Gozadinos
6add2780e8
Make ingester job retry idempotent against a live sibling 2026-06-16 16:05:05 +03:00
Yiorgis Gozadinos
79a4f49387
Stop re-enqueuing permanently-failed ingester documents 2026-06-16 16:03:51 +03:00
Yiorgis Gozadinos
276f277232
Merge pull request #442 from ggozad/feat/custom-source
Custom ingester sources via entry points
2026-06-16 12:11:10 +03:00
Yiorgis Gozadinos
515f42dd5a
Add custom ingester sources via entry points 2026-06-16 10:54:24 +03:00
Yiorgis Gozadinos
2ce7d10c51
Merge pull request #441 from ggozad/fix/sqlite-pool
Widen SQLite ingester queue pool to serve concurrent connections
2026-06-16 09:41:11 +03:00
Yiorgis Gozadinos
7e20b47e98
Widen SQLite ingester queue pool to serve concurrent connections 2026-06-16 09:25:01 +03:00
Yiorgis Gozadinos
f94c419c31
cl 2026-06-15 10:21:09 +03:00
Yiorgis Gozadinos
6440698b80
Merge pull request #440 from ggozad/fix/webdav-follow-redirects
Follow HTTP redirects in the WebDAV source
2026-06-15 10:10:44 +03:00
Yiorgis Gozadinos
fac62cb347
Follow HTTP redirects in the WebDAV source 2026-06-15 09:59:13 +03:00
Yiorgis Gozadinos
84b778b4ea
Merge pull request #439 from ggozad/feat/ingestor-meta
Ingester metadata providers
2026-06-15 08:51:52 +03:00
Yiorgis Gozadinos
cc73a8629a
Wire metadata providers into ingester ingestion 2026-06-15 08:41:26 +03:00
Yiorgis Gozadinos
5722260857
Add metadata-provider discovery for the ingester 2026-06-15 08:05:45 +03:00
Yiorgis Gozadinos
61f27085f9
Merge pull request #438 from ggozad/fix/pdfium-concurrency
Serialize all in-process pdfium access under a shared lock
2026-06-14 11:08:26 +03:00
Yiorgis Gozadinos
70c9c2778f
Serialize all in-process pdfium access under a shared lock 2026-06-14 10:50:42 +03:00
Yiorgis Gozadinos
8d9dbd0abc
Merge pull request #435 from ggozad/feat/ingestor-vacuuming
Fix ingestion disk bloat: split mutable document  attributes into a document_meta table
2026-06-12 10:29:38 +03:00
Yiorgis Gozadinos
885e7b7ce7
coverage 2026-06-12 10:17:19 +03:00
Yiorgis Gozadinos
6c04dd5881
docs 2026-06-12 10:17:18 +03:00
Yiorgis Gozadinos
bfc51b8b28
urface document_meta in info, history, and the inspector 2026-06-12 10:17:18 +03:00
Yiorgis Gozadinos
2b8b9477b7
Serialize and roll back the multi-table document delete 2026-06-12 10:17:18 +03:00
Yiorgis Gozadinos
f2a5ac4246
Throttle background auto-vacuum to at most once per 5 minutes 2026-06-12 10:17:18 +03:00
Yiorgis Gozadinos
5d5d87d44c
Throttle background auto-vacuum to at most once per 5 minutes 2026-06-12 10:17:18 +03:00
Yiorgis Gozadinos
df8af54298
rebase from main 2026-06-12 10:17:18 +03:00
Yiorgis Gozadinos
3366a6d383
Split meta document attributes into a document_meta table 2026-06-12 10:17:17 +03:00
Yiorgis Gozadinos
4bc52f0710
Serialize multi-table document writes and bound update version churn 2026-06-12 10:16:39 +03:00
Yiorgis Gozadinos
ab9fea4833
Merge pull request #437 from bd-mkt/invalid-attachments
fix for issue with parsing pdf attachment such as *.joboptions files
2026-06-12 09:53:23 +03:00
Yiorgis Gozadinos
8ac670714d
Tidy PDF attachment extension fix 2026-06-12 09:44:11 +03:00
bryan davis
bce24e84a0
fix for issue with parsing pdf attachment such as *.joboptions files 2026-06-11 16:52:07 -05:00
Yiorgis Gozadinos
88696cc6f1
vb 2026-06-11 09:50:33 +03:00
Yiorgis Gozadinos
77eb2fc2f9
Merge pull request #434 from ggozad/feat/secrets-from-env
Expand ${VAR} environment references in YAML config
2026-06-11 09:48:49 +03:00
Yiorgis Gozadinos
ceae56562f
Expand ${VAR} environment references in YAML config 2026-06-11 09:39:54 +03:00
Yiorgis Gozadinos
a64524d5ba
Merge pull request #432 from tseaver/feat-431-control-plane-behind-proxy
feat(ingester): serve control plane under a configurable base path
2026-06-11 09:38:43 +03:00
Yiorgis Gozadinos
90e9a6bc17
changelog entry + bare-prefix redirect for ingester root_path 2026-06-11 09:30:22 +03:00
Tres Seaver
b78f0ae9ed
feat(ingester): serve control plane under a configurable base path
Add `ingester.api.root_path` so the HTTP control plane (dashboard + API)
can be reverse-proxied behind a sub-path (e.g. /ingester/) on a shared
origin, instead of needing nginx sub_filter URL-rewriting.

- APIConfig.root_path: normalized ('', or single leading slash, no trailing
  slash) via a field_validator; validate_assignment so CLI overrides
  normalize the same way as config-file values.
- Forwarded to FastAPI(root_path=) and uvicorn.Config(root_path=) so
  OpenAPI/docs links are prefix-aware.
- Dashboard route injects a <base href> matching root_path; all dashboard
  fetches are now base-relative, so they resolve under the prefix while
  staying identical at the root.
- `serve --root-path` CLI flag.
- Docs: "Behind a reverse proxy" section with an nginx example.

Closes #431

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:02:43 -04:00
Yiorgis Gozadinos
bdad908a60
vb 2026-06-09 15:39:14 +03:00
Yiorgis Gozadinos
2aa3528cd0
Merge pull request #430 from ggozad/feat/ingestor-ui-info
Ingester dashboard: Database info and full-config panels
2026-06-09 12:58:08 +03:00
Yiorgis Gozadinos
54524a4ea3
Gate ingester control-plane access logs to DEBUG 2026-06-09 12:46:17 +03:00
Yiorgis Gozadinos
4c186b01c7
Add Database and Configuration panels to the ingester dashboard 2026-06-09 12:28:13 +03:00
Yiorgis Gozadinos
c62166b26e
Add /database and /config endpoints to the ingester API 2026-06-09 12:11:33 +03:00
Yiorgis Gozadinos
0be2e5b24f
Extract gather_database_info shared by the info command 2026-06-09 11:09:28 +03:00
Yiorgis Gozadinos
d45f8f5de8
Merge pull request #429 from ggozad/feat/batch-document-creation
Batch document import (import_documents)
2026-06-09 10:44:01 +03:00
Yiorgis Gozadinos
a7ed405d75
Add HaikuRAG.import_documents for batch document import 2026-06-09 10:23:37 +03:00
Yiorgis Gozadinos
b43843f862
Batch-capable document and document-item repositories 2026-06-09 10:01:20 +03:00
Yiorgis Gozadinos
f83aad3cc4
Merge pull request #428 from ggozad/fix/per-source-circuit-breaker
Make the ingester worker circuit breaker per-source
2026-06-09 10:00:35 +03:00
Yiorgis Gozadinos
bd548837e5
Make the ingester worker circuit breaker per-source 2026-06-09 09:41:54 +03:00
Yiorgis Gozadinos
46b8fa3fef
vb 2026-06-08 12:24:04 +03:00
Yiorgis Gozadinos
7941c6738c
Merge pull request #427 from ggozad/feat/t2-ragbench
Add T²-RAGBench evaluation
2026-06-08 12:15:06 +03:00
Yiorgis Gozadinos
9ef25e2e53
Order benchmarks docs ORB, T², Wix 2026-06-08 11:51:21 +03:00
Yiorgis Gozadinos
295570797c
Disable Logfire scrubbing in evaluations 2026-06-08 11:51:13 +03:00
Yiorgis Gozadinos
e9bd56467c
Add T²-RAGBench leaderboard submission exporter 2026-06-08 11:51:02 +03:00
Yiorgis Gozadinos
a3a73f1331
Add --filter-ids to run QA on a case-id subset 2026-06-08 09:36:01 +03:00
Yiorgis Gozadinos
d386d7f900
Bump haiku.skills to 0.17.2 2026-06-06 14:52:05 +03:00
Yiorgis Gozadinos
cd77bd9889
Bound analysis execute_code calls to avoid request-limit nulls 2026-06-06 14:52:05 +03:00
Yiorgis Gozadinos
1d7d540270
Match the numeric scale convention in Number-Match 2026-06-06 14:52:05 +03:00
Yiorgis Gozadinos
d5b0aafa2b
Add T²-RAGBench TAT-DQA subset; generalize subset layout 2026-06-06 14:52:05 +03:00
Yiorgis Gozadinos
de9731d5f3
Score Number-Match on the declared ANSWER line, magnitude and ×100 scale 2026-06-06 14:52:05 +03:00
Yiorgis Gozadinos
390deb4203
Normalize unicode signs 2026-06-06 14:52:04 +03:00
Yiorgis Gozadinos
80594cd38c
Handle percent/decimal convention in Number-Match 2026-06-06 14:52:04 +03:00
Yiorgis Gozadinos
c2a48b55c0
Add deterministic Number-Match QA scoring for T²-RAGBench 2026-06-06 14:52:04 +03:00
Yiorgis Gozadinos
9f1bd9940a
Add T²-RAGBench FinQA evaluation dataset 2026-06-06 14:52:04 +03:00
Yiorgis Gozadinos
3807c48a60
Merge pull request #425 from ggozad/fix/sandbox-borrowed-bug
Fix analysis-sandbox "Already borrowed" crash under concurrent tool calls
2026-06-06 14:16:29 +03:00
Yiorgis Gozadinos
52738cbc0b
Always create the shared-connection lock so serialization can't be skipped 2026-06-06 10:51:07 +03:00
Yiorgis Gozadinos
4a65a69a27
Make the shared-connection lock optional for direct tool calls 2026-06-05 18:04:56 +03:00
Yiorgis Gozadinos
e59ee56002
Serialize shared-connection access across skill tools and the sandbox 2026-06-05 17:22:11 +03:00
Yiorgis Gozadinos
b47583258e
Run analysis sandbox VFS reads on the calling loop via the skill connection 2026-06-05 16:20:11 +03:00
Yiorgis Gozadinos
e0a892ec97
Update embedding-drift docs for read-only opens 2026-06-05 12:14:14 +03:00
Yiorgis Gozadinos
916b84bff4
vb 2026-06-05 12:09:57 +03:00
Yiorgis Gozadinos
f3cc1e8c7c
Merge pull request #422 from ggozad/fix/sandbox-lancedb-event-loop
Fix analysis sandbox "Already borrowed" crash on document VFS reads
2026-06-05 12:08:01 +03:00
Yiorgis Gozadinos
7906688bf1
Fix analysis sandbox "Already borrowed" crash on VFS reads 2026-06-05 11:50:19 +03:00
Yiorgis Gozadinos
a2def043cc
Merge pull request #421 from ggozad/fix/read-only-update
Read operations no longer modify the database
2026-06-05 11:46:46 +03:00
Yiorgis Gozadinos
abefdcdd41
Test vector_dim mismatch raises in read-only mode 2026-06-05 10:55:38 +03:00
Yiorgis Gozadinos
414be551fb
Open read CLI verbs read-only 2026-06-05 10:48:49 +03:00
Yiorgis Gozadinos
0e3d53791f
Add rebuild --set-embedder to reconcile embedder identity 2026-06-05 10:42:15 +03:00
Yiorgis Gozadinos
213569601b
Stop rewriting stored embedding settings on database open 2026-06-05 10:35:50 +03:00
Yiorgis Gozadinos
b6bbea3d64
Stop writing the schema version on database open 2026-06-05 10:32:30 +03:00
Yiorgis Gozadinos
e70f271ac7
vb 2026-06-04 14:13:55 +03:00
Yiorgis Gozadinos
c688871b61
Merge pull request #418 from ggozad/feat/ingester-postgres-queue
Support a database server for the ingester queue (SQLite + Postgres)
2026-06-04 14:07:14 +03:00
Yiorgis Gozadinos
e2e0a8dc1b
Cover the Postgres queue construction paths in CI 2026-06-04 12:21:47 +03:00
Yiorgis Gozadinos
73e8ac2dd6
Build the SQLite queue URL without reparsing the path 2026-06-04 12:14:27 +03:00
Yiorgis Gozadinos
a04dc9445b
Pre-ping the Postgres queue engine 2026-06-04 11:29:31 +03:00
Yiorgis Gozadinos
e37d764ab2
Add a docker-compose for integration test services 2026-06-04 11:13:16 +03:00
Yiorgis Gozadinos
1717bd4996
Make the SQLite job claim atomic across processes 2026-06-03 16:57:57 +03:00
Yiorgis Gozadinos
5cc32f111e
Mask the dburi password in queue init/migrate output 2026-06-03 14:42:32 +03:00
Yiorgis Gozadinos
a3cc13230f
test the Postgres queue and document dburi 2026-06-03 14:34:09 +03:00
Yiorgis Gozadinos
44089e5b1f
Support a dburi for the ingester queue (SQLite + Postgres)
Migrate the ingester queue storage from raw aiosqlite to SQLAlchemy Core
async. The backend is chosen by ingester.queue.dburi: a SQLAlchemy async
URL points the queue at a database server, and SQLite remains the default
when unset. The Postgres path claims jobs with FOR UPDATE SKIP LOCKED so
multiple ingester processes can share one queue; SQLite caps the pool to a
single connection to keep the select-then-update claim atomic.
2026-06-03 14:34:09 +03:00
Yiorgis Gozadinos
4eae86225e
vb 2026-06-03 14:26:58 +03:00
Yiorgis Gozadinos
0b58e6dc14
Merge pull request #417 from ggozad/fix/atomic-rename-race
skip spurious ingester DELETE when the resource is back on its source
2026-06-03 14:17:51 +03:00
Yiorgis Gozadinos
8afdd46176
skip spurious ingester DELETE when the resource is back on its source 2026-06-03 14:05:53 +03:00
Yiorgis Gozadinos
5b879aa6ed
Merge pull request #416 from ggozad/fix/rebuild-embed-images
Fix rebuild --embed-only corrupting picture embeddings
2026-06-03 13:49:04 +03:00
Yiorgis Gozadinos
a65757807e
Use cached HuggingFace models offline in test job
The Qwen tokenizer and cross-encoder pre-downloads call the HF metadata
API to revalidate even on a cache hit; a 429 there propagates instead of
falling back to the cached files, failing CI on HF throttling.

Skip the pre-download steps when the cache is restored and run pytest
with HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE on a hit, so cached models are
used without any network revalidation; allow online on a miss so a fresh
cache key still populates. Rename the cache key so the snapshot
re-populates with every test model (the old key predated the
cross-encoder step and never cached it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:11:33 +03:00
Yiorgis Gozadinos
651b22ddcf
Fix rebuild --embed-only corrupting picture embeddings 2026-06-03 11:46:43 +03:00
Yiorgis Gozadinos
6d95fbe74a
Merge pull request #414 from ggozad/feat/prune-ingestor-pool
Add retention window to ingester queue
2026-06-03 11:06:44 +03:00
Yiorgis Gozadinos
a423a6dd9c
authenticate HuggingFace downloads in test job 2026-06-03 10:57:45 +03:00
Yiorgis Gozadinos
46747d369a
Add retention window to ingester queue, prune terminal job rows past retention window 2026-06-03 10:38:58 +03:00
Yiorgis Gozadinos
83b7e37855
Merge pull request #413 from ggozad/feat/analysis-citation-improvement
Improve citation rate for analysis skill in lesser models (gemma4)
2026-06-03 10:03:54 +03:00
Yiorgis Gozadinos
4ff17a84bb
Update benchmarks 2026-06-02 15:20:17 +03:00
Yiorgis Gozadinos
eb74525084
Lift analysis-skill citation rate via SKILL.md tightening 2026-06-01 18:58:52 +03:00
Yiorgis Gozadinos
8dbcd4bd7a
vb, changelog 2026-06-01 18:54:22 +03:00
Yiorgis Gozadinos
0550e363d4
Merge pull request #394 from mcdonc/perf/max-file-size-validation
perf: add configurable max_file_size to prevent OOM on large files
2026-06-01 18:49:18 +03:00
Chris McDonough
7e4aa8c71f
Add configurable max_file_size to reject oversized files before ingestion
Large files buffered entirely in RAM can OOM workers. Add
max_file_size to source config (default None = no limit).

FS checks stat().st_size before read_bytes(). HTTP and WebDAV issue
a HEAD request before GET when a limit is configured. S3 checks the
size from the existing head_async() call before get_async().

FileTooLargeError is classified as PermanentError so oversized files
go straight to the DLQ instead of retrying.
2026-06-01 18:40:46 +03:00
Yiorgis Gozadinos
774ac7c350
Merge pull request #393 from mcdonc/perf/batch-sync-state-writes
perf: batch sync_state writes during poller sweeps
2026-06-01 18:33:40 +03:00
Yiorgis Gozadinos
249d6c85c5
Merge pull request #401 from mcdonc/fix/revisionless-server-re-ingestion
fix: stop constant re-ingestion when server provides no revision header
2026-06-01 18:25:51 +03:00
Yiorgis Gozadinos
2cd97880fd
Replace sync_state batch 5-tuple with a SyncRow NamedTuple 2026-06-01 18:25:15 +03:00
Chris McDonough
a0a247d18a
Batch sync_state writes during poller sweeps
Each discovered file previously triggered a separate sync.upsert()
call with its own lock acquire + SQLite commit (fsync). On a sweep
finding 1,000 files this meant 1,000 individual commits.

Collect sync_state rows into a list during the sweep and flush them
in a single SyncStateRepo.batch_upsert() call at the end — one lock
acquisition, one commit, one fsync.
2026-06-01 18:21:09 +03:00
Chris McDonough
8deac2fee8
Fix constant re-ingestion when server provides no revision header
HTTP, S3, and WebDAV sources all check `revision is not None and
snapshot.get(uri) == revision` to decide UPSERT vs UNCHANGED.  When
a server returns no ETag or Last-Modified, revision is None and the
condition always fails — every sweep emits UPSERT even though the
content hasn't changed.

Now emit UNCHANGED when revision is None and the URI is already
known (has been ingested before).  A first-time discovery with no
revision still correctly emits UPSERT.
2026-06-01 18:14:27 +03:00
Yiorgis Gozadinos
20634376a3
Merge pull request #411 from mcdonc/chore/coverage-gaps
chore: close coverage gaps in cli, filter, registry, and migrations
2026-06-01 18:12:22 +03:00
Yiorgis Gozadinos
c0faf5ecf3
Merge pull request #408 from mcdonc/fix/directory-errors-permanent
fix: classify IsADirectoryError and NotADirectoryError as PermanentError
2026-06-01 18:07:24 +03:00
Yiorgis Gozadinos
734a1d8f0e
Merge pull request #407 from mcdonc/fix/http-discover-silent-exception
fix: narrow HTTP discover() exception catch to TransportError
2026-06-01 18:06:46 +03:00
Yiorgis Gozadinos
c1932e22e5
Strengthen config-load assertions 2026-06-01 18:03:09 +03:00
Chris McDonough
1bf2505093
Improve test coverage for cli, filter, registry, and migrations
These files were not touched by the recent performance and
correctness PRs but had coverage gaps. Adds tests for:

- CLI: serve, queue init/migrate, config loading, cli() entry point
  including MigrationRequiredError exit path
- filter: _default_supported_extensions, __call__ watchfiles callback,
  FileFilter with supported_extensions=None
- registry: resolve_adhoc_fetcher with bucket-less S3 URI
- migrations: pragma no-cover on unreachable schema upgrade path
  (no diff migrations exist until SCHEMA_VERSION > 1)
2026-06-01 18:00:47 +03:00
Chris McDonough
adf03284ff
Classify IsADirectoryError and NotADirectoryError as PermanentError
Both are OSError subclasses caught by the broad timeout/io handler
and classified as transient. Pointing at a directory instead of a
file or a broken path component will never succeed on retry.
2026-06-01 17:59:23 +03:00
Chris McDonough
61ea22527a
Narrow HTTP discover() exception catch to TransportError only
The bare `except Exception` in HTTPSource.discover() silently
swallowed all errors from HEAD requests — including configuration
errors (bad auth, invalid headers) and programming errors (TypeError,
AttributeError) — treating them identically to network failures by
emitting UPSERT with no revision.

Narrow the catch to httpx.TransportError (the umbrella for
ConnectError, TimeoutException, etc.) and add a debug log. Other
exceptions now propagate to the poller's circuit breaker where they
surface as failures instead of being silently retried forever.
2026-06-01 17:57:51 +03:00
Yiorgis Gozadinos
64f2b7b7d2
Merge pull request #405 from mcdonc/fix/run-batch-hang-on-dead-workers
fix: run_batch hangs forever when all workers die
2026-06-01 17:51:49 +03:00
Yiorgis Gozadinos
49cb6e7591
Merge pull request #409 from mcdonc/fix/sync-state-write-crash
fix: sync_state write failure after mark_succeeded should not crash worker
2026-06-01 17:46:46 +03:00
Yiorgis Gozadinos
e9875fc842
Merge pull request #395 from mcdonc/perf/reuse-httpx-clients
perf: reuse httpx.AsyncClient in HTTP and WebDAV sources
2026-06-01 17:38:05 +03:00
Yiorgis Gozadinos
c5814b31bf
Test source clients close after the worker pool stops
Workers share the pollers' Source instances for fetch(), so the httpx
clients must be closed only after the pool has stopped. Guards both
run_batch() and serve() against reintroducing the shutdown-order bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:29:53 +03:00
Yiorgis Gozadinos
e3ea207358
Merge pull request #396 from mcdonc/perf/stagger-periodic-polls
perf: stagger periodic poll sweeps to avoid thundering herd
2026-06-01 17:23:22 +03:00
Yiorgis Gozadinos
b79bbcaed4
Merge pull request #399 from mcdonc/fix/discover-stat-race
fix: handle file deleted during discover() stat() call
2026-06-01 17:18:47 +03:00
Yiorgis Gozadinos
fda798a746
Merge pull request #391 from mcdonc/perf/event-driven-worker-wakeup
perf: event-driven worker wakeup via asyncio.Condition
2026-06-01 17:06:40 +03:00
Yiorgis Gozadinos
6544501123
Merge pull request #392 from mcdonc/perf/add-queue-indexes
perf: add partial indexes for has_pending() and dashboard queries
2026-06-01 17:03:57 +03:00
Chris McDonough
22ab79c492 Fix shutdown-order bug: close source clients after workers stop
Workers share the same Source instances as pollers and use them for
fetch(). PollerManager.stop() was closing httpx clients before the
worker pool drained, so in-flight fetches during the shutdown grace
hit a closed client.

- Move source closing out of stop() into a separate close_sources()
- Call close_sources() after _stop_pool() in both serve() and
  run_batch()
- Promote aclose() to the Source protocol with no-op defaults for
  FS and S3, removing the hasattr duck-typing
2026-06-01 09:55:39 -04:00
Chris McDonough
b144e620de Fix dead-worker condition and test for run_batch abort
The condition only checked claimed jobs, but queued jobs with no
live workers also hang forever. Check live_workers == 0 regardless
of whether outstanding work is queued or claimed.

Rewrite the test to actually crash workers: patch _process to raise
a bare Exception (which _worker_loop doesn't catch), use
worker_count=1 so the single crash leaves live_workers == 0, and
assert the abort log message fires.
2026-06-01 09:47:10 -04:00
Chris McDonough
004d59563c Extract _stagger_start helper into BasePoller, add tests
The jitter-before-first-sleep block was duplicated verbatim in
PeriodicPoller.run() and FSPoller._sweep_loop(). Move it to
BasePoller._stagger_start() with a named _STAGGER_FRACTION constant.

This also gives a testable seam outside the pragma-no-cover
event-loop glue methods.
2026-06-01 09:32:18 -04:00
Chris McDonough
2a06421e7a Improve discover stat race test to actually exercise the try/except
The previous test deleted a file mid-iteration, but is_file()
caught it before stat() ran — so the new try/except never executed.
Monkeypatch Path.stat to raise FileNotFoundError on the third call
for the victim path (after is_symlink and is_file pass), simulating
the exact TOCTOU window between is_file() and stat().
2026-06-01 09:28:26 -04:00
Chris McDonough
5010069474 Drop redundant idx_jobs_pending_by_source index
has_pending() already uses the leading column of uq_jobs_live
(source_id) with the same WHERE clause. The extra index just adds
write amplification on every insert/claim/complete without improving
reads.
2026-06-01 09:24:11 -04:00
Chris McDonough
720ff23357 Fix shutdown regression: notify idle workers on stop()
Workers parked on job_available.wait() were not woken by stop(),
causing them to sleep out the full poll_idle_interval_s before
noticing _stop. With the default 1.0s interval, stop() took ~0.8s
instead of ~0.007s.

Notify all waiters on the condition in stop() so idle workers exit
immediately. Add tests for fast job pickup via notification and
fast shutdown with idle workers.
2026-06-01 09:21:24 -04:00
Yiorgis Gozadinos
8bf1b6be1a
Merge pull request #403 from mcdonc/fix/watch-change-stat-race
fix: watch loop crash when file deleted before stat() in _handle_watch_change
2026-06-01 15:59:50 +03:00
Yiorgis Gozadinos
bf45c4efa8
Merge pull request #406 from mcdonc/fix/permissionerror-permanent
fix: classify PermissionError as PermanentError
2026-06-01 15:49:47 +03:00
Chris McDonough
3c7dbf0966
Classify PermissionError as PermanentError instead of TransientError
PermissionError is a subclass of OSError, so it was caught by the
broad timeout/io handler and classified as transient. An unreadable
file would retry 5 times then DLQ — permissions don't fix themselves
without operator intervention.

Add an explicit PermissionError check before the OSError catch so
unreadable files go straight to the DLQ.
2026-06-01 15:36:37 +03:00
Yiorgis Gozadinos
4372b0546d
Merge pull request #400 from mcdonc/fix/filenotfound-permanent-error
fix: classify FileNotFoundError as PermanentError
2026-06-01 15:35:03 +03:00
Chris McDonough
f33a4629e1 Handle sync_state write failure after mark_succeeded without crashing
If sync.upsert() or sync.delete() raises after a job is already
marked succeeded (e.g. disk full, DB locked), the unhandled
exception crashes the worker. The job stays succeeded but sync_state
is stale, and the crashed worker stops processing other jobs.

Wrap the post-success sync_state writes in a try/except. On failure,
log the error and continue. The worst case is a redundant re-ingest
on the next sweep — better than killing the worker.
2026-06-01 08:11:02 -04:00
Chris McDonough
da8e7dc568 Fix run_batch hanging forever when all workers die
The drain loop in run_batch() polls counts_by_status() waiting for
queued and claimed counts to reach zero. If all worker tasks crash
(unhandled exception, OOM), claimed jobs stay claimed forever and
the loop never exits — the CLI command hangs.

Check live_workers during the drain loop. If claimed jobs exist but
no workers are alive to process them, log an error and break out.
The stranded jobs will be reaped on the next start.
2026-06-01 07:52:17 -04:00
Chris McDonough
42922cd2bd Fix watch loop crash when file is deleted before stat() in _handle_watch_change
The expression `str(path.stat().st_mtime_ns) if path.exists() else None`
has a TOCTOU race: the file can be deleted between exists() and stat().
The resulting FileNotFoundError propagates up to _watch_loop's except
handler, which records a breaker failure and terminates the loop — no
more push events are processed until restart.

Replace with a try/except around stat() and return early on
FileNotFoundError. The deletion event from watchfiles will handle
cleanup.
2026-06-01 07:46:35 -04:00
Chris McDonough
c9c48bc814 Classify FileNotFoundError as PermanentError instead of TransientError
FileNotFoundError is a subclass of OSError, so it was caught by the
broad timeout/io handler and classified as transient. A file deleted
between discovery and fetch would retry 5 times on a file that's
permanently gone, then DLQ with a confusing error message.

Add an explicit FileNotFoundError check before the OSError catch so
deleted files go straight to the DLQ.
2026-06-01 07:32:09 -04:00
Chris McDonough
7d80eb4f83 Fix discover() crash when file is deleted during stat()
A file deleted between os.walk() and path.stat() raises
FileNotFoundError, which propagated uncaught and failed the entire
discover() sweep. With enough failures this trips the circuit
breaker, silencing the poller.

Catch FileNotFoundError around the stat() call and skip the file.
The next sweep (or watchfiles) will emit the DELETE event.
2026-06-01 07:29:51 -04:00
Chris McDonough
a6b3e7f1f6 Stagger periodic poll sweeps to avoid thundering herd
All pollers sharing the same poll_interval_s previously woke up and
swept at exactly the same moment after startup. With 10+ sources
this causes a coordinated spike in listing traffic (S3 LIST, WebDAV
PROPFIND, HTTP HEAD) every interval.

Add a random initial delay of 0-25% of the poll interval after the
first sweep, applied to both PeriodicPoller and FSPoller's sweep
loop. Subsequent sweeps run on the normal fixed interval, now
staggered across sources.
2026-06-01 07:02:42 -04:00
Chris McDonough
07c5a97929 Reuse httpx.AsyncClient across requests in HTTP and WebDAV sources
HTTPSource and WebDAVSource previously created a new AsyncClient for
every head(), fetch(), and discover() call — no connection reuse, TLS
renegotiation on every request, and connection pool churn at scale.

Create the client once in __init__ and reuse it for the lifetime of
the source. Add aclose() to both sources, called by PollerManager on
shutdown to cleanly close the connection pool.
2026-06-01 07:00:15 -04:00
Chris McDonough
48826031ac Add partial indexes for has_pending() and dashboard throughput queries
has_pending() scans jobs WHERE source_id=? AND status IN
('queued','claimed') on every poller sweep — without an index this
is a full table scan as the jobs table grows. Similarly,
count_succeeded_since() scans by completed_at for the dashboard's
rolling-throughput display.

Add two partial indexes:
- idx_jobs_pending_by_source: covers has_pending() lookups
- idx_jobs_succeeded_completed: covers count_succeeded_since()

Both use CREATE INDEX IF NOT EXISTS so they're idempotent on
existing databases.
2026-06-01 06:28:55 -04:00
Chris McDonough
4fa8a012b4 Use asyncio.Condition for event-driven worker wakeup
Workers previously polled the queue with a fixed 1s sleep between
claim attempts, adding ~500ms average latency to job pickup. Now
JobRepo.job_available (an asyncio.Condition) is notified on every
successful enqueue, waking idle workers immediately. The poll
interval remains as a timeout fallback for stop signals and breaker
state changes.
2026-06-01 06:28:16 -04:00
Yiorgis Gozadinos
d5e5733f67
Merge pull request #389 from ggozad/chore/clean-up-evaluations
Clean up and update evaluations.
2026-06-01 11:07:32 +03:00
Yiorgis Gozadinos
cbd9b46da6
Results for analysis skill 2026-06-01 10:40:52 +03:00
Yiorgis Gozadinos
339d231a4e
Remove repliqa from test setup 2026-06-01 10:40:52 +03:00
Yiorgis Gozadinos
e653c49fce
Remove unecessary Mean Reciprocal Rank metric 2026-06-01 10:40:52 +03:00
Yiorgis Gozadinos
00f2a40a60
Refresh benchmarks doc and remove unused eval datasets 2026-06-01 10:40:51 +03:00
Yiorgis Gozadinos
6ac7d5a4be
Add nemotron embedder to ORB benchmarks and consolidate source buckets 2026-06-01 10:40:51 +03:00
Yiorgis Gozadinos
a0029e9ab9
Merge pull request #390 from ggozad/feat/ingester-run-batch
Add haiku-ingester run-batch, remove run-once
2026-06-01 10:34:57 +03:00
Yiorgis Gozadinos
5a23e4eda6
Surface failed discovery sweeps in run-batch 2026-06-01 10:27:31 +03:00
Yiorgis Gozadinos
6f2a40c676
cover run-batch, serve, and _stop_pool with tests 2026-05-29 17:43:58 +03:00
Yiorgis Gozadinos
5ba7838b71
Add haiku-ingester run-batch, remove run-once 2026-05-29 17:00:59 +03:00
Yiorgis Gozadinos
62be23d130
vb 2026-05-29 11:47:12 +03:00
Yiorgis Gozadinos
33cfa11484
Merge pull request #388 from ggozad/chore/consolidate-embedder-cache
Always re-use the Store-owned embedder
2026-05-29 11:45:09 +03:00
Yiorgis Gozadinos
e7c7df2915
Always use the Store-owned embedder 2026-05-29 11:36:53 +03:00
Yiorgis Gozadinos
0a9a444996
Merge pull request #387 from ggozad/feat/cache_reranker
Cache reranker on the client instead of rebuilding per search
2026-05-29 10:50:45 +03:00
Yiorgis Gozadinos
37a78a4e9b
Cache reranker on the client instead of rebuilding per search 2026-05-29 10:33:50 +03:00
Yiorgis Gozadinos
c0c83d8037
Merge pull request #386 from ggozad/feat/pdf-containers
Support for embedded documents inside pdfs.
2026-05-29 10:25:05 +03:00
Yiorgis Gozadinos
d8d6727227
improve coverage 2026-05-28 18:06:47 +03:00
Yiorgis Gozadinos
402957d3e5
Fix CI flakes for PDF attachment extraction 2026-05-28 17:51:02 +03:00
Yiorgis Gozadinos
6eee09743b
Cover PDF attachment ingest through the full create_document_from_source path 2026-05-28 16:07:12 +03:00
Yiorgis Gozadinos
8e8c4433bd
Extract PDF /EmbeddedFiles attachments as child documents 2026-05-28 15:51:32 +03:00
Yiorgis Gozadinos
26bc71d6d8
Cascade delete_document to children via metadata.parent_uri 2026-05-28 15:36:00 +03:00
Yiorgis Gozadinos
e04a425d78
Merge pull request #385 from ggozad/feat/auto-prune
Auto-prune dead jobs when a sibling DELETE succeeds
2026-05-27 17:31:37 +03:00
Yiorgis Gozadinos
5400085147
Auto-prune dead jobs when a sibling DELETE succeeds 2026-05-27 17:17:01 +03:00
Yiorgis Gozadinos
3828a91c37
vb 2026-05-27 16:23:43 +03:00
Yiorgis Gozadinos
f6364126ec
Merge pull request #383 from ggozad/feat/ingester
Ingestion service for production, replaces monitor
2026-05-27 16:13:49 +03:00
Yiorgis Gozadinos
561010d1ec
Add ingester screenshot 2026-05-27 15:53:01 +03:00
Yiorgis Gozadinos
4d1b89de8f
Custom hover tooltips for truncated dashboard cells 2026-05-27 15:42:17 +03:00
Yiorgis Gozadinos
1e6dc34871
Skip FS DELETE enqueue when the file is already back 2026-05-27 15:18:46 +03:00
Yiorgis Gozadinos
15d13cab04
Probe docling-serve only when converter or chunker uses it 2026-05-27 15:18:25 +03:00
Yiorgis Gozadinos
b0d0ac588d
Split snapshot APIs and resolve_fetcher by intent 2026-05-27 14:39:54 +03:00
Yiorgis Gozadinos
2c63ceda0c
Backfill ingester test gaps and drop unneeded retry clamp 2026-05-27 14:24:22 +03:00
Yiorgis Gozadinos
cfbd0b09d6
Tighten HTTP config-removal handling 2026-05-27 14:07:45 +03:00
Yiorgis Gozadinos
7466539b4f
Emit DELETE for HTTP URLs removed from config 2026-05-27 13:39:18 +03:00
Yiorgis Gozadinos
d7fdd61fed
Resolve worker source by source_id, not just supports(uri) 2026-05-27 13:36:03 +03:00
Yiorgis Gozadinos
922d1d567d
Prevent DELETE/UPSERT race for the same URI 2026-05-27 13:30:46 +03:00
Yiorgis Gozadinos
0114653522
Docs update 2026-05-27 13:22:35 +03:00
Yiorgis Gozadinos
d059b59c1e
Adapt docker compose example to use the published image 2026-05-27 12:59:32 +03:00
Yiorgis Gozadinos
1c710433c7
Collapse worker_count and max_concurrent into worker_count 2026-05-27 12:48:14 +03:00
Yiorgis Gozadinos
6f74fe67b3
Cap claimed-row count at max_concurren 2026-05-27 12:10:42 +03:00
Yiorgis Gozadinos
87af5e5139
Boot-reap stale claims at WorkerPool start 2026-05-27 11:46:37 +03:00
Yiorgis Gozadinos
1b36452629
Surface provider reachability and per-job failure context on dashboard 2026-05-27 11:38:48 +03:00
Yiorgis Gozadinos
439307d5af
Add pool-wide circuit breaker to the worker pool 2026-05-27 10:52:48 +03:00
Yiorgis Gozadinos
b491f767f3
Let httpx errors flow through DoclingServeClient 2026-05-26 17:38:49 +03:00
Yiorgis Gozadinos
fdb73fc41f
Drop defensive branches 2026-05-26 16:56:32 +03:00
Yiorgis Gozadinos
291b565b4c
Stream dashboard index.html via FileResponse 2026-05-26 16:31:41 +03:00
Yiorgis Gozadinos
29b3ccdbda
Drain orphan cancel-cleanup releases before closing the queue 2026-05-26 16:14:17 +03:00
Yiorgis Gozadinos
960bf8f3e3
Document auth_token for non-loopback 2026-05-26 16:07:53 +03:00
Yiorgis Gozadinos
3c608046e2
Bound ?limit and ?offset on /jobs and /dlq 2026-05-26 16:05:47 +03:00
Yiorgis Gozadinos
761956eb70
Extend reaper-resurrection guard to reschedule and release_if_claimed 2026-05-26 16:04:24 +03:00
Yiorgis Gozadinos
6799a4a6d4
Document worker-pool settings 2026-05-26 14:13:16 +03:00
Yiorgis Gozadinos
a822b754b7
Guard mark_succeeded/mark_dead against reaper resurrection 2026-05-26 14:07:44 +03:00
Yiorgis Gozadinos
9cb3ce40ad
Shield cancel-cleanup release in worker pool 2026-05-26 13:57:35 +03:00
Yiorgis Gozadinos
78fc5d0e05
Discover within-root symlinks in FSSource 2026-05-26 13:54:33 +03:00
Yiorgis Gozadinos
e5e0df6ade
Unlink PDF slice tempfiles when slice write fails 2026-05-26 13:46:24 +03:00
Yiorgis Gozadinos
98efd73177
Serialize JobRepo and SyncStateRepo on one shared lock 2026-05-26 13:11:01 +03:00
Yiorgis Gozadinos
5f31a6b12f
Fix round-robin docling-serve 2026-05-26 13:07:21 +03:00
Yiorgis Gozadinos
cf6caf14fe
Tighten retry/reap_stale guards; fix SourceSummary.type leak 2026-05-26 12:38:55 +03:00
Yiorgis Gozadinos
f89cc998eb
add sources_provider to WorkerPool. workers now resolve extra info through these sources 2026-05-26 11:45:35 +03:00
Yiorgis Gozadinos
410279cd6c
Preserve sync_state revision when poller bumps last_seen_at 2026-05-26 11:45:35 +03:00
Yiorgis Gozadinos
2febfe2011
Docs & cl 2026-05-26 11:45:35 +03:00
Yiorgis Gozadinos
083ff401d5
vacuum db after migration 2026-05-26 11:44:47 +03:00
Yiorgis Gozadinos
73f8349a00
Use haiku.rag as telemtry scope 2026-05-26 11:44:47 +03:00
Yiorgis Gozadinos
d900651b92
Render "queue busy" badge on sources skipped due to pending_work
Replaces the confusing em-dash + raw `pending_work` text when no successful sweep has run yet.
2026-05-26 11:44:47 +03:00
Yiorgis Gozadinos
ef9cacf981
Harden FS source against symlink escape; surface pool/poller liveness in /health, additional auth tests 2026-05-26 11:44:47 +03:00
Yiorgis Gozadinos
4aee18dcbe
Operator dashboard at GET /; tighten Logfire span shape
Self-contained HTML status page served from the ingester's FastAPI app.
Polls /health, /sources, /stats, /jobs?status={claimed,dead,succeeded}
every 3s from the browser and renders queue chips, sources with
last-poll/skip-reason/circuit state, active jobs with cancel, recent
failures with retry, and recently-completed feed with op badges so
DELETE rows are visually distinct from UPSERTs. Zero external deps —
single static HTML, no CDN, no fonts, no images. Works offline.

To support the dashboard:
- New /stats endpoint exposing rolling throughput (5m/30m/1h), worker
  occupancy, oldest-queued age, and per-source DLQ + queue-depth
  breakdowns. Each field is a single SQL aggregation against the queue.
- JobRepo gains count_succeeded_since, oldest_queued_age_seconds,
  counts_by_source.
- SourceSummary gains last_skip_reason. BasePoller now records the
  reason the most recent sweep attempt was skipped ("pending_work" /
  "circuit_open"), cleared on the next successful poll. Closes the
  gap where operators couldn't tell from /sources alone why a source
  wasn't picking up new work.

Auth: dashboard route is unauthenticated (markup only). The JS attaches
the bearer to its own JSON fetches; on 401 it prompts once and stashes
the token in localStorage.

Two Logfire fixes that landed alongside:

- Drop logfire.instrument_fastapi() and the [fastapi] extra. The control
  plane is polled frequently (dashboard + docker healthcheck), so every
  endpoint became a span and drowned the useful traces. logfire itself
  stays — pulled in transitively via pydantic-ai-slim[logfire] — so
  ingester.poller.* / ingester.job / document.* spans keep emitting.

- Wrap FSPoller._handle_watch_change in an ingester.poller.watch_event
  span and pass _enqueue_extra. Without this, the watchfiles callback
  ran with no active context, the _otel carrier in job.extra was empty,
  and the worker's ingester.job span surfaced as an orphan trace root
  instead of nesting under the FS event that caused it.
2026-05-26 11:44:47 +03:00
Yiorgis Gozadinos
5affe70eae
Cover unparseable-metadata path 2026-05-26 11:44:47 +03:00
Yiorgis Gozadinos
9ca06df745
Bound pdf_split memory + release lock between slices 2026-05-26 11:44:46 +03:00
Yiorgis Gozadinos
c2f681dd78
constant-time auth, migration short-circuit 2026-05-26 11:44:46 +03:00
Yiorgis Gozadinos
da3cfe1a58
Release claim on jobs when cancelled 2026-05-26 11:44:46 +03:00
Yiorgis Gozadinos
14beba6786
Lock pypdfium2 under concurrent workers; per-slice convert spans 2026-05-26 11:44:46 +03:00
Yiorgis Gozadinos
567a0daf67
Add second docling-serve replica 2026-05-26 11:44:46 +03:00
Yiorgis Gozadinos
ba623b0862
mark event-loop glue + defensive guards as no-cover 2026-05-26 11:44:46 +03:00
Yiorgis Gozadinos
710276ffd8
record cassettes against new fixtures, fix stale assertions 2026-05-26 11:44:46 +03:00
Yiorgis Gozadinos
ed36cc2230
typed UnsupportedSourceError replaces pipeline string-marker matching 2026-05-26 11:44:46 +03:00
Yiorgis Gozadinos
57e89426ea
regex-based _strip_etag with weak-marker test 2026-05-26 11:44:45 +03:00
Yiorgis Gozadinos
9ed24ad53e
swap doclaynet.pdf to the full paper, add real-PDF split/merge test 2026-05-26 11:44:45 +03:00
Yiorgis Gozadinos
6bcc2f6357
PDF split-convert-merge for memory-bound large PDFs 2026-05-26 11:44:45 +03:00
Yiorgis Gozadinos
f4468b65ee
Add --host, --port to ingester cli, update docker compose example 2026-05-26 11:44:45 +03:00
Yiorgis Gozadinos
ec94426556
HTTPSource.head() returns ETag for the revision short-circuit 2026-05-26 11:44:45 +03:00
Yiorgis Gozadinos
66d5fee682
Docker & docs updates 2026-05-26 11:44:45 +03:00
Yiorgis Gozadinos
055fd23d5d
round-robin docling-serve across multiple base_urls 2026-05-26 11:44:45 +03:00
Yiorgis Gozadinos
5ccbadde0a
Handle shutdown more gracefully, by stopping pollers and cancelling jobs after timeout. Skip periodic poll if a source has pending jobs 2026-05-26 11:44:03 +03:00
Yiorgis Gozadinos
65c309e12b
Handle WebDAV as source 2026-05-26 11:43:30 +03:00
Yiorgis Gozadinos
13cbadeb6f
canonical metadata keys: source_revision + content_type, bump to 0.50.0 2026-05-26 11:43:30 +03:00
Yiorgis Gozadinos
fa672c298a
logfire spans for the ingester, nested poller/job/document traces 2026-05-26 11:42:42 +03:00
Yiorgis Gozadinos
6d15f1f247
fix URL-decode file:// paths before stat/exists checks 2026-05-26 11:42:42 +03:00
Yiorgis Gozadinos
7ea61a7b10
drop the old monitor, rename serve→mcp, add e2e tests 2026-05-26 11:42:41 +03:00
Yiorgis Gozadinos
1ca3c25a83
logging, sqlite cursors 2026-05-26 11:41:54 +03:00
Yiorgis Gozadinos
de3b3fa1c9
HTTP control plane 2026-05-26 11:41:54 +03:00
Yiorgis Gozadinos
75c3896588
additional config, pollers, serve 2026-05-26 11:41:54 +03:00
Yiorgis Gozadinos
bdcc1caa49
worker pool, rety policy, pipeline 2026-05-26 11:41:54 +03:00
Yiorgis Gozadinos
4b3641244c
SQLite queue + haiku-ingester CLI skeleton 2026-05-26 11:41:54 +03:00
Yiorgis Gozadinos
717fc3320b
route create_document_from_source through source adapters 2026-05-26 11:41:54 +03:00
Yiorgis Gozadinos
b9637cd625
add HTTP and S3 source adapters + resolve_fetcher 2026-05-26 11:41:53 +03:00
Yiorgis Gozadinos
d0a730ef60
ingester: add source-adapter scaffolding + FS source 2026-05-26 11:41:53 +03:00
Yiorgis Gozadinos
2d199037b2
Merge pull request #384 from ggozad/fix/remove-context-explosion-tools
drop list_documents/get_document from default RAG skill
2026-05-26 11:40:18 +03:00
Yiorgis Gozadinos
8e9e84132e
drop list_documents/get_document from default RAG skill 2026-05-26 11:35:43 +03:00
Yiorgis Gozadinos
14d9951016
vb 2026-05-21 14:32:13 +03:00
Yiorgis Gozadinos
260b8e8b09
Merge pull request #382 from ggozad/chore/updates
Prepare for pydantic-ai 2.0, update haiku.skills
2026-05-21 14:30:55 +03:00
Yiorgis Gozadinos
e881ce285d
update orb benchmark 2026-05-21 14:23:41 +03:00
Yiorgis Gozadinos
188d35023f
rotate chat TUI conversation id per launch / clear-chat 2026-05-21 14:21:51 +03:00
Yiorgis Gozadinos
80e3ae0280
clean up deprecation warnings: docling annotations + haiku.skills 0.17.1 2026-05-21 13:45:40 +03:00
Yiorgis Gozadinos
392c74039b
bump pydantic-ai-slim to 1.100 and haiku.skills to 0.17
Migrate off two APIs slated for removal in pydantic-ai 2.0:

- Agent(tool_retries=, output_retries=) -> Agent(retries={"tools": ...,
  "output": ...}) in the LLM-as-judge evaluator.
- Evaluator.evaluation_name class attribute -> overriding
  get_default_evaluation_name() on the citation MRR / MAP evaluators.

haiku.skills 0.17.0 already migrated its internal AGUIAdapter,
MCPToolset and ProcessEventStream usage; no further changes needed on
our side beyond the pin bumps.
2026-05-21 13:20:54 +03:00
Yiorgis Gozadinos
d98557ad52
Merge pull request #381 from ggozad/fix/migration-schema-coupling
fix migration failures on pre-v0.48.0 document_items schemas
2026-05-21 12:16:10 +03:00
Yiorgis Gozadinos
d14bf88096
fix migration failures on pre-v0.48.0 document_items schemas 2026-05-21 10:53:21 +03:00
Yiorgis Gozadinos
e55125763f
Document setting thinking on OpenAI compat providers 2026-05-21 10:25:48 +03:00
Yiorgis Gozadinos
3e29a974c2
Merge pull request #379 from ggozad/fix/mxbai-crash-tui
fix mxbai reranker crash in chat TUI
2026-05-20 17:31:54 +03:00
Yiorgis Gozadinos
6690953480
fix mxbai reranker crash in chat TUI 2026-05-20 17:18:57 +03:00
Yiorgis Gozadinos
323668672f
Merge pull request #380 from ggozad/chore/zensical
New documentation site based on Zensical
2026-05-20 17:03:04 +03:00
Yiorgis Gozadinos
b7f542b148
fix README links and stale MkDocs reference 2026-05-20 17:00:40 +03:00
Yiorgis Gozadinos
0b36c65b58
Landing page hero with TUI screen recording 2026-05-20 16:56:25 +03:00
Yiorgis Gozadinos
3e8aa8f09f
Move to zensical.toml 2026-05-20 16:01:14 +03:00
Yiorgis Gozadinos
a4091cb6fa
Straight migration to zensical 2026-05-20 15:44:39 +03:00
Yiorgis Gozadinos
98b0c068e8
vb 2026-05-20 14:34:33 +03:00
Yiorgis Gozadinos
1d3b6bb0c6
Merge pull request #378 from ggozad/feat/better-docs
Improve documentation.
2026-05-20 14:33:14 +03:00
Yiorgis Gozadinos
0ee00334c7
drop prose emdashes and semicolons; minor fixes 2026-05-20 14:27:16 +03:00
Yiorgis Gozadinos
f1f2e24639
docs: benchmarks — skill-only framing, move inactive datasets to bottom 2026-05-20 14:27:16 +03:00
Yiorgis Gozadinos
910d178b00
docs: rebuild Skills section, drop pitch prose 2026-05-20 14:27:16 +03:00
Yiorgis Gozadinos
19e2be003e
docs: add chat page, restructure nav, reorder sections 2026-05-20 14:27:16 +03:00
Yiorgis Gozadinos
76804b682f
index, tutorial 2026-05-20 14:27:16 +03:00
Yiorgis Gozadinos
ae17e53f97
Merge pull request #376 from ggozad/feat/vfs-search
Analysis skill: structural VFS, multimodal citations, drop research
2026-05-20 14:17:31 +03:00
Yiorgis Gozadinos
b48ac2fdd0
sandbox + cite: guard sort precondition; require rag in cite fallback 2026-05-20 13:02:25 +03:00
Yiorgis Gozadinos
f9392cee46
evaluations: judge model moves to config.evaluations.judge 2026-05-20 12:56:22 +03:00
Yiorgis Gozadinos
8c57a3ca99
Drop the multi-agent research workflow 2026-05-20 12:46:48 +03:00
Yiorgis Gozadinos
a317a951d9
CLI citations: compact panel, inline figures, doc/chunk IDs in footer 2026-05-20 12:00:24 +03:00
Yiorgis Gozadinos
3858ab905a
toc.json nodes carry chunk_ids; fix cite to accept DB-resolvable chunk_ids 2026-05-20 11:37:35 +03:00
Yiorgis Gozadinos
04eeec77d2
per-doc lazy items/toc cache; index document_items for fast per-doc lookup 2026-05-20 10:08:25 +03:00
Yiorgis Gozadinos
e3314e36fd
search: only attach picture bytes from pre-expansion chunks 2026-05-19 15:03:22 +03:00
Yiorgis Gozadinos
e8c61ad0c6
items.jsonl exposes chunk_ids per row; drops position and tree_depth 2026-05-19 14:20:11 +03:00
Yiorgis Gozadinos
6f95e2bc27
Delete the standalone QA and analysis agents 2026-05-19 11:39:20 +03:00
Yiorgis Gozadinos
ceb645564c
Drop GEPA prompt optimization and --target qa from evaluations 2026-05-19 11:01:07 +03:00
Yiorgis Gozadinos
947a26b391
client.analyze routes through the rag-analysis skill; drop documents= and AnalysisResult.program. Re-record all cassettes that are relevant 2026-05-19 10:46:32 +03:00
Yiorgis Gozadinos
d96d2eeb0f
client.ask routes through the rag skill; always show citations in CLI 2026-05-19 10:19:39 +03:00
Yiorgis Gozadinos
57d077e2c3
cite raises ModelRetry on unresolved chunk_ids 2026-05-18 16:49:44 +03:00
Yiorgis Gozadinos
7569fdcea1
Consolidate uv.local from main 2026-05-18 16:49:44 +03:00
Yiorgis Gozadinos
87c551c892
Cover 0.48.0 migration 2026-05-18 16:49:44 +03:00
Yiorgis Gozadinos
84e91097a8
Stop hitting the embedder in test_sandbox_toc fixtures 2026-05-18 16:49:44 +03:00
Yiorgis Gozadinos
3b6bef3bfb
Rename get_captions_for_chunk() to get_text_for_refs() 2026-05-18 16:49:44 +03:00
Yiorgis Gozadinos
bdc77bd467
Forgotten changelog entries 2026-05-18 16:49:44 +03:00
Yiorgis Gozadinos
74dea65f21
analysis.model inherits qa.model when unset; per-skill vision gate 2026-05-18 16:49:43 +03:00
Yiorgis Gozadinos
52e93d08eb
Explain why no images in sandbox search(). We might want to add show_image() back in the future. 2026-05-18 16:49:43 +03:00
Yiorgis Gozadinos
c562c397f9
move analysis skill toward search-first; document VFS doc_id vs uri 2026-05-18 16:49:43 +03:00
Yiorgis Gozadinos
514b0c51f8
surface figure captions in search results, lower search.limit to 5 2026-05-18 16:49:43 +03:00
Yiorgis Gozadinos
7d99be471f
Add tree-sitter-json on tui 2026-05-18 16:49:43 +03:00
Yiorgis Gozadinos
e51841b924
Drop list_documents from analysis skill 2026-05-18 16:49:43 +03:00
Yiorgis Gozadinos
68388032be
consolidate #/pictures/ prefix, tighten CHANGELOG, log migration exc_info 2026-05-18 16:49:43 +03:00
Yiorgis Gozadinos
f13461cdca
route figures through cite, drop show_image 2026-05-18 16:48:34 +03:00
Yiorgis Gozadinos
387c4f12a9
multimodal sandbox: show_image + picture_refs, drop llm 2026-05-18 16:48:34 +03:00
Yiorgis Gozadinos
2831030f9b
expose toc.json + heading_level/tree_depth in analysis VFS 2026-05-18 16:48:34 +03:00
Yiorgis Gozadinos
1360303edf
persist docling heading hierarchy on document_items 2026-05-18 16:48:34 +03:00
Yiorgis Gozadinos
f208730a5b
Merge pull request #374 from tseaver/feat-cli-search_type
feat: add '--search-type' option to CLI 'search'
2026-05-18 16:39:28 +03:00
Tres Seaver
8026693450
chore: ruff format 2026-05-18 09:31:38 -04:00
Tres Seaver
ad0161e6da
chore: isort 2026-05-18 09:31:12 -04:00
Tres Seaver
f9ce3bc401
refactor: move 'SearchType' to 'store.models.chunk' 2026-05-18 09:27:34 -04:00
Tres Seaver
d7e96f2658
fix: typo 2026-05-18 09:17:53 -04:00
Tres Seaver
4feac9683a
fix: default 'search_type' to None 2026-05-18 09:17:09 -04:00
Yiorgis Gozadinos
b98af1bf11
Merge pull request #377 from ggozad/chore/deps
Update dependencies
2026-05-18 16:13:12 +03:00
Yiorgis Gozadinos
04415a9d09
bump pydantic-monty, refresh deps, cap transformers<5 2026-05-18 16:02:55 +03:00
Yiorgis Gozadinos
1df6530d03
Bump pydantic-ai, prepare for 2.* 2026-05-18 15:14:29 +03:00
Yiorgis Gozadinos
b1e8bdc839
Bump docling 2026-05-18 15:14:29 +03:00
Tres Seaver
b331dd0b76
fix: default 'search_type' to 'None' in 'client.search.search'
Apply 'hybrid' default only for text queries.
2026-05-18 06:52:03 -04:00
Tres Seaver
5079205d3d
fix: use 'SearchType' for 'ChunkRepository.search' 2026-05-18 06:50:49 -04:00
Tres Seaver
70da24b691
docs: add '--search-type' examples to CLI docs 2026-05-18 06:40:41 -04:00
Tres Seaver
7953de46b9
fix: declare 'SearchType' for 'search_type' arg
fix: allow 'search_type' only for text searches
2026-05-18 06:35:51 -04:00
Tres Seaver
b2ffc50168
WIP: add '--search-type' option to CLI 'search' 2026-05-18 06:35:50 -04:00
Yiorgis Gozadinos
f588e1150b
add vllm Gemma-4-26B skill QA row to wix table 2026-05-18 09:43:13 +03:00
Yiorgis Gozadinos
c1923b85cd
Add cross-encoder to all extras 2026-05-15 14:26:56 +03:00
Yiorgis Gozadinos
abc5a210a6
update benchmarks 2026-05-15 10:04:17 +03:00
Yiorgis Gozadinos
97a7eb246c
vb 2026-05-14 16:03:33 +03:00
Yiorgis Gozadinos
9c19de93c0
Merge pull request #373 from ggozad/feat/rerank-cross-encoder
Add cross-encoder reranking provider support
2026-05-14 16:02:07 +03:00
Yiorgis Gozadinos
5de4d50622
coverage 2026-05-14 15:54:43 +03:00
Yiorgis Gozadinos
57f273e000
Minor fixes, CI should build 2026-05-14 15:43:36 +03:00
Yiorgis Gozadinos
519afe6709
Add cross-encoder reranking provider 2026-05-14 15:43:35 +03:00
Yiorgis Gozadinos
0e4c462885
Merge pull request #372 from ggozad/fix/rebuild-embed-memory
Bound memory usage during embeddings rebuild
2026-05-14 15:37:00 +03:00
Yiorgis Gozadinos
dbb87e091f
Ensure schema parity for _StagingChunkRecord 2026-05-14 15:11:18 +03:00
Yiorgis Gozadinos
849eca94d0
Make rebuild --embed-only idempotent across crashes 2026-05-14 15:02:12 +03:00
Yiorgis Gozadinos
a035b0f9e4
Stream rebuild --embed-only through a staging table to bound memory 2026-05-14 15:02:12 +03:00
Yiorgis Gozadinos
704bea6f4a
Stabilize test_client_search ranking assertion 2026-05-14 14:37:27 +03:00
Yiorgis Gozadinos
d76c645862
Update benchmarks for orb for gemma4 2026-05-14 14:03:19 +03:00
Yiorgis Gozadinos
862a209137
vb 2026-05-13 16:53:35 +03:00
Yiorgis Gozadinos
129563b319
Merge pull request #370 from ggozad/fix/picture-generation-opt-out
Replace picture_description.enabled with processing.pictures enum
2026-05-13 16:32:31 +03:00
Yiorgis Gozadinos
a98ddc14b8
Replace picture_description.enabled with processing.pictures enum 2026-05-13 16:22:44 +03:00
Yiorgis Gozadinos
bdab201c42
Merge pull request #371 from ggozad/fix/image-results
Verify picture bytes before attaching to multimodal tool returns
2026-05-13 16:19:09 +03:00
Yiorgis Gozadinos
a6e5664c78
Verify picture bytes before attaching to multimodal tool returns 2026-05-13 16:01:39 +03:00
Yiorgis Gozadinos
072edad62f
Merge pull request #368 from ggozad/feat/relax-compat-check
Relax embedding compatibility check to vector_dim only
2026-05-13 15:43:37 +03:00
Yiorgis Gozadinos
701e9d3632
Relax embedding compat check to vector_dim only 2026-05-13 14:44:18 +03:00
Yiorgis Gozadinos
675c9030a4
Merge pull request #369 from ggozad/fix/embedding-batch-size
Expose embedding batch size as config
2026-05-13 14:21:10 +03:00
Yiorgis Gozadinos
8b135c4d4d
Expose embedding batch size as config 2026-05-13 14:09:47 +03:00
Yiorgis Gozadinos
d11b8a4e6e
Merge pull request #367 from ggozad/feat/extra-body
Add ModelConfig.extra_body for raw provider pass-through
2026-05-13 13:54:08 +03:00
Yiorgis Gozadinos
c6cd847299
Add ModelConfig.extra_body for raw provider pass-through 2026-05-13 13:45:09 +03:00
Yiorgis Gozadinos
0f4c4af495
Cache the chunking tokenizer to avoid HF Hub 429 2026-05-13 13:32:24 +03:00
Yiorgis Gozadinos
d512f9fbd1
Disable Typer locals in CLI tracebacks 2026-05-13 13:24:05 +03:00
Yiorgis Gozadinos
bf1cd68f64
Preserve cause chain on docling wrapper exceptions 2026-05-13 12:18:53 +03:00
Yiorgis Gozadinos
2189002974
Benchmark update 2026-05-12 17:20:48 +03:00
Yiorgis Gozadinos
45dfd4b382
Merge pull request #363 from ggozad/feat/chat-tui-markdown-streaming
Support streaming for markdown
2026-05-12 17:16:22 +03:00
Yiorgis Gozadinos
674b80e8c9
Support streaming for markdown 2026-05-12 16:01:42 +03:00
Yiorgis Gozadinos
bb39eb3809
Merge pull request #364 from ggozad/fix/remote-image-ingestion
Fix HTML image ingestion and wire conversion options across all formats
2026-05-12 15:58:05 +03:00
Yiorgis Gozadinos
a206f8bfcc
Document fetch_remote_images and the docling-serve HTML gap 2026-05-12 15:50:33 +03:00
Yiorgis Gozadinos
df21286cc9
add tests for mixed <img> sources and IMAGE-format wiring 2026-05-12 15:37:50 +03:00
Yiorgis Gozadinos
afd4665517
Thread source_uri through URL ingest 2026-05-12 15:13:02 +03:00
Yiorgis Gozadinos
a25d9699ee
Wire per-format options across docling-local 2026-05-12 15:02:59 +03:00
Yiorgis Gozadinos
c34a9dd466
Merge pull request #360 from ggozad/feat/s3-monitor
S3/object-storage monitoring and s3:// document sources
2026-05-12 14:55:11 +03:00
Yiorgis Gozadinos
9f8c9fe3b1
cover s3 uri override, unsupported extension, watcher error path 2026-05-11 11:29:56 +03:00
Yiorgis Gozadinos
6631e339ee
record VCR cassettes for s3 source tests 2026-05-11 11:29:56 +03:00
Yiorgis Gozadinos
6f6ebf27c8
Document s3 storage & monitoring 2026-05-11 11:29:56 +03:00
Yiorgis Gozadinos
ef82e6eb08
swap aioboto3 for obstore in the S3 client path 2026-05-11 11:29:42 +03:00
Yiorgis Gozadinos
f87d894b01
add SeaweedFS integration tests for S3Watcher 2026-05-11 11:28:40 +03:00
Yiorgis Gozadinos
37099988a6
poll S3 prefixes with S3Watcher and wire into serve 2026-05-11 11:28:40 +03:00
Yiorgis Gozadinos
d009da06d6
support s3:// document sources 2026-05-11 11:28:31 +03:00
Yiorgis Gozadinos
8b8f44e7b2
vb 2026-05-08 12:22:35 +03:00
Yiorgis Gozadinos
5c0598c3be
Merge pull request #362 from ggozad/feat/multimodal-search
Vision capabilities: picture-aware ingestion, multimodal embeddings, image-as-query
2026-05-08 12:20:04 +03:00
Yiorgis Gozadinos
387032511e
Simplify 2026-05-08 12:11:25 +03:00
Yiorgis Gozadinos
788fac422e
Clean up migration 2026-05-08 11:52:57 +03:00
Yiorgis Gozadinos
ab5cfdd04a
Docs & cl 2026-05-08 11:20:04 +03:00
Yiorgis Gozadinos
6af463699d
record VCR cassette for vllm embedder end-to-end test 2026-05-06 20:01:22 +03:00
Yiorgis Gozadinos
5e6a7148ed
Update README 2026-05-06 13:24:44 +03:00
Yiorgis Gozadinos
cc59dc7203
Use upload_large_folder for hf upload 2026-05-06 13:12:08 +03:00
Yiorgis Gozadinos
e55e3d13c8
fix _patch_picture_descriptions silently dropping docling_pages 2026-05-06 12:57:41 +03:00
Yiorgis Gozadinos
ce7201271f
split open_rag_bench dataset into orb_text and orb_multimodal variants 2026-05-06 12:49:03 +03:00
Yiorgis Gozadinos
75f75ec505
render attached pictures in inspector context modal under qa.model.vision 2026-05-06 12:00:12 +03:00
Yiorgis Gozadinos
7fac35d2af
rename embed_image_query to embed_image; run description check on text path 2026-05-05 16:15:47 +03:00
Yiorgis Gozadinos
1ccb5b5fad
auto-append /v1 to vllm base_url, matching ollama behavior 2026-05-05 16:08:14 +03:00
Yiorgis Gozadinos
4478b7ce2b
test v0.45.0 migration 2026-05-05 16:06:14 +03:00
Yiorgis Gozadinos
c96894a0f0
fix cross-document picture dedup in agent and skill search tools 2026-05-05 15:56:10 +03:00
Yiorgis Gozadinos
61a88edfd8
drop bogus deprecation warning for processing.pictures 2026-05-05 14:46:41 +03:00
Yiorgis Gozadinos
08696773fe
Fix vcr test 2026-05-05 14:38:03 +03:00
Yiorgis Gozadinos
49274ab51b
attach picture bytes through skill search and document the schema 2026-05-05 14:33:57 +03:00
Yiorgis Gozadinos
46b81f3fa1
cover picture-description provider and rebuild edge cases 2026-05-05 14:20:37 +03:00
Yiorgis Gozadinos
ffc7b95375
yield per document during rebuild for live progress reporting 2026-05-05 14:04:37 +03:00
Yiorgis Gozadinos
a45d82f206
Update benchmark 2026-05-05 13:58:48 +03:00
Yiorgis Gozadinos
5c3a864a0b
rename open_rag_bench eval DBs to text/multimodal variants 2026-05-05 12:55:06 +03:00
Yiorgis Gozadinos
dca3188a48
hydrate documents lazily during rebuild 2026-05-05 12:47:45 +03:00
Yiorgis Gozadinos
ff82d36c2f
add rebuild --descriptions: run VLM over stored picture bytes only 2026-05-05 12:02:46 +03:00
Yiorgis Gozadinos
5fad0aa5c6
warn when picture descriptions silently fail to come back 2026-05-05 11:44:18 +03:00
Yiorgis Gozadinos
2038d43435
collapse pictures enum to picture_description.enabled boolean 2026-05-05 11:16:08 +03:00
Yiorgis Gozadinos
e88321767d
always store picture bytes; collapse converter to a single zip path 2026-05-05 10:40:40 +03:00
Yiorgis Gozadinos
17e36fc069
Add first openrag results 2026-05-05 09:50:22 +03:00
Yiorgis Gozadinos
35d5f4416e
fill in vLLM error-path and MCP image-query coverage 2026-05-05 09:39:20 +03:00
Yiorgis Gozadinos
ee41bc676f
remove defensive code 2026-05-04 15:12:27 +03:00
Yiorgis Gozadinos
e8d89aa035
dedup picture-only chunks at the search-result layer 2026-05-04 13:31:15 +03:00
Yiorgis Gozadinos
33e462d987
add real vLLM integration test 2026-05-04 13:16:31 +03:00
Yiorgis Gozadinos
aa3e9406cf
auto-append /v1 to per-model Ollama base_url, fix flaky tests 2026-05-04 13:12:37 +03:00
Yiorgis Gozadinos
65d9c74224
expose image-as-query through MCP and the CLI. 2026-05-04 11:46:45 +03:00
Yiorgis Gozadinos
ff656504d3
make client.search() polymorphic on query type: str | bytes | PIL.Image.Image. Bytes/PIL queries embed via embed_image_query and run vector-only against the chunks table 2026-05-04 11:22:22 +03:00
Yiorgis Gozadinos
21f12cd770
add vision: bool flag on ModelConfig (default False). Gate the agent search tool's BinaryContent attachment on qa.model.vision so picture bytes are only sent to vision-capable QA models. 2026-05-04 10:47:21 +03:00
Yiorgis Gozadinos
6037f26673
drop mlx 2026-05-03 19:05:49 +03:00
Yiorgis Gozadinos
c9fdad15a9
bump docling-core to >=2.74.1 so that local docling-local and docling-serve chunkers run the same MarkdownTableSerializer 2026-05-03 18:40:44 +03:00
Yiorgis Gozadinos
c9227c649f
emit synthetic picture chunks at ingest under multimodal embedders.
processing.chunk() merges text chunks with one synthetic Chunk per PictureItem-with-bytes,
sorted by iterate_items() position so chunk.order is structural.
embed_chunks dispatches on a Chunk._picture_data PrivateAttr
(text through embed_documents, picture through embed_image_query)
2026-05-03 17:04:43 +03:00
Yiorgis Gozadinos
a36f920f5d
drop the batched embed_images method from multi-modal embedders 2026-05-03 16:21:47 +03:00
Yiorgis Gozadinos
37215f1a00
add multimodal embedder support for vllm and mlx 2026-05-03 10:57:56 +03:00
Yiorgis Gozadinos
0e9efe448d
Merge pull request #361 from ggozad/fix/source-uri-override
Allow uri override on create_document_from_source
2026-04-30 17:35:38 +03:00
Yiorgis Gozadinos
d509b0662d
update benchmarks 2026-04-30 16:54:17 +03:00
Yiorgis Gozadinos
75c8e83515
Allow uri override on create_document_from_source 2026-04-30 16:36:35 +03:00
Yiorgis Gozadinos
861c36942b
cl 2026-04-30 15:50:28 +03:00
Yiorgis Gozadinos
4c9ef81fc6
drop the existing-row-not-found guard from the 0.45.0 picture-bytes migration. 2026-04-30 15:48:03 +03:00
Yiorgis Gozadinos
b290bbe82d
rename document_items repository's _LIGHT_COLUMNS to _METADATA_COLUMNS 2026-04-30 15:43:03 +03:00
Yiorgis Gozadinos
35e6568f59
add a regression test pinning down extract_items precedence: when a PictureItem has both an inline image.uri AND a fallback in existing_picture_data, the live URI wins. 2026-04-30 15:35:13 +03:00
Yiorgis Gozadinos
875c635a77
have rebuild --rechunk re-chunk from the stored docling blob instead of re-converting from the markdown export 2026-04-30 15:30:24 +03:00
Yiorgis Gozadinos
7831057339
add processing.pictures enum (none|description|image), replacing the implicit pair of generate_picture_images+picture_description.enabled flags 2026-04-30 14:26:14 +03:00
Yiorgis Gozadinos
b01c649684
surface picture image bytes in SearchResult and emit multimodal ToolReturn from the agent search tool 2026-04-30 12:43:05 +03:00
Yiorgis Gozadinos
6a77ce92a9
extract picture bytes to document_items.picture_data at ingest, strip them from the docling_document blob, and add 0.45.0 migration to backfill existing
databases
2026-04-30 11:26:37 +03:00
Yiorgis Gozadinos
191cdc636b
retrieve picture image bytes via referenced+zip path, fix docling-serve not returning images even when set to do so. Remove xfail from relevant test 2026-04-30 11:23:29 +03:00
Yiorgis Gozadinos
dc16f74b58
add picture_data column to document_items 2026-04-30 11:23:29 +03:00
Yiorgis Gozadinos
3391335e3a
Update qa wix benchmarks 2026-04-29 14:21:40 +03:00
Yiorgis Gozadinos
29bc42514e
vb 2026-04-29 13:45:36 +03:00
Yiorgis Gozadinos
722a863803
Merge pull request #359 from ggozad/feat/cite-rate-skill
Skill-target evals, citation retrieval, qwen3.6 as default judge
2026-04-29 13:42:01 +03:00
Yiorgis Gozadinos
318266e681
Update benchmark 2026-04-29 13:27:32 +03:00
Yiorgis Gozadinos
8962987ff9
pin judge model to ollama:qwen3.6 2026-04-29 12:24:06 +03:00
Yiorgis Gozadinos
5f08a2a8ae
Adapt rag SKILL.md in order to improve citation rate 2026-04-28 14:51:28 +03:00
Yiorgis Gozadinos
b0256b3648
Merge pull request #358 from ggozad/feat/skill-evals
benchmark RAG and analysis skills
2026-04-28 14:44:46 +03:00
Yiorgis Gozadinos
5e31c15907
docs 2026-04-28 14:44:27 +03:00
Yiorgis Gozadinos
7bfb818d77
remove dataset-specific system prompts 2026-04-28 14:33:25 +03:00
Yiorgis Gozadinos
7d288c525e
citation retrieval scoring 2026-04-28 12:46:16 +03:00
Yiorgis Gozadinos
d00befd0c4
benchmark RAG and analysis skills via --target 2026-04-28 12:09:19 +03:00
Yiorgis Gozadinos
7685a592c2
bump haiku.skills to 0.16.0 2026-04-28 12:03:14 +03:00
Yiorgis Gozadinos
6807f082ec
vb 2026-04-25 09:35:47 +03:00
Yiorgis Gozadinos
758bd3ee72
Group relative-path test with the other connect_lancedb dispatch tests 2026-04-25 09:28:46 +03:00
Yiorgis Gozadinos
7828a8b05d
Merge pull request #355 from tseaver/fix-354-connect_lancedb-w-relative-db_path
fix: pass absolute 'db_path' to 'lancedb.connect_async'
2026-04-25 09:26:08 +03:00
Tres Seaver
0d70f4aec6
fix: pass absolute 'db_path' to 'lancedb.connect_async'
Closes #354.
2026-04-24 17:59:01 -04:00
Yiorgis Gozadinos
81ba248483
vb 2026-04-24 17:47:57 +03:00
Yiorgis Gozadinos
5e3f7fb3a6
Merge pull request #351 from ggozad/feat/async-lancedb
refactor: native async LanceDB (non-blocking DB I/O)
2026-04-24 17:47:12 +03:00
Yiorgis Gozadinos
a4275acdce
Fix frontend selection collisions, stale fetches, and URL consistency 2026-04-24 17:17:28 +03:00
Yiorgis Gozadinos
821b7361e9
Push document-id filter into chunk search query 2026-04-24 16:07:07 +03:00
Yiorgis Gozadinos
31fc22ce9d
List tables consistently 2026-04-24 15:57:07 +03:00
Yiorgis Gozadinos
cd4224a727
drop private title/embedding forwarders from HaikuRAG 2026-04-24 15:52:50 +03:00
Yiorgis Gozadinos
0ffca7727c
Fix caplog-based assertion flaking under xdist ordering 2026-04-24 15:51:29 +03:00
Yiorgis Gozadinos
ddcc4518ad
Cover FULL rebuild source-failure branch 2026-04-24 15:32:53 +03:00
Yiorgis Gozadinos
b23bad32c3
Check table_names() before dropping chunks in recreate_embeddings_table 2026-04-24 15:32:06 +03:00
Yiorgis Gozadinos
729899299b
Log post-rebuild vacuum failures and clarify vacuum-await scope 2026-04-24 15:31:36 +03:00
Yiorgis Gozadinos
2ad51f5dca
Serialize HaikuRAG singleton creation in backend get_client 2026-04-24 15:31:20 +03:00
Yiorgis Gozadinos
258ffa41fa
Improve test coverage 2026-04-24 14:43:59 +03:00
Yiorgis Gozadinos
d99e48a9a3
Close HaikuRAG client on backend shutdown 2026-04-24 14:43:59 +03:00
Yiorgis Gozadinos
aca3ccc29f
Close connections if __aenter__ fails mid-initialization 2026-04-24 14:43:59 +03:00
Yiorgis Gozadinos
3224d1f20c
Extract document orchestration into client/documents.py 2026-04-24 14:43:59 +03:00
Yiorgis Gozadinos
cb02a2c3d8
Extract rebuild machinery into client/rebuild.py 2026-04-24 14:43:59 +03:00
Yiorgis Gozadinos
528acb79a3
Extract LLM agents into client/agents.py 2026-04-24 14:43:58 +03:00
Yiorgis Gozadinos
12939f1444
Extract search, expand_context, visualize_chunk into client/search.py 2026-04-24 14:43:58 +03:00
Yiorgis Gozadinos
d4ecebf896
Extract processing primitives into client/processing.py 2026-04-24 14:42:53 +03:00
Yiorgis Gozadinos
b5e480bfa1
Extract title generation into client/titles.py 2026-04-24 14:42:53 +03:00
Yiorgis Gozadinos
e814459e1f
Extract download_models into client/downloads.py 2026-04-24 14:42:53 +03:00
Yiorgis Gozadinos
43f2774c14
Turn client.py into a client/ package 2026-04-24 14:42:53 +03:00
Yiorgis Gozadinos
d0ed9e213f
Pin FTS search column to content_fts 2026-04-24 14:42:53 +03:00
Yiorgis Gozadinos
a74c3a6f9e
Satisfy ty by adding a typed to_pydantic helper 2026-04-24 14:42:53 +03:00
Yiorgis Gozadinos
b6ea07d2de
Track all in-flight background vacuum tasks, not just the last one 2026-04-24 14:42:53 +03:00
Yiorgis Gozadinos
57385506c1
Fix is_new_db detection when create=True is passed on an existing DB 2026-04-24 14:42:53 +03:00
Yiorgis Gozadinos
ce2df2e0bd
Docs 2026-04-24 14:42:52 +03:00
Yiorgis Gozadinos
ba911a35f6
Add test for background vaccum being awaited 2026-04-24 14:42:52 +03:00
Yiorgis Gozadinos
82fd10e0ee
Migrate LanceDB to native async API
Convert all LanceDB operations from sync calls wrapped in async
functions to the native async API (connect_async, AsyncConnection,
AsyncTable, AsyncQuery). Database I/O no longer blocks the event loop.

- Store and HaikuRAG use async context managers (async with). Store
  initialization is deferred to __aenter__; direct construction
  without async with is no longer supported.
- Index creation uses config objects (FTS, BTree, IvfPq) instead of
  string-based index_type parameter.
- Upgrade callbacks are async.
- HaikuRAG tracks background vacuum tasks and awaits them in __aexit__
  and before destructive rebuild operations to avoid races with
  concurrent table mutations.
- temp_db_path fixture uses pytest's tmp_path for reliable async
  cleanup.
2026-04-24 14:42:52 +03:00
Yiorgis Gozadinos
84c5391259
Merge pull request #353 from ggozad/feat/better-visual-highlight
Tone down the yellow colour in visual highlights
2026-04-24 14:41:27 +03:00
Yiorgis Gozadinos
6a289a2bf3
Merge pull request #352 from ggozad/fix/citations-tui
fix chat TUI citation rendering after state flattening
2026-04-24 13:58:38 +03:00
Yiorgis Gozadinos
cb9360ef7b
fix chat TUI citation rendering after state flattening 2026-04-24 13:48:57 +03:00
Yiorgis Gozadinos
a833e58d54
Tone down the yellow colour in visual highlights 2026-04-24 10:31:43 +03:00
Yiorgis Gozadinos
4f058bf484
vb 2026-04-22 17:00:03 +03:00
Yiorgis Gozadinos
1202fe17e7
Merge pull request #350 from ggozad/fix/citations
flatten skill state `citations` from list[list[str]] to list[str]
2026-04-22 16:59:07 +03:00
Yiorgis Gozadinos
ae2b8461be
flatten skill state citations from list[list[str]] to list[str] 2026-04-22 16:50:13 +03:00
Yiorgis Gozadinos
bb9e7b28ca
vb 2026-04-22 16:18:44 +03:00
Yiorgis Gozadinos
48affd3ce8
Merge pull request #348 from ggozad/feat/skills-lifespan
Use haiku.skills lifespan for per-invocation client, sandbox, state
2026-04-22 15:12:30 +03:00
Yiorgis Gozadinos
206c4ffcf2
address review: top-level AnalysisRunDeps import, asserts, e2e tests
- Move AnalysisRunDeps to the module-level import in _tools.py.
- Swap the RuntimeError guards in _require_rag and execute_code for
  plain asserts with the same diagnostic messages. Apply the same
  treatment to the ty-reachability guard in Sandbox._ensure_initialized.
- Add end-to-end lifespan tests for both skills that drive _run_skill
  with TestModel, covering the sub-agent entry path that the existing
  tool-level tests bypass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:04:24 +03:00
Yiorgis Gozadinos
e736a8c73a
Merge pull request #349 from ggozad/fix/convert-urlparse
fix convert() misreading text content that starts with a URL
2026-04-22 14:56:53 +03:00
Yiorgis Gozadinos
9067b89d2f
fix convert() misreading text content that starts with a URL 2026-04-22 14:47:23 +03:00
Yiorgis Gozadinos
f0016ebcd2
scope citations, searches, and executions to the current invocation 2026-04-22 13:53:39 +03:00
Yiorgis Gozadinos
4a9dd9b49a
persist sandbox variables across execute_code calls within one invocation 2026-04-22 13:30:38 +03:00
Yiorgis Gozadinos
4e9c02afc2
open one HaikuRAG client per skill invocation via lifespan 2026-04-22 13:13:42 +03:00
Yiorgis Gozadinos
8a23108fbd
vb 2026-04-20 16:36:32 +03:00
Yiorgis Gozadinos
2cd4a85f5f
Merge pull request #342 from ggozad/feat/analysis
Refactor analysis sandbox with document VFS and expanded search
2026-04-20 16:34:41 +03:00
Yiorgis Gozadinos
8e555ab76f
Improve coverage 2026-04-20 15:45:27 +03:00
Yiorgis Gozadinos
eb4e1721ce
remove dead code, add tool descriptions in frontend, update changelog 2026-04-20 15:38:17 +03:00
Yiorgis Gozadinos
ff8ad0879c
fix context expansion: respect section boundaries, remove max_context_items 2026-04-20 15:18:16 +03:00
Yiorgis Gozadinos
af7731f4e1
bulk-fetch items.jsonl via lazy cache to avoid per-document query timeout 2026-04-20 14:25:16 +03:00
Yiorgis Gozadinos
cd0c21c996
remove cited_chunks from analysis agent, hoist _deny_write out of loop 2026-04-20 14:05:10 +03:00
Yiorgis Gozadinos
579e609ae1
revert REPL persistence: fresh sandbox per execute_code call 2026-04-20 12:48:05 +03:00
Yiorgis Gozadinos
9d921b13fe
reset sandbox per skill invocation to prevent state leaks 2026-04-20 12:18:14 +03:00
Yiorgis Gozadinos
167c837514
repository methods, module-level executor, read-only VFS 2026-04-20 11:57:39 +03:00
Yiorgis Gozadinos
27c5defdbb
Improve analysis SKILL.md 2026-04-20 11:38:09 +03:00
Yiorgis Gozadinos
4d75943df5
use MontyRepl for persistent variables across execute_code calls 2026-04-20 10:47:50 +03:00
Yiorgis Gozadinos
fa87cf79c5
Docs & cl 2026-04-20 10:14:19 +03:00
Yiorgis Gozadinos
68a9f191d2
Remove documents from state, fix tests 2026-04-20 09:48:18 +03:00
Yiorgis Gozadinos
d52f453c44
flatten skill architecture: replace ask/analyze/research with direct tools 2026-04-17 18:35:17 +03:00
Yiorgis Gozadinos
20e40d75f9
add collapsible program display to chat TUI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 18:35:17 +03:00
Yiorgis Gozadinos
7d98d0ec57
add citation support to analysis agent 2026-04-17 18:35:16 +03:00
Yiorgis Gozadinos
cf89ff55cd
When setting --model, set all subagents as well 2026-04-17 18:35:16 +03:00
Yiorgis Gozadinos
aa4ce07dc9
remove filter parameter from analyze skill tool and clean up unused filter helpers 2026-04-17 18:35:16 +03:00
Yiorgis Gozadinos
2a0e89bbe7
add --skill flag to chat TUI for rag and analysis skills 2026-04-17 18:35:16 +03:00
Yiorgis Gozadinos
a45820dbf7
add document virtual filesystem to analysis sandbox
Replace get_document() and get_docling_document() with a VFS at
/documents/{id}/ with metadata.json (eager), content.txt (lazy),
and items.jsonl (lazy). Keep search(), list_documents() (now returns
all), and llm() as external functions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 18:35:16 +03:00
Yiorgis Gozadinos
bacc21b38b
expose doc_item_refs and labels in sandbox search results 2026-04-17 18:35:16 +03:00
Yiorgis Gozadinos
4118533db1
fold context expansion into sandbox search and remove get_context 2026-04-17 18:35:16 +03:00
Yiorgis Gozadinos
44fd0c9906
add get_context() to analysis sandbox and improve prompt 2026-04-17 18:32:45 +03:00
Yiorgis Gozadinos
d2b3ba1b59
rename RLM agent to analysis throughout the codebase 2026-04-17 18:32:01 +03:00
Yiorgis Gozadinos
499a843a43
remove unused create_analysis_toolset and AnalysisResult 2026-04-17 18:32:01 +03:00
Yiorgis Gozadinos
1049586469
vb 2026-04-17 16:24:25 +03:00
Yiorgis Gozadinos
47df79d026
Merge pull request #347 from ggozad/fix/info-migration-pending
info: report partial stats and pending migrations on pre-migration DBs
2026-04-17 16:22:58 +03:00
Yiorgis Gozadinos
a1181ff52d
extract get_database_stats and share it across info, inspector, and backend 2026-04-17 15:33:30 +03:00
Yiorgis Gozadinos
a0a9a3410b
info: report partial stats and pending migrations on pre-migration DBs 2026-04-17 14:00:03 +03:00
Yiorgis Gozadinos
4f16714430
prevent stale files from accumulating on HF during uploads. 2026-04-16 13:45:24 +03:00
Yiorgis Gozadinos
9beaf0d4ed
fix changelog 2026-04-16 12:57:22 +03:00
Yiorgis Gozadinos
969c45fe5c
Merge pull request #344 from ggozad/feat/docling-document-load-performace
Fast context expansion.
2026-04-16 12:33:52 +03:00
Yiorgis Gozadinos
75c84805b4
Improve judge prompt, QA prompt, and raise max_searches to 5 2026-04-16 12:12:23 +03:00
Yiorgis Gozadinos
e92f4774fa
Improve judge prompt 2026-04-16 12:11:54 +03:00
Yiorgis Gozadinos
d3c8ed62a8
preserve original chunk when expansion produces less content 2026-04-16 12:11:54 +03:00
Yiorgis Gozadinos
e2bac1e887
deduplicate escape_sql_string into utils 2026-04-16 12:11:53 +03:00
Yiorgis Gozadinos
39b9d0f683
Additional tests 2026-04-16 12:11:53 +03:00
Yiorgis Gozadinos
3c1a33779f
use windowed fetch for context expansion instead of loading all items 2026-04-16 12:11:53 +03:00
Yiorgis Gozadinos
b6113bf8ab
replace fixed-radius expansion with section-bounded algorithm
Context expansion is now automatic and structure-aware. For structured
documents, expands within the section containing the match. For sections
that exceed the budget or are too small, expands item-by-item outward
skipping noise labels. Unstructured documents use budget-based outward
expansion. Results sorted by relevance score.
2026-04-16 12:11:53 +03:00
Yiorgis Gozadinos
364b1bc509
add document_items table for fast context expansion 2026-04-16 12:11:53 +03:00
Yiorgis Gozadinos
661e0d34d8
Pin docling-core to avoid surprises with version bumps 2026-04-16 12:11:53 +03:00
Yiorgis Gozadinos
3bb10a5d22
Merge pull request #345 from ggozad/chore/bump-haiku-skills
Adapt to new haiku.skills
2026-04-16 12:07:18 +03:00
Yiorgis Gozadinos
e16c819e83
Adapt to new haiku.skills 2026-04-16 11:50:55 +03:00
Yiorgis Gozadinos
73d3807b7a
Merge pull request #343 from ggozad/chore/agui-update
Update ag-ui protocol
2026-04-14 14:00:42 +03:00
Yiorgis Gozadinos
0d6df472af
bump ag-ui protocol deps, fix duplicate React key in chat 2026-04-14 12:50:25 +03:00
Yiorgis Gozadinos
dcde86a5f1
vb 2026-04-09 15:33:19 +03:00
Yiorgis Gozadinos
0a4cd31168
Merge pull request #341 from ggozad/fix/skill-document-filtering
Fix: apply document filtering to list_documents & analyze tool
2026-04-09 15:30:51 +03:00
Yiorgis Gozadinos
a51b922be3
fix skill analyze tool ignores state.document_filter 2026-04-09 15:29:20 +03:00
Yiorgis Gozadinos
99af4fe11d
fix skill list_documents tool ignores state.document_filter 2026-04-09 15:29:20 +03:00
Yiorgis Gozadinos
4c1eec4ae7
Merge pull request #335 from ggozad/feat/s3-object-store
add S3/object storage support for LanceDB connections
2026-04-09 15:27:18 +03:00
Yiorgis Gozadinos
a118ee7aaa
Cleanup 2026-04-08 14:19:33 +03:00
Yiorgis Gozadinos
367accb814
Replace MinIO with SeaweedFS for S3 integration tests 2026-04-08 14:19:32 +03:00
Yiorgis Gozadinos
5e7a4ebae5
Update docs 2026-04-08 14:19:32 +03:00
Yiorgis Gozadinos
98641c24e4
S3 storage tests 2026-04-08 14:19:10 +03:00
Yiorgis Gozadinos
8e876ef28d
For remote stores, detect new db by checking if tables exist 2026-04-08 14:19:10 +03:00
Yiorgis Gozadinos
2522d46304
support remote storage in skill generator 2026-04-08 14:19:10 +03:00
Yiorgis Gozadinos
343bfd7199
guard app/UI filesystem checks for remote storage 2026-04-08 14:19:09 +03:00
Yiorgis Gozadinos
110accb8e7
Add ConnectionMode enum (LOCAL/CLOUD/OBJECT_STORAGE) and connect_lancedb() utility to support S3, GCS, Azure, and HDFS backends via storage_options. 2026-04-08 14:19:09 +03:00
Yiorgis Gozadinos
23d2aee955
Merge pull request #338 from ggozad/feat/zstd-image-separation
Separate page images into dedicated column and migrate to zstd compression
2026-04-08 14:16:12 +03:00
Yiorgis Gozadinos
d7d0090a92
cl 2026-04-08 14:08:00 +03:00
Yiorgis Gozadinos
900c10972b
Update docs 2026-04-08 14:04:33 +03:00
Yiorgis Gozadinos
3fe953eccb
Minor fixes 2026-04-08 14:04:32 +03:00
Yiorgis Gozadinos
9c5bef163e
Use zstandard get_frame_parameters for max_output_size 2026-04-08 14:04:32 +03:00
Yiorgis Gozadinos
62a436dd9d
vb 2026-04-08 14:04:32 +03:00
Yiorgis Gozadinos
89e2a303e6
migration to split pages and re-compress with zstd 2026-04-08 14:04:32 +03:00
Yiorgis Gozadinos
53445b8722
Add docling_pages column to DocumentRecord/Document for separate page image storage. 2026-04-08 14:04:32 +03:00
Yiorgis Gozadinos
b614ef19e1
switch compression from gzip to zstd, use stdlib for python >= 3.14 2026-04-08 14:04:32 +03:00
Yiorgis Gozadinos
f10d6b9960
Merge pull request #339 from ggozad/fix/generated-skills-preamble
fix: apply config.prompts.domain_preamble in generated skills
2026-04-08 14:03:24 +03:00
Yiorgis Gozadinos
f8db7fccb6
fix: apply config.prompts.domain_preamble in generated skills 2026-04-08 13:54:53 +03:00
Yiorgis Gozadinos
3d30840e9d
vb 2026-04-07 16:36:27 +03:00
Yiorgis Gozadinos
506fa050f6
Merge pull request #337 from ggozad/chore/search-performance
Performance improvements for large documents
2026-04-07 16:27:24 +03:00
Yiorgis Gozadinos
8a24606784
Strip page images from DoclingDocument before validation, unless we use visualize_chunk() 2026-04-07 12:54:23 +03:00
Yiorgis Gozadinos
32258d4e2a
Add batching to embeddings for huge documents 2026-04-07 12:06:28 +03:00
Yiorgis Gozadinos
68c3fa0f79
Add order to SearchResult, add ChunkRepository.get_chunks_in_range(), to use them in _expand_with_chunks to fetch only nearby chunks 2026-04-07 12:04:05 +03:00
Yiorgis Gozadinos
6fdb15e3b2
Add DocumentRepository.get_docling_data() for lazy loading docling in expand_context 2026-04-07 11:19:04 +03:00
Yiorgis Gozadinos
52712dbdb2
Use .select() projection to fetch only id/uri/title/metadata in search 2026-04-07 11:04:40 +03:00
Yiorgis Gozadinos
f2998ad0b5
add visualize_chunk test for multi-page chunks 2026-04-03 20:20:42 +03:00
Yiorgis Gozadinos
e286fc2053
Merge pull request #328 from ggozad/chore/deps-update
Update core dependencies
2026-04-03 11:44:29 +03:00
Yiorgis Gozadinos
e4dc60c4f1
Update core dependencies 2026-04-03 10:14:27 +03:00
Yiorgis Gozadinos
5c4164d224
vb 2026-04-01 13:32:56 +03:00
Yiorgis Gozadinos
71922c7cd4
Merge pull request #333 from ggozad/fix/skills-domain-preamble
Propagate domain_preamble to skill instructions and main agent preamble
2026-04-01 13:23:51 +03:00
Yiorgis Gozadinos
eb5db78026
clarify domain_preamble purpose 2026-04-01 13:13:52 +03:00
Yiorgis Gozadinos
c4c90b3930
Propagate domain_preamble to skill instructions and main agent preamble 2026-04-01 13:13:36 +03:00
Yiorgis Gozadinos
fa92151311
Merge pull request #332 from ggozad/fix/citation-uuid-leaking
Replace UUIDs with readable identifiers in citation text
2026-04-01 12:24:32 +03:00
Yiorgis Gozadinos
15d94bfe7f
replace UUIDs with readable identifiers in citation text 2026-04-01 11:44:17 +03:00
Yiorgis Gozadinos
127535bc56
vb 2026-03-28 10:05:50 +02:00
Yiorgis Gozadinos
55a429ec28
Merge pull request #331 from tseaver/fix-329-330-reconfig-and-extras
fix: include 'db_path'/'config' in 'extras'
2026-03-28 10:03:43 +02:00
Yiorgis Gozadinos
ef6c69e9a8
Fix typos 2026-03-28 10:01:46 +02:00
Tres Seaver
c2745c987b
chore: linting 2026-03-27 14:23:55 -04:00
Tres Seaver
ed69bf4262
fix: include 'db_path'/'config' in 'extras'
fix: 'skills.rlm.create_skill' includes extras

Closes #329.
Closes #330.
2026-03-27 14:20:31 -04:00
Yiorgis Gozadinos
13a71e34a7
vb 2026-03-27 16:39:22 +02:00
Yiorgis Gozadinos
5adb7866c3
Merge pull request #327 from ggozad/feat/skill-extras
Use haiku.skills extras for passing utility functions
2026-03-27 11:58:51 +02:00
Yiorgis Gozadinos
61a38fc0a9
Coverage for skill extras 2026-03-27 11:54:53 +02:00
Yiorgis Gozadinos
f4dd2dafc3
Use haiku.skills extras for passing utility functions 2026-03-27 11:31:10 +02:00
Yiorgis Gozadinos
adcd3bcb92
Merge pull request #325 from ggozad/fix/inspect
Fix broken textual detection in chat & inspect
2026-03-26 17:51:20 +02:00
Yiorgis Gozadinos
5eb250af05
Fix broken textual detection in chat & inspect 2026-03-26 17:42:38 +02:00
Yiorgis Gozadinos
f46b8195ed
vb 2026-03-26 14:15:09 +02:00
Yiorgis Gozadinos
ab80b7366f
Merge pull request #323 from ggozad/fix/docling-serve-local-parity
Fix docling-serve silent failure detection and local chunker table header parity
2026-03-26 14:10:53 +02:00
Yiorgis Gozadinos
a07a64a31f
enable repeat_table_header for self-contained table chunks and docling-serve parity 2026-03-26 14:02:12 +02:00
Yiorgis Gozadinos
a3a1ad9811
detect per-document failure status in docling-serve chunker 2026-03-26 14:02:11 +02:00
Yiorgis Gozadinos
2e0564d7ea
Merge pull request #322 from ggozad/feat/reconfigure-skills
Add post-discovery reconfiguration for generated skills
2026-03-26 13:58:13 +02:00
Yiorgis Gozadinos
f6231c0b19
Require haiku.skills>=0.11.0 2026-03-26 13:48:27 +02:00
Yiorgis Gozadinos
1c9035ebb5
Add optional db_path and config params to generated skill create_skill() 2026-03-26 13:41:58 +02:00
Yiorgis Gozadinos
72cd94322c
Merge pull request #321 from ggozad/fix/generated-skills-visualization
add visualize_chunk to generated skills and built-in RAG skill
2026-03-26 11:48:14 +02:00
Yiorgis Gozadinos
9d986421a0
add visualize_chunk to generated skills and built-in RAG skill 2026-03-26 11:41:22 +02:00
Yiorgis Gozadinos
174ece94cc
Merge pull request #320 from ggozad/fix/proper-skill-package-setup
include SKILL.md, assets and README in generated skill package wheels.
2026-03-26 10:57:51 +02:00
Yiorgis Gozadinos
ffc70be7b0
include SKILL.md and assets in generated skill package wheels. Add README to generated skills 2026-03-26 10:49:57 +02:00
Yiorgis Gozadinos
f5014f39bf
vb 2026-03-24 17:46:51 +02:00
Yiorgis Gozadinos
757b58d5db
Merge pull request #316 from ggozad/feature/create-skill
Add create-skill command for generating standalone skill packages
2026-03-24 17:46:03 +02:00
Yiorgis Gozadinos
75b9a172cb
Minor fixes 2026-03-24 17:45:46 +02:00
Yiorgis Gozadinos
705ce80b4f
Allow all spec-compliant names 2026-03-24 17:27:56 +02:00
Yiorgis Gozadinos
7d50edea86
Fix validate_metadata layering and type annotations in _tools.py 2026-03-24 17:09:14 +02:00
Yiorgis Gozadinos
7309d13317
Docs 2026-03-24 16:06:46 +02:00
Yiorgis Gozadinos
ce95234cc8
CLI for create-skill; 2026-03-24 15:44:46 +02:00
Yiorgis Gozadinos
26da4a0624
Skill generator core 2026-03-24 15:44:24 +02:00
Yiorgis Gozadinos
a778ea68a8
Extract reusable tool functions from skill implementations 2026-03-24 14:46:28 +02:00
Yiorgis Gozadinos
8a5188aa01
vb 2026-03-24 14:23:29 +02:00
Yiorgis Gozadinos
92b0408800
Merge pull request #315 from ggozad/fix/fix-tests-after-updates
Remove structured output support
2026-03-24 14:18:02 +02:00
Yiorgis Gozadinos
aa9fdf051d
Remove support for structured output 2026-03-24 14:11:43 +02:00
Yiorgis Gozadinos
83ea6347b6
Fix CI failures: update tests for pydantic-ai 1.70.0 and FastMCP API changes 2026-03-24 14:02:03 +02:00
Yiorgis Gozadinos
5474465610
Fix mcp test 2026-03-24 13:36:14 +02:00
Yiorgis Gozadinos
8f5b6c299d
Conform to ActivitySnapshot update using the same message_id 2026-03-24 13:30:42 +02:00
Yiorgis Gozadinos
ba83779bb2
Update skills 2026-03-24 13:30:23 +02:00
Yiorgis Gozadinos
88b6eef87e
Merge pull request #314 from ggozad/chore/docling-document-update
Update docling, docling-core  to version 1.10.0 of DoclingDocument
2026-03-24 13:06:24 +02:00
Yiorgis Gozadinos
47da5bfb6e
Update supported file extensions 2026-03-23 18:03:47 +02:00
Yiorgis Gozadinos
ce320ca772
Update docling, docling-core and move to version 1.10.0 of DoclingDocument. 2026-03-23 17:34:06 +02:00
Yiorgis Gozadinos
7e9e7148f4
Merge pull request #313 from ggozad/feat/specify-judge-reflect-models
Configurable judge and reflect models for evaluations
2026-03-19 13:49:19 +02:00
Yiorgis Gozadinos
fd327996b8
Configurable judge and reflect models for evaluations 2026-03-18 17:14:11 +02:00
Yiorgis Gozadinos
9243f56e1c
vb 2026-03-16 14:30:08 +02:00
Yiorgis Gozadinos
705950e345
Merge pull request #312 from ggozad/feat/support-puml
Support for plantuml diagrams
2026-03-16 11:09:48 +02:00
Yiorgis Gozadinos
d4e34ab79f
Support for plantuml diagrams 2026-03-13 17:18:19 +02:00
Yiorgis Gozadinos
f961f00f2c
vb 2026-03-13 13:24:31 +02:00
Yiorgis Gozadinos
5851c2482c
Merge pull request #311 from ggozad/fix/skills-use-activity-events
handle skill sub-agent ActivitySnapshotEvent in TUI and web frontend
2026-03-13 13:21:32 +02:00
Yiorgis Gozadinos
a90c8ca822
Fix version bump script to also update the /app 2026-03-13 13:13:15 +02:00
Yiorgis Gozadinos
6548f1f0aa
Update haiku.skills to 0.8.0 2026-03-13 13:12:55 +02:00
Yiorgis Gozadinos
a0025e9203
Fix ty 2026-03-13 12:51:35 +02:00
Yiorgis Gozadinos
1782e01922
chore: simplify frontend CSS and unify message rendering logic
Consolidate 4 CopilotKit max-width override rules into 1 scoped to
.chat-container. Remove dead pre-1.54.0 selectors and redundant
browser-default styles. Unify the two render paths in
MessageViewWithCitations into a single loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:45:58 +02:00
Yiorgis Gozadinos
9e29a7e849
handle ActivitySnapshotEvent in web frontend and fix CopilotKit 1.54.0 styling 2026-03-13 12:39:55 +02:00
Yiorgis Gozadinos
1d108b42ec
handle ActivitySnapshotEvent for skill sub-agent tool calls 2026-03-13 11:37:24 +02:00
Yiorgis Gozadinos
b09b45e8fa
Merge pull request #308 from ggozad/feat/rlm-monty-update
RLM monty update.
2026-03-13 11:23:39 +02:00
Yiorgis Gozadinos
91d7197932
Merge pull request #310 from tseaver/tseaver-chore-309-suppress-lancedb-deprecation-warning
chore: suppress deprecation wwarning from 'lancedb'
2026-03-13 11:01:50 +02:00
Tres Seaver
53481a7d7a
chore: suppress deprecation wwarning from 'lancedb' 2026-03-12 17:11:58 -04:00
Yiorgis Gozadinos
e8e02f341d
Update docs 2026-03-12 17:55:17 +02:00
Yiorgis Gozadinos
2c5128e5ba
Re-record rlm tests 2026-03-12 17:31:26 +02:00
Yiorgis Gozadinos
713e9087cc
Bump pydantic-monty to 0.0.8, remove regex external functions, update skill 2026-03-12 17:24:01 +02:00
Yiorgis Gozadinos
7696b3f98f
vb 2026-03-12 14:46:13 +02:00
Yiorgis Gozadinos
1bee8723ae
Merge pull request #307 from ggozad/feat/evals-gepa
Add GEPA prompt optimization for QA evaluations
2026-03-12 14:36:43 +02:00
Yiorgis Gozadinos
8c7eec8a8d
Strengthen cited chunk structured output description 2026-03-12 14:00:24 +02:00
Yiorgis Gozadinos
7757f96ffe
Merge pull request #305 from ggozad/fix/always-cite-in-ask
Enforce the QA agent to always return citations when answer is based on search results
2026-03-12 13:36:08 +02:00
Yiorgis Gozadinos
6ac5f277d8
Rename iterations to num_candidates 2026-03-12 13:00:51 +02:00
Yiorgis Gozadinos
5b3ad9bae7
Refactor QAPromptAdapter to accept QA agent directly, rewrite mock-heavy tests 2026-03-12 12:35:39 +02:00
Yiorgis Gozadinos
24275d2ef9
Fixes 2026-03-12 12:19:21 +02:00
Yiorgis Gozadinos
c785a54f5f
Respect config.prompts.qa in evaluations benchmark and optimization 2026-03-12 12:05:51 +02:00
Yiorgis Gozadinos
e7f19c4d42
Compactify tuning.md 2026-03-12 12:05:50 +02:00
Yiorgis Gozadinos
6812435ba0
cleanup 2026-03-12 12:05:50 +02:00
Yiorgis Gozadinos
7c399d1ff1
Remove evaluation ceremony from gepa judge 2026-03-12 12:05:50 +02:00
Yiorgis Gozadinos
8bac8b4104
Properly choose training/eval set 2026-03-12 12:05:50 +02:00
Yiorgis Gozadinos
66f88f7872
Test evaluations 2026-03-12 12:05:50 +02:00
Yiorgis Gozadinos
10abfbe3d8
docs 2026-03-12 12:05:49 +02:00
Yiorgis Gozadinos
45f7ac8d1b
run_optimization should be sync 2026-03-12 12:05:25 +02:00
Yiorgis Gozadinos
8f39ccf29c
Fix precommit to use uv installed ruff 2026-03-12 12:05:25 +02:00
Yiorgis Gozadinos
adc83e5f81
Tests for gepa 2026-03-12 12:05:25 +02:00
Yiorgis Gozadinos
60b1d3c013
Add GEPA prompt optimization for QA evaluations 2026-03-12 12:05:25 +02:00
Yiorgis Gozadinos
90fe624fa7
improve QA citation reliability via prompt and ID normalization 2026-03-12 11:50:46 +02:00
Yiorgis Gozadinos
e31b692124
Merge pull request #306 from ggozad/fix/read-only-inits-on-empty-folder
Prevent read-only mode from creating tables in empty directories
2026-03-12 11:50:13 +02:00
Yiorgis Gozadinos
b8c377da1e
Prevent read-only mode from creating tables in empty directories 2026-03-12 11:42:04 +02:00
Yiorgis Gozadinos
8d6617d63f
vb 2026-03-11 17:08:22 +02:00
Yiorgis Gozadinos
f07a4b70ed
Merge pull request #304 from ggozad/chore/speed
Cap QA search iterations and tune defaults for faster responses
2026-03-11 13:49:48 +02:00
Yiorgis Gozadinos
45e94ec67a
Set search limit to 10 2026-03-11 12:26:41 +02:00
Yiorgis Gozadinos
bf8eec68e3
Use dict-keyed counter for concurrent-safe search cap 2026-03-11 12:19:19 +02:00
Yiorgis Gozadinos
e2749ad2a6
Cap QA agent search iterations to reduce response time 2026-03-11 12:13:43 +02:00
Yiorgis Gozadinos
f366aea905
vb 2026-03-06 11:12:54 +02:00
Yiorgis Gozadinos
1382d0aebe
Merge pull request #303 from ggozad/feat/param-tune
Set appropriate temperature and max_tokens defaults
2026-03-05 15:56:50 +02:00
Yiorgis Gozadinos
5a0430857f
Enable thinking by default for QA agent 2026-03-05 15:48:08 +02:00
Yiorgis Gozadinos
40ff54eac0
Set params for evaluations judge 2026-03-05 13:59:20 +02:00
Yiorgis Gozadinos
3cc2d5e19e
Set appropriate temperature and max_tokens defaults 2026-03-05 13:23:50 +02:00
Yiorgis Gozadinos
75642c8074
Merge pull request #302 from ggozad/chore/deps-update
Dependencies update
2026-03-05 13:12:18 +02:00
Yiorgis Gozadinos
d460d553f3
Set repeat_table_header=False to match docling serve 2026-03-05 13:02:18 +02:00
Yiorgis Gozadinos
30ebd6694b
Update dependencies 2026-03-05 13:01:53 +02:00
Yiorgis Gozadinos
24de604678
Merge pull request #301 from ggozad/chore/test-cleanup
Test suite cleanup & parallelization
2026-03-05 12:31:14 +02:00
Yiorgis Gozadinos
58959b9249
enable parallel test run 2026-03-05 12:23:22 +02:00
Yiorgis Gozadinos
5e99b84308
Prevent HuggingFace network access during tests 2026-03-05 12:10:07 +02:00
Yiorgis Gozadinos
c7b03eb9ab
Strengthen weak assertions in context enhancement and converter tests
- Add doc_item_refs and headings assertions to test_expand_context_docling_merges_metadata
- Add page count and markdown content assertions to test_convert_pdf_with_ocr_engine

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:44:51 +02:00
Yiorgis Gozadinos
3c7d746d57
Strengthen search tests, remove redundant title test, relocate primary label test
- Strengthen test_search_returns_search_result with chunk_id, document_id, and
  non-empty labels assertions
- Remove test_chunks_include_document_title (subsumed by strengthened test) and
  its cassette
- Rename test_chunks_include_document_info to
  test_search_chunk_includes_document_provenance; add document_title is None
  assertion for untitled documents
- Move test_search_result_get_primary_label from test_context_enhancement.py to
  test_search.py; rename, remove unnecessary @pytest.mark.vcr() decorator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:39:45 +02:00
Yiorgis Gozadinos
f73d4f4da9
Remove stale cassette, dead vcr_cassette_dir fixtures, and unnecessary VCR decorator 2026-03-05 11:36:22 +02:00
Yiorgis Gozadinos
878423ea2e
vb 2026-03-04 15:02:34 +02:00
Yiorgis Gozadinos
d08d32dd5f
Merge pull request #300 from ggozad/feat/support-native-structured-output
Automatically set structured output mode (tool vs native)
2026-03-04 15:01:47 +02:00
Yiorgis Gozadinos
28e90e5a87
Auto-detect structured output mode from model profile. Remove the structured_output config field from ModelConfig. 2026-03-04 14:51:09 +02:00
Yiorgis Gozadinos
fdd7c21757
Add configurable structured output mode (tool vs native) 2026-03-04 13:35:32 +02:00
Yiorgis Gozadinos
2358c14028
Merge pull request #299 from ggozad/feat/skill-meta
Expose skill state metadata as module-level API
2026-03-04 13:17:52 +02:00
Yiorgis Gozadinos
3f02abbc70
Expose skill state metadata as module-level API 2026-03-04 11:38:00 +02:00
Yiorgis Gozadinos
8a859fbe3c
vb 2026-03-03 17:25:53 +02:00
Yiorgis Gozadinos
1c4beb9970
Merge pull request #295 from ggozad/fix/tool-calls
Use ToolOutput for structured output
2026-03-03 17:19:32 +02:00
Yiorgis Gozadinos
c9f63b9ab6
Re-record RLM vcrs 2026-03-03 16:59:35 +02:00
Yiorgis Gozadinos
034ee27daf
Use ToolOutput for structured output. 2026-03-03 16:14:45 +02:00
Yiorgis Gozadinos
1eca0fc308
Fix AGENT_PREAMBLE 2026-03-03 15:06:58 +02:00
Yiorgis Gozadinos
a92f5fe062
Merge pull request #294 from ggozad/feat/search-citations
Search use in SKILL produces citations.
2026-03-03 15:06:06 +02:00
Yiorgis Gozadinos
9a4cda1d65
Reinforce ask prompt in skill 2026-03-03 14:04:53 +02:00
Yiorgis Gozadinos
ee232ba657
Merge pull request #293 from ggozad/feat/real-time-agui-with-skills
Tool calls within skills are now streamed as real-time AG-UI events
2026-03-03 13:25:20 +02:00
Yiorgis Gozadinos
2566bf1a61
Adapt to haiku.skills streaming sub-agent ag-ui events 2026-03-03 11:16:44 +02:00
Yiorgis Gozadinos
a9583abd76
Merge pull request #292 from ggozad/fix/fix-filter
Remove filter parameter from search and list_documents tools
2026-03-03 11:12:22 +02:00
Yiorgis Gozadinos
1a3ea64aa6
Remove filter parameter from search and list_documents tools 2026-03-03 11:01:58 +02:00
Yiorgis Gozadinos
70273ecf5b
Remove filter from the search tool 2026-03-02 17:15:38 +02:00
Yiorgis Gozadinos
99ca3605e7
vb 2026-02-28 14:11:25 +02:00
Yiorgis Gozadinos
c69ce0caa2
Merge pull request #291 from ggozad/fix/fix-default-chat-model
Fix chat model, default to config.qa.model
2026-02-28 14:10:09 +02:00
Yiorgis Gozadinos
52ef983e75
Fix default chat model, default to config.qa.model 2026-02-28 14:00:40 +02:00
Yiorgis Gozadinos
9acb04d915
Merge pull request #290 from ggozad/fix/haiku-skill-prompt-fix
Adapt skill usage to haiku.skills prompt builder.
2026-02-28 13:56:21 +02:00
Yiorgis Gozadinos
2eea9b3bc9
Add changelog entry for haiku.skills 0.5.1 compatibility fix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:42:42 +02:00
Yiorgis Gozadinos
27a71c7e01
Adapt skill usage to haiku.skills prompt builder. Closes #289 2026-02-28 13:34:54 +02:00
Yiorgis Gozadinos
50116bb49a
vb 2026-02-26 11:21:46 +02:00
Yiorgis Gozadinos
3111e54718
Merge pull request #288 from ggozad/feat/title-generation
Automatic title generation for documents
2026-02-26 11:21:05 +02:00
Yiorgis Gozadinos
e2bcbd8eea
Update docs, and download-models for title generation 2026-02-26 11:05:48 +02:00
Yiorgis Gozadinos
b5cb2852c4
Simplify _resolve_title() 2026-02-26 10:56:28 +02:00
Yiorgis Gozadinos
d3a1031ec4
Update docs 2026-02-26 09:09:42 +02:00
Yiorgis Gozadinos
41f694d377
Better exception handling 2026-02-26 08:55:53 +02:00
Yiorgis Gozadinos
49f9843db3
add generate_title(), rebuild --title-only, and --title on add 2026-02-26 08:40:07 +02:00
Yiorgis Gozadinos
ae3469b282
add automatic title generation for documents 2026-02-25 17:12:24 +02:00
Yiorgis Gozadinos
2acb0292e4
vb 2026-02-24 13:30:01 +02:00
Yiorgis Gozadinos
0ed1e5289c
Merge pull request #280 from ggozad/feat/monty
Replace Docker sandbox with pydantic-monty
2026-02-24 13:28:04 +02:00
Yiorgis Gozadinos
6759a2e0a5
Give regex to monty as externals 2026-02-24 13:12:56 +02:00
Yiorgis Gozadinos
d134276819
Update docs 2026-02-24 12:26:04 +02:00
Yiorgis Gozadinos
1ee9747551
Catch MontyRuntimeError from Monty() constructor 2026-02-24 12:11:20 +02:00
Yiorgis Gozadinos
380fc6c930
Consolidate prompt 2026-02-24 12:09:11 +02:00
Yiorgis Gozadinos
459fdfca71
Add get_docling_document external function to sandbox 2026-02-24 11:51:43 +02:00
Yiorgis Gozadinos
d3c6322481
Simplify sandbox with run_monty_async, replace manual ThreadPoolExecutor start/resume loop 2026-02-24 11:35:39 +02:00
Yiorgis Gozadinos
b9fa1a061f
Update monty 2026-02-24 09:55:45 +02:00
Yiorgis Gozadinos
454f12b319
Update coverage 2026-02-24 09:55:45 +02:00
Yiorgis Gozadinos
017712c9b1
Update prompts & docs. Remove docker sandbox workflow 2026-02-24 09:55:45 +02:00
Yiorgis Gozadinos
c61ded1271
Replace Docker sandbox with pydantic-monty 2026-02-24 09:55:40 +02:00
Yiorgis Gozadinos
2cafb5c8cc
vb 2026-02-20 19:41:17 +02:00
Yiorgis Gozadinos
c529ecc940
Merge pull request #285 from ggozad/fix/info-readonly
Open database in read-only mode for info and history commands.
2026-02-20 19:40:41 +02:00
Yiorgis Gozadinos
b23f99af06
Fix history too 2026-02-20 19:15:28 +02:00
Yiorgis Gozadinos
388e94ae80
info() should always be read-only 2026-02-20 19:13:45 +02:00
Yiorgis Gozadinos
e186e13cd2
vb 2026-02-20 19:01:16 +02:00
Yiorgis Gozadinos
82a5ce8519
Merge pull request #282 from ggozad/feat/haiku-skills
Integrate haiku.skills: RAG & RLM skills, simplified toolsets, rebuilt TUI and web app
2026-02-20 19:00:04 +02:00
Yiorgis Gozadinos
cb5935a6fa
Show in tui and nextjs app what is being requested from a skill 2026-02-20 18:14:44 +02:00
Yiorgis Gozadinos
492f5cf1e4
Avoid mutable defaults 2026-02-20 17:59:35 +02:00
Yiorgis Gozadinos
d1fdbdeb21
Refactor MCP to use proper models, test 2026-02-20 17:39:02 +02:00
Yiorgis Gozadinos
69083c8cb4
Fix stale docs 2026-02-20 16:59:58 +02:00
Yiorgis Gozadinos
339ff15018
Update rlm SKILL.md 2026-02-20 16:45:41 +02:00
Yiorgis Gozadinos
5dd8a82d1f
Exclude TUI apps from coverage 2026-02-20 16:37:16 +02:00
Yiorgis Gozadinos
11534bf686
Skills documentation 2026-02-20 16:37:15 +02:00
Yiorgis Gozadinos
a1c09e1a47
Break apart RLM analysis as a separate skill 2026-02-20 16:37:15 +02:00
Yiorgis Gozadinos
37f28ea8de
Clean up stale references and dead code 2026-02-20 16:37:15 +02:00
Yiorgis Gozadinos
cb41f84615
Add tests 2026-02-20 16:36:51 +02:00
Yiorgis Gozadinos
c55c68a96d
Fix frontend build: satisfy CopilotChatView messageView slot type. Closes #284 2026-02-20 16:36:51 +02:00
Yiorgis Gozadinos
eb9436eb2a
Remove unused conversational output mode from research graph 2026-02-20 16:36:50 +02:00
Yiorgis Gozadinos
e6310fc484
Fix citations typing 2026-02-20 16:36:50 +02:00
Yiorgis Gozadinos
c567842420
Rewire document filter 2026-02-20 16:36:50 +02:00
Yiorgis Gozadinos
972fb18d30
Wire AG-UI state round-tripping in app backend 2026-02-20 16:36:50 +02:00
Yiorgis Gozadinos
696f8598eb
Remove get_session_context, handle it automatically in ask() 2026-02-20 16:36:49 +02:00
Yiorgis Gozadinos
bf724ad1ef
Update docs & examples 2026-02-20 16:36:49 +02:00
Yiorgis Gozadinos
09e6324add
Adapt frontend 2026-02-20 16:36:49 +02:00
Yiorgis Gozadinos
a7850e2210
Assign stable index to citations. Use a strong prompt preamble for our app agents 2026-02-20 16:36:48 +02:00
Yiorgis Gozadinos
643ed20d6f
Rewrite chat TUI and app backend with haiku.skills 2026-02-20 16:36:48 +02:00
Yiorgis Gozadinos
8176103eb0
Remove agents/chat/ 2026-02-20 16:36:48 +02:00
Yiorgis Gozadinos
ed89ff0fc9
Simplify tools/ — remove AG-UI state machinery, keep core toolsets 2026-02-20 16:36:48 +02:00
Yiorgis Gozadinos
524647c501
Add unified RAG skill with session context and reuse existing tools; 2026-02-20 16:36:47 +02:00
Yiorgis Gozadinos
4855051936
Add research() to client and add haiku.skills dependency, remove --deep flag and simplify app 2026-02-20 16:36:47 +02:00
Yiorgis Gozadinos
0c270ba42f
vb 2026-02-19 14:28:13 +02:00
Yiorgis Gozadinos
1cf448538a
Merge pull request #283 from ggozad/fix/download-models-ollama-not-running
download-models, show actionable error message when Ollama is not running
2026-02-19 14:26:53 +02:00
Yiorgis Gozadinos
1e8d29d938
test-cover download models 2026-02-19 14:14:07 +02:00
Yiorgis Gozadinos
08a4252c33
download-models, show actionable error message when Ollama is not running 2026-02-19 14:06:53 +02:00
Yiorgis Gozadinos
356e9f040d
Merge pull request #281 from ggozad/fix/cachetools
Add cachetools dependency
2026-02-19 14:05:24 +02:00
Yiorgis Gozadinos
df5c8bcfaf
Add cachetools dependency 2026-02-19 13:04:07 +02:00
Yiorgis Gozadinos
d15c31b550
Fix changelog and bump version script 2026-02-19 12:32:11 +02:00
Yiorgis Gozadinos
c62fe5fce8
vb 2026-02-17 13:56:43 +02:00
Yiorgis Gozadinos
8e67d0212e
Merge pull request #279 from ggozad/chore/dep-update
Update dependencies
2026-02-17 13:54:37 +02:00
Yiorgis Gozadinos
0919d5babf
Merge pull request #278 from ggozad/fix/ask-tool-statedelta
ask tool now emits StateDeltaEvent with client-aware baseline
2026-02-17 13:53:01 +02:00
Yiorgis Gozadinos
8abcb5ce32
Update react dependencies 2026-02-17 13:34:28 +02:00
Yiorgis Gozadinos
a870052ee9
Fix coverage 2026-02-17 13:22:23 +02:00
Yiorgis Gozadinos
2d5fbd06f2
Update dependencies 2026-02-17 13:12:08 +02:00
Yiorgis Gozadinos
f4735667df
emit StateDeltaEvent with client-aware baseline 2026-02-17 12:24:09 +02:00
Yiorgis Gozadinos
9685bd533b
Expand ~ in paths 2026-02-16 17:21:35 +02:00
Yiorgis Gozadinos
53f6cd3579
vb 2026-02-16 13:12:06 +02:00
Yiorgis Gozadinos
b8d9903e1b
Merge pull request #273 from ggozad/feat/reusable-toolsets
Reusable toolsets for composable agent creation
2026-02-16 13:08:58 +02:00
Yiorgis Gozadinos
8c76897af2
Update tui and app to use the toolkit 2026-02-16 12:54:44 +02:00
Yiorgis Gozadinos
7ff2123806
Introduce toolkit to reduce toolset creation ceremony 2026-02-16 11:51:02 +02:00
Yiorgis Gozadinos
72c8f6e1b1
Additional tests 2026-02-13 18:08:46 +02:00
Yiorgis Gozadinos
84c48e1541
Add preamble parameter to chat agent and citations_history to ChatSessionState 2026-02-13 16:59:21 +02:00
Yiorgis Gozadinos
5767158737
Paddings and ui fixes 2026-02-13 16:42:03 +02:00
Yiorgis Gozadinos
969c64d106
Add citations_history to SessionState for unified citation rendering between search and ask 2026-02-13 16:21:50 +02:00
Yiorgis Gozadinos
c0ed93da2d
Expand tests 2026-02-13 14:34:24 +02:00
Yiorgis Gozadinos
f5c562db9b
Fix double summarization, cleanup 2026-02-13 12:16:23 +02:00
Yiorgis Gozadinos
b64d9721bb
Mark with pragma no cover areas that are just wiring. Consolidate tests and bring coverage to 100% 2026-02-13 11:56:37 +02:00
Yiorgis Gozadinos
d9acdfba9d
prompt builder 2026-02-13 11:21:09 +02:00
Yiorgis Gozadinos
4247ecbedf
Agentic example with and without agui 2026-02-13 10:40:36 +02:00
Yiorgis Gozadinos
5f6d9e9812
handle state_key duplication between deps and ToolContext 2026-02-13 10:23:12 +02:00
Yiorgis Gozadinos
2496b02a3b
Docs 2026-02-12 17:38:41 +02:00
Yiorgis Gozadinos
a5b8e3be7e
Add AgentDeps and prepare_context for custom agent DX 2026-02-12 17:37:37 +02:00
Yiorgis Gozadinos
8f180f707b
Decouple QA toolset from chat agent, add ToolContext state methods 2026-02-12 17:27:47 +02:00
Yiorgis Gozadinos
0c6d73409d
Move SessionContext from agents/chat/state.py to tools/session.py, change QASessionState.session_context to use it 2026-02-12 17:13:01 +02:00
Yiorgis Gozadinos
05ebd781d4
Move client and tool_context from toolset factories to RunContext.deps 2026-02-12 15:50:46 +02:00
Yiorgis Gozadinos
467bcff94d
Add frontend session management with localStorage persistence, deduplicate shared code 2026-02-12 15:16:47 +02:00
Yiorgis Gozadinos
abfee49d13
cl 2026-02-11 17:56:01 +02:00
Yiorgis Gozadinos
51f1d9cf13
Issue a full state snapshot from ask() tool to preserve server side background context 2026-02-11 17:49:09 +02:00
Yiorgis Gozadinos
58c2bd49ea
Clean up server vs client session setting priorities 2026-02-11 16:54:00 +02:00
Yiorgis Gozadinos
0434cd068b
Simplify TUI app 2026-02-11 15:10:50 +02:00
Yiorgis Gozadinos
9e63ff1ff7
Remove session_id from state layer, remove module-level caches 2026-02-11 14:55:53 +02:00
Yiorgis Gozadinos
d88f2f003a
Remove incoming_* fields, simplify delta computation 2026-02-11 14:31:37 +02:00
Yiorgis Gozadinos
d67df09cce
Introduce ToolContextCache 2026-02-11 14:18:26 +02:00
Yiorgis Gozadinos
5fc4ddff69
Docs 2026-02-11 12:55:09 +02:00
Yiorgis Gozadinos
c916356a42
Clean up toolset exports, simplify update_session_context(), fix health check 2026-02-11 12:46:50 +02:00
Yiorgis Gozadinos
87bcb7a5dc
Extract run_qa_core() from ask closure in QA toolset. Remove redundant tests and cassettes for OpenAI and Anthropic QA tools, as they are now tested in the core function tests. 2026-02-11 11:38:34 +02:00
Yiorgis Gozadinos
2cc4a8a88f
Fix VCR cassette 2026-02-10 18:01:13 +02:00
Yiorgis Gozadinos
f85aaefed7
Extract shared snapshot helpers 2026-02-10 17:34:30 +02:00
Yiorgis Gozadinos
a592a04031
Removed unused accumulator states 2026-02-10 16:15:30 +02:00
Yiorgis Gozadinos
cf4083d2d7
Add multi-turn integration test with initial context and summarization 2026-02-10 14:55:12 +02:00
Yiorgis Gozadinos
ce3c3e7ed5
Add feature-based chat agent & prompt composition 2026-02-10 14:38:14 +02:00
Yiorgis Gozadinos
da47e3e345
Unify get_typed() into get() with optional type parameter
Fix find_document regression
2026-02-10 14:38:14 +02:00
Yiorgis Gozadinos
0dd181356e
Update vcr 2026-02-10 14:38:14 +02:00
Yiorgis Gozadinos
dd7162e72e
Fix get_or_create type annotation, export compute_combined_state_delta 2026-02-10 14:38:13 +02:00
Yiorgis Gozadinos
bd3b26f78b
Extract get_session_filter helper, remove dead code and duplicate tests 2026-02-10 14:38:13 +02:00
Yiorgis Gozadinos
feee458d97
Update agents docs 2026-02-10 14:38:13 +02:00
Yiorgis Gozadinos
df57b0cf43
Remove SearchAgent & friends, consolidate duplicate models 2026-02-10 14:38:13 +02:00
Yiorgis Gozadinos
84b320765a
Fix analysis tool to use docker sandbox 2026-02-10 14:38:13 +02:00
Yiorgis Gozadinos
3ed9cbb7d3
Use the tools inside the chat agent. 2026-02-10 14:38:12 +02:00
Yiorgis Gozadinos
5bcbcc3928
Remove analyze tool from chat agent 2026-02-10 14:38:12 +02:00
Yiorgis Gozadinos
2be3389222
DocumentToolset, QAToolset, AnalysisToolset 2026-02-10 14:38:12 +02:00
Yiorgis Gozadinos
2f229f91e1
Create SearchToolset, refactor QA Agent to use it 2026-02-10 14:38:12 +02:00
Yiorgis Gozadinos
a3863882cd
Introduce tool module & ToolContext to save state 2026-02-10 14:38:12 +02:00
Yiorgis Gozadinos
3544c3177a
Merge pull request #275 from ggozad/fix/tui-session-id
TUI now generates a UUID `session_id` on mount and on chat clear
2026-02-10 13:37:07 +01:00
Yiorgis Gozadinos
a7b79c433d
TUI now generates a UUID session_id on mount and on chat clear 2026-02-10 14:21:22 +02:00
Yiorgis Gozadinos
aaa9b6a32f
vb 2026-02-10 13:29:30 +02:00
Yiorgis Gozadinos
8bae858626
Merge pull request #274 from ggozad/fix/empty-state_id
Fix session_id not persisting across AG-UI requests
2026-02-10 12:26:19 +01:00
Yiorgis Gozadinos
d1a9439500
Fix session_id not persisting across AG-UI requests 2026-02-10 13:14:05 +02:00
Yiorgis Gozadinos
a889883cfc
Update wix benchmarks 2026-02-10 12:49:24 +02:00
Yiorgis Gozadinos
0d5ef6fac4
Merge pull request #272 from ggozad/fix/list-documents-oom
Fix out-of-memory in list_documents by adding column projection
2026-02-06 14:57:04 +01:00
Yiorgis Gozadinos
8c2a101a8a
Fix out-of-memory in list_documents by adding column projection 2026-02-06 14:11:36 +01:00
Yiorgis Gozadinos
17c2136b4e
vb 2026-02-06 12:48:16 +01:00
Yiorgis Gozadinos
3c3b14958d
Merge pull request #271 from ggozad/feat/recursive-llm
Add RLM agent for analytical tasks via sandboxed Python execution
2026-02-06 12:41:05 +01:00
Yiorgis Gozadinos
20414ed959
Additional tests 2026-02-06 12:07:21 +01:00
Yiorgis Gozadinos
720f697a48
Cleanup 2026-02-06 12:07:21 +01:00
Yiorgis Gozadinos
f8ec511250
Return consolidated program from RLM agent instead of execution history 2026-02-06 12:07:20 +01:00
Yiorgis Gozadinos
e882831afb
Fix execute_code prompt 2026-02-06 12:07:20 +01:00
Yiorgis Gozadinos
dc83dde9ab
Make test_client_resolve_document not require embeddings 2026-02-06 12:07:20 +01:00
Yiorgis Gozadinos
a0c4ddbd01
Simplify deps 2026-02-06 12:07:20 +01:00
Yiorgis Gozadinos
122da834d7
Use resolve_document() 2026-02-06 12:07:19 +01:00
Yiorgis Gozadinos
764b62ee20
Fix tests 2026-02-06 12:07:19 +01:00
Yiorgis Gozadinos
b426827fc2
Docker-based sandbox. Reused within a single rlm() call for latency 2026-02-06 12:07:19 +01:00
Yiorgis Gozadinos
4241a4b09e
Security fixes for RLM. Fix type() builtin, AST validation for dict key access, sql injection 2026-02-06 12:07:19 +01:00
Yiorgis Gozadinos
fb6bbca124
Remove analyze tool from chat agent, not yet ready for integration 2026-02-06 12:07:19 +01:00
Yiorgis Gozadinos
9a480cee90
Return program in chat agent analyze; 2026-02-06 12:07:18 +01:00
Yiorgis Gozadinos
ed570633cd
Basic integration of RLM with chat agent 2026-02-06 12:07:18 +01:00
Yiorgis Gozadinos
56cf6ebfb6
Docs 2026-02-06 12:07:18 +01:00
Yiorgis Gozadinos
198a8d5a88
Add pre-loaded documents support and improve RLM test coverage 2026-02-06 12:07:04 +01:00
Yiorgis Gozadinos
fa4d7731d7
Replace ask() with llm(). Add and document the programs written in tests 2026-02-06 12:07:03 +01:00
Yiorgis Gozadinos
5e7832a6ea
Fix REPL for docling document, add test 2026-02-06 12:07:03 +01:00
Yiorgis Gozadinos
5e5fc4a7d9
Integration tests for RLM 2026-02-06 12:07:03 +01:00
Yiorgis Gozadinos
b68b2393e9
Integrate with client, cli, app, mcp 2026-02-06 12:07:03 +01:00
Yiorgis Gozadinos
75de81accf
RLM agent 2026-02-06 12:07:03 +01:00
Yiorgis Gozadinos
ad416cac84
RLM sandbox environment 2026-02-06 12:07:02 +01:00
Yiorgis Gozadinos
e470304277
Merge pull request #270 from ggozad/feat/chunking-ocr-options
Pass OCR options from conversion_options to docling-serve chunker
2026-02-06 12:04:45 +01:00
Yiorgis Gozadinos
b22ac33dde
Pass OCR options from conversion_options to docling-serve chunker 2026-02-03 11:34:46 +02:00
Yiorgis Gozadinos
b6431322cf
Merge pull request #268 from ggozad/chore/fix-flaky-hf-tests
Fix flaky huggingface in tests by caching the tokenizer
2026-01-31 12:24:16 +02:00
Yiorgis Gozadinos
35bed89717
Fix flaky huggingface in tests by caching the tokenizer 2026-01-31 12:07:42 +02:00
Yiorgis Gozadinos
1e22937482
vb 2026-01-31 11:12:38 +02:00
Yiorgis Gozadinos
5cdf079322
Merge pull request #267 from ggozad/feat/research-graph-improvements
Performance improvements for the research graph & chat agents
2026-01-31 11:11:40 +02:00
Yiorgis Gozadinos
ac76ecee86
Remove dead code from iterative research planning refactor 2026-01-31 10:51:34 +02:00
Yiorgis Gozadinos
42f2d0fd0e
docs 2026-01-31 10:13:22 +02:00
Yiorgis Gozadinos
57d5b1bde5
Simplify research planning: remove gather_context, always use planner 2026-01-30 22:32:08 +02:00
Yiorgis Gozadinos
109f770a2a
Refactor research graph to iterative planning approach 2026-01-30 20:46:29 +02:00
Yiorgis Gozadinos
5f6488e116
Update benchmarks 2026-01-30 13:05:47 +02:00
Yiorgis Gozadinos
4264cdcc24
vb 2026-01-29 15:35:42 +02:00
Yiorgis Gozadinos
f17c1d5003
TUI video link in docs 2026-01-29 15:26:35 +02:00
Yiorgis Gozadinos
6aa9f83670
Merge pull request #265 from ggozad/feat/deep-ask-evals
Evaluations using ask --deep
2026-01-29 14:40:53 +02:00
Yiorgis Gozadinos
2a99cb09bf
Evaluate using ask --deep 2026-01-29 14:22:18 +02:00
Yiorgis Gozadinos
39f4937916
Handle missing datasets from huggingface 2026-01-29 13:31:31 +02:00
Yiorgis Gozadinos
51e6be0d68
Merge pull request #264 from ggozad/fix/tui
Fix TUI session handling
2026-01-29 12:26:10 +02:00
Yiorgis Gozadinos
77894b8c35
Clena citation handling now that we handle the total state 2026-01-29 12:12:48 +02:00
Yiorgis Gozadinos
202df368a0
TUI now syncs full session state from AG-UI events 2026-01-29 12:05:46 +02:00
Yiorgis Gozadinos
49efe3eede
Update docs 2026-01-28 18:12:31 +02:00
Yiorgis Gozadinos
2afb10f6a4
Merge pull request #263 from ggozad/feat/state-deltas
Send delta snapshots instead of full snapshots in AGUI
2026-01-28 17:44:55 +02:00
Yiorgis Gozadinos
cbd94f6cc9
Always use state deltas instead of conditional snapshot/delta logic 2026-01-28 17:03:18 +02:00
Yiorgis Gozadinos
57fd08d600
Revert "Synthesis steps now select only relevant citations instead of including all" 2026-01-28 16:00:27 +02:00
Yiorgis Gozadinos
e2158c7dad
Handle snapshot deltas in TUI 2026-01-28 15:23:56 +02:00
Yiorgis Gozadinos
3741274c6c
Update ty and fix 2026-01-28 14:24:16 +02:00
Yiorgis Gozadinos
639accc923
Update tests for state deltas 2026-01-28 14:19:42 +02:00
Yiorgis Gozadinos
e325962fcc
Add logging to the backend 2026-01-28 14:08:24 +02:00
Yiorgis Gozadinos
b5aeefb10f
Fix state delta computation by avoiding mutation of original state 2026-01-28 14:06:29 +02:00
Yiorgis Gozadinos
c27dde2497
Send state deltas updates instead of full state snapshots 2026-01-28 14:06:29 +02:00
Yiorgis Gozadinos
27532eb657
Merge pull request #262 from ggozad/feat/chat-agent-improvements
Conversational agent improvements
2026-01-28 14:05:43 +02:00
Yiorgis Gozadinos
1bc2b5f05a
Add vcr test 2026-01-27 17:28:50 +02:00
Yiorgis Gozadinos
5bfd6db984
Test count_documents 2026-01-27 17:17:05 +02:00
Yiorgis Gozadinos
626f5cdd0e
Make list_documents return structured output and give feedback about total count and pages 2026-01-27 17:00:39 +02:00
Yiorgis Gozadinos
cca03dbe61
Give the conversational agent a list documents and a summarize tools 2026-01-27 16:39:48 +02:00
Yiorgis Gozadinos
8a587ec554
Synthesis steps now select only relevant citations instead of including all 2026-01-27 15:56:29 +02:00
Yiorgis Gozadinos
614c34b98f
Add read-only initial context for chat sessions for TUI and web app 2026-01-27 13:59:00 +02:00
Yiorgis Gozadinos
bb634e68a4
vb 2026-01-27 11:17:17 +02:00
Yiorgis Gozadinos
66467abec3
Merge pull request #261 from ggozad/chore/chat-agent-improvements
Initial Context for Chat Sessions & fixes
2026-01-27 11:16:35 +02:00
Yiorgis Gozadinos
09aa94bfe5
Fix datetime JSON serialization in AG-UI StateSnapshotEvent 2026-01-27 11:05:01 +02:00
Yiorgis Gozadinos
ee3cb5bd87
Allow initial_context to be set for the chat agent 2026-01-27 10:38:18 +02:00
Yiorgis Gozadinos
7c9d70fa62
vb 2026-01-26 16:46:33 +02:00
Yiorgis Gozadinos
ab10bfc689
Merge pull request #254 from ggozad/feat/context-building
Dynamic session context for conversational RAG
2026-01-26 16:44:24 +02:00
Yiorgis Gozadinos
6f9bc369dc
Improve coverage 2026-01-26 16:43:12 +02:00
Yiorgis Gozadinos
5976cebe8c
Concolidate tests 2026-01-26 16:43:12 +02:00
Yiorgis Gozadinos
061b8450a6
docs 2026-01-26 16:43:11 +02:00
Yiorgis Gozadinos
9ce711db29
Update tests 2026-01-26 16:43:11 +02:00
Yiorgis Gozadinos
0465e1eebd
Update prompt to remove the recall tool 2026-01-26 16:43:11 +02:00
Yiorgis Gozadinos
a7d7a09c22
Integrate recall functionality into ask tool 2026-01-26 16:43:11 +02:00
Yiorgis Gozadinos
7835509213
Fix tui test that still wanted to use shortcuts 2026-01-26 16:43:11 +02:00
Yiorgis Gozadinos
276e86a033
Fix citation selection when we call visualize 2026-01-26 16:43:10 +02:00
Yiorgis Gozadinos
7f63a7c4ab
Use recall tool in chat agent 2026-01-26 16:43:10 +02:00
Yiorgis Gozadinos
77647b08bc
Recall tool in chat agent 2026-01-26 16:42:41 +02:00
Yiorgis Gozadinos
8f35033a42
Update search(), ask() to use get_or_assign_index() 2026-01-26 16:42:41 +02:00
Yiorgis Gozadinos
fa2072a720
Docs 2026-01-26 16:42:40 +02:00
Yiorgis Gozadinos
0419c9a0ae
Filter documents in app 2026-01-26 16:42:40 +02:00
Yiorgis Gozadinos
af786596b1
Document filter in the TUI, use command palette instead of shortcuts 2026-01-26 16:42:40 +02:00
Yiorgis Gozadinos
30e9ff3038
Use session document filter in agent, combine with document name filter if necessary 2026-01-26 16:42:40 +02:00
Yiorgis Gozadinos
5b14038dc5
Add document_filter to ChatSessionState for session-level filtering 2026-01-26 16:42:40 +02:00
Yiorgis Gozadinos
0f0c3a076f
minor fixes 2026-01-26 16:42:39 +02:00
Yiorgis Gozadinos
729a3910d8
Fix visual grounding citation selection in TUI 2026-01-26 16:42:39 +02:00
Yiorgis Gozadinos
ff92ee8271
Skip gather_context when session context exists 2026-01-26 16:42:39 +02:00
Yiorgis Gozadinos
5aabae4d06
Remove background_context & simplify 2026-01-26 16:42:39 +02:00
Yiorgis Gozadinos
9d71ccb213
Move session context to agent, make AGUI work 2026-01-26 16:42:39 +02:00
Yiorgis Gozadinos
93737852a8
Use previous context in summarization 2026-01-26 16:42:38 +02:00
Yiorgis Gozadinos
3214160fe7
Show SessionContext in frontend 2026-01-26 16:42:38 +02:00
Yiorgis Gozadinos
9ed8773672
Unify Citation class and context formatting between chat and research agents 2026-01-26 16:42:38 +02:00
Yiorgis Gozadinos
74eac57ffa
Remove qa_history ranking in favor of SessionContext, planner can skip searching if context adequate 2026-01-26 16:42:38 +02:00
Yiorgis Gozadinos
3b7b7c1bbc
Cancel any running summarization tasks 2026-01-26 16:42:38 +02:00
Yiorgis Gozadinos
8441516a73
Tests, widget to show context in tui 2026-01-26 16:41:13 +02:00
Yiorgis Gozadinos
aaddf4a5a9
Dynamic session context to be used in the conversational agent 2026-01-26 16:41:12 +02:00
Yiorgis Gozadinos
6f94f71d0c
Merge pull request #260 from ggozad/feat/evals-huggingface
Introduce huggingface dataset for sharing evaluation dbs
2026-01-26 16:40:04 +02:00
Yiorgis Gozadinos
369b3a4bf7
No zip 2026-01-26 16:28:19 +02:00
Yiorgis Gozadinos
9b166969e2
Introduce huggingface dataset for sharing evaluation dbs 2026-01-26 15:53:37 +02:00
Yiorgis Gozadinos
50a1a6171c
Update orb qa accuracy 2026-01-26 10:54:36 +02:00
Yiorgis Gozadinos
c7a9ad8583
Customize orb QA prompt to not use LaTeX as gpt-oss Ollama implementation fails to parse it properly 2026-01-24 13:48:09 +02:00
Yiorgis Gozadinos
d025cf5552
Merge pull request #258 from ggozad/chore/dependencies
Update dependencies
2026-01-23 12:26:28 +02:00
Yiorgis Gozadinos
47d355ed0a
Update dependencies 2026-01-23 11:57:02 +02:00
Yiorgis Gozadinos
35ebdd67e2
vb 2026-01-22 17:48:39 +02:00
Yiorgis Gozadinos
3bddf87454
Merge pull request #228 from ggozad/feat/multimodal-evaluation
Add evaluation dataset for multi-modal q&a
2026-01-22 17:47:17 +02:00
Yiorgis Gozadinos
3f9f2191a0
Merge pull request #257 from ggozad/fix/fix-0.25.0-migration
Fix potential 0.25.0 migration crash
2026-01-22 17:46:48 +02:00
Yiorgis Gozadinos
b4c158ffcf
Fix potential 0.25.0 migration crash 2026-01-22 17:29:26 +02:00
Yiorgis Gozadinos
0d3ac5142d
Fix answer in orb 2026-01-22 15:08:33 +02:00
Yiorgis Gozadinos
756285e91c
Add retrieval benchmarks for ORB 2026-01-22 15:01:37 +02:00
Yiorgis Gozadinos
de022639b6
Correctly lookup docs for the orb dataset 2026-01-22 14:17:09 +02:00
Yiorgis Gozadinos
e6b239bd6c
Add evaluation dataset for multi-modal q&a 2026-01-22 14:17:09 +02:00
Yiorgis Gozadinos
32c64d3d11
vb 2026-01-22 12:59:32 +02:00
Yiorgis Gozadinos
0adbf0f125
Merge pull request #250 from tianyicui/fix/dotenv-usecwd
fix: load .env from current working directory, not source file directory
2026-01-22 12:58:01 +02:00
Yiorgis Gozadinos
ea7a4a9a8f
apply dotenv usecwd fix to remaining files
Apply the same find_dotenv(usecwd=True) fix from cli.py to:
- evaluations/evaluations/benchmark.py
- app/backend/main.py

Co-Authored-By: Tianyi Cui <contact@tianyicui.com>
2026-01-22 12:41:35 +02:00
Tianyi Cui
910ebbab22
fix: load .env from current working directory, not source file directory
The previous load_dotenv() searched for .env starting from the calling
file's directory (haiku/rag/cli.py), which fails when users run haiku-rag
from a different directory containing their .env file.

Changes:
- Move load_dotenv() before config imports (config reads env vars at import time)
- Use find_dotenv(usecwd=True) to search from current working directory
2026-01-22 12:36:55 +02:00
Yiorgis Gozadinos
6201303df3
Merge pull request #256 from tseaver/patch-2
fix: mark 'hashlib.md5' as 'usedforsecurity=False'
2026-01-22 10:25:05 +02:00
Tres Seaver
e9746a1785
fix: mark 'hashlib.md5' as 'usedforsecurity=False'
Closes #255.
2026-01-21 12:14:56 -05:00
Yiorgis Gozadinos
4bdfb180d5
Merge pull request #252 from ggozad/fix/score-use
Search results now show rank position instead of raw scores.
2026-01-21 15:28:43 +02:00
Yiorgis Gozadinos
b96e5a3be1
Additional prompts needing adapting. Missing cassettes. 2026-01-21 15:17:56 +02:00
Yiorgis Gozadinos
7f51c63ad7
Search results now show rank position instead of raw scores. Consolidate cassettes 2026-01-21 14:54:15 +02:00
Yiorgis Gozadinos
dff313fa74
Merge pull request #251 from ggozad/feat/jina-reranker
Jina Reranker v3 (local & API)
2026-01-21 14:14:24 +02:00
Yiorgis Gozadinos
a1abfd9666
Jina uses AutoModel not AutoModelForSequenceClassification 2026-01-21 11:26:23 +02:00
Yiorgis Gozadinos
ce95dc47a5
Do not run coverage on download-models & code that requires integration tests 2026-01-21 11:09:35 +02:00
Yiorgis Gozadinos
36a675e1bb
Add jina, mxbai and sentence-transformers to download-models when used 2026-01-21 10:53:45 +02:00
Yiorgis Gozadinos
7ff014684a
Support for jina reranker, both local and API 2026-01-21 10:39:21 +02:00
Yiorgis Gozadinos
b8cf8f5198
Remove reranker cache 2026-01-20 17:26:24 +02:00
Yiorgis Gozadinos
e7ce9694b5
vb 2026-01-20 15:47:37 +02:00
Yiorgis Gozadinos
be82473624
Merge pull request #249 from ggozad/feat/ocr-engine
Add `ocr_engine` field to `ConversionOptions`
2026-01-20 13:56:23 +02:00
Yiorgis Gozadinos
6fd496e908
Merge pull request #248 from ggozad/fix/logfire-chat
Fix crash when logfire is installed but user is not authenticated.
2026-01-20 13:52:09 +02:00
Yiorgis Gozadinos
7bbac66640
Fix crash when logfire is installed but user is not authenticated. Closes #247 2026-01-20 13:43:44 +02:00
Yiorgis Gozadinos
62d35d2907
Add ocr_engine field to ConversionOptions 2026-01-20 13:41:43 +02:00
Yiorgis Gozadinos
8a7514cad2
Merge pull request #244 from ggozad/chore/cleanup
Remove a2a example, clean up
2026-01-19 17:03:50 +02:00
Yiorgis Gozadinos
ea492248e7
Remove a2a examples, clean up 2026-01-19 16:53:40 +02:00
Yiorgis Gozadinos
e32b48d142
Merge pull request #243 from ggozad/chore/ty
replace pyright with ty type checker
2026-01-19 16:32:34 +02:00
Yiorgis Gozadinos
1022d5dafd
Update dependencies 2026-01-19 16:23:26 +02:00
Yiorgis Gozadinos
f05159859c
Additional fixes to remove type ignores 2026-01-19 16:00:18 +02:00
Yiorgis Gozadinos
5e55c0df54
replace pyright with ty type checker 2026-01-19 15:50:14 +02:00
Yiorgis Gozadinos
2c264f4afc
vb 2026-01-19 14:54:18 +02:00
Yiorgis Gozadinos
2f4354d100
Merge pull request #242 from ggozad/chore/out-out-migration
Require explicit migrate command for database migrations
2026-01-19 14:53:22 +02:00
Yiorgis Gozadinos
f582552f6a
Add tests for migration scenarios 2026-01-19 14:12:51 +02:00
Yiorgis Gozadinos
8b2cc19288
Fix tests 2026-01-19 13:47:20 +02:00
Yiorgis Gozadinos
1316c89135
docs 2026-01-19 13:30:17 +02:00
Yiorgis Gozadinos
5464d24483
require explicit migrate command for database migrations 2026-01-19 13:22:09 +02:00
Yiorgis Gozadinos
5f2f180960
vb 2026-01-16 15:27:55 +02:00
Yiorgis Gozadinos
9756add372
Merge pull request #240 from ggozad/chore/improve-agui
Improve conversational agent & AG-UI integration
2026-01-16 15:25:49 +02:00
Yiorgis Gozadinos
c97397dd67
Fix docs 2026-01-16 15:07:17 +02:00
Yiorgis Gozadinos
bdfc6b87e7
Rename initial_context to background_context 2026-01-16 15:03:42 +02:00
Yiorgis Gozadinos
6c5c23b338
Use pnpm, not npm on CI, fix errors. 2026-01-16 15:03:42 +02:00
Yiorgis Gozadinos
c1163dc8ff
Use biome on the frontend for linting, add to CI 2026-01-16 15:03:41 +02:00
Yiorgis Gozadinos
609cbb78de
Background context component for frontend 2026-01-16 15:03:41 +02:00
Yiorgis Gozadinos
2054450142
ChatDeps implements StateHandler protocol for proper AG-UI state management 2026-01-16 15:03:41 +02:00
Yiorgis Gozadinos
d2fabb9f13
Update docs 2026-01-16 15:03:41 +02:00
Yiorgis Gozadinos
18362f01c2
Pass context for CLI/app 2026-01-16 15:03:06 +02:00
Yiorgis Gozadinos
d82e1f95c1
Optimize context prompts 2026-01-16 15:03:05 +02:00
Yiorgis Gozadinos
dff489f75f
Add initial_context support to chat agent 2026-01-16 15:03:05 +02:00
Yiorgis Gozadinos
e73486b7a8
Set retries to 3 for agents that did not have it. 2026-01-16 15:03:05 +02:00
Yiorgis Gozadinos
076f11d4dd
Merge pull request #239 from runyaga/docs/comprehensive-review
docs: fix documentation discrepancies with codebase
2026-01-16 15:01:54 +02:00
runyaga
a29f5b2e48 docs: fix documentation discrepancies with codebase
CLI documentation:
- Add missing short flags (-f for --filter, -l for --limit)
- Document missing `init-config` command
- Document missing `inspect` command

Python API documentation:
- Fix get_document_by_id example to use string ID (not integer)
- Document filter parameter for ask() method

MCP documentation:
- Correct search_documents limit default (uses config, not hardcoded 5)
2026-01-15 15:44:49 -06:00
Yiorgis Gozadinos
12e5e9ebfb
vb 2026-01-15 16:24:03 +02:00
Yiorgis Gozadinos
e38b235d72
Merge pull request #237 from ggozad/feat/ag-ui-features
Add AGUI_STATE_KEY for namespaced AG-UI state emission
2026-01-15 15:59:54 +02:00
Yiorgis Gozadinos
7639c2915e
Add AGUI_STATE_KEY for namespaced AG-UI state emission 2026-01-15 15:46:37 +02:00
Yiorgis Gozadinos
157773ef15
vb 2026-01-15 11:47:49 +02:00
Yiorgis Gozadinos
df48f47b6e
Merge pull request #236 from ggozad/chore/cleanup
Improve test coverage and remove unnecessary defensive code
2026-01-15 11:00:20 +02:00
Yiorgis Gozadinos
e0da58bb6c
Add tests for settings validation and app operations 2026-01-15 10:46:29 +02:00
Yiorgis Gozadinos
bb60ad127a
Add pragma nocover to optional dependency imports 2026-01-15 10:32:35 +02:00
Yiorgis Gozadinos
fae274a04d
Remove defensive checks in info() 2026-01-15 10:30:45 +02:00
Yiorgis Gozadinos
345807e699
Remove defensive app() try/except, let errors propagate if they occur 2026-01-15 10:17:26 +02:00
Yiorgis Gozadinos
affffc1013
Extract get_package_versions() for info & modals 2026-01-14 18:24:41 +02:00
Yiorgis Gozadinos
fe73267e66
Remove unecessary try/except from info() 2026-01-14 18:17:43 +02:00
Yiorgis Gozadinos
fc1ea82cb1
Display on info pydantic-ai and docling-document schema version 2026-01-14 18:14:53 +02:00
Yiorgis Gozadinos
e14499658f
Merge pull request #235 from ggozad/fix/rebuild-changed-vector-size
Fix embed-only rebuild with changed vector dimensions
2026-01-14 17:54:30 +02:00
Yiorgis Gozadinos
8bdfbe526c
Fix embed-only rebuild with changed vector dimensions
When a database was created with one embedding model and rebuild
--embed-only was run with a different model, it failed with a
vector dimension validation error.

- Store reads stored vector_dim when opening existing databases
- _rebuild_embed_only recreates chunks table to handle dimension changes
- Add test for rebuild with changed vector dimensions
2026-01-14 17:42:28 +02:00
Yiorgis Gozadinos
05be72d773
Merge pull request #233 from ggozad/feat/agui-state-key
Add state_key parameter for keyed AG-UI state emission
2026-01-14 17:26:21 +02:00
Yiorgis Gozadinos
bff613b7dc
Better test coverage 2026-01-14 17:12:33 +02:00
Yiorgis Gozadinos
11f7f02262
Add option to namespace state key as a "feature" for ag-ui apps that maintain their own state 2026-01-14 14:21:08 +02:00
Yiorgis Gozadinos
d602ad48bb
Merge pull request #232 from ggozad/feat/page-generation-option
Control page image generation
2026-01-14 13:50:44 +02:00
Yiorgis Gozadinos
7b6b2e2f38
Update docs 2026-01-14 12:55:02 +02:00
Yiorgis Gozadinos
929c29250e
Additional tests for page & picture images for local and docling-serve 2026-01-14 12:48:13 +02:00
Yiorgis Gozadinos
447e94c5db
Config options for generating page images 2026-01-14 11:55:08 +02:00
Yiorgis Gozadinos
2930679dc1
vb 2026-01-13 19:47:27 +02:00
Yiorgis Gozadinos
6d852fad6a
Merge pull request #231 from ggozad/chore/upgrade-docling-document-1.9.0
Update to docling-document 1.9.0
2026-01-13 19:46:08 +02:00
Yiorgis Gozadinos
98b3d114b3
Update to docling-document 1.9.0 2026-01-13 19:20:27 +02:00
Yiorgis Gozadinos
275b09c8c5
vb 2026-01-13 18:34:45 +02:00
Yiorgis Gozadinos
043a92a8aa
Merge pull request #230 from ggozad/fix/docling-version-mismatch
Revert docling from 2.67.0 to 2.65.0 to use docling-document 1.8.0
2026-01-13 18:31:24 +02:00
Yiorgis Gozadinos
c9cbf04cc8
Reverted docling from 2.67.0 to 2.65.0 to use docling-document 1.8.0 2026-01-13 18:22:58 +02:00
Yiorgis Gozadinos
eb5d0b0622
Use tui as an optional group. vb 2026-01-13 12:30:19 +02:00
Yiorgis Gozadinos
e9efe69136
Update bump version script 2026-01-13 12:21:55 +02:00
Yiorgis Gozadinos
f3b644a2a3
Update docs for app 2026-01-13 12:18:13 +02:00
Yiorgis Gozadinos
3bb1721e0f
Merge pull request #224 from ggozad/feat/haiku.rag.app
Add conversational RAG application with chat agent module
2026-01-13 12:10:27 +02:00
Yiorgis Gozadinos
f6bb3c2d33
Update README 2026-01-13 12:02:14 +02:00
Yiorgis Gozadinos
567e50be1f
Update documentation 2026-01-13 11:59:21 +02:00
Yiorgis Gozadinos
525e5f9831
Fix vaccum test 2026-01-13 11:55:04 +02:00
Yiorgis Gozadinos
6b3ff5ef1e
CLI app 2026-01-13 11:31:21 +02:00
Yiorgis Gozadinos
100933b36e
update cl 2026-01-12 17:21:41 +02:00
Yiorgis Gozadinos
3e66d6b21d
Make qa list a FIFO with 50 max, keep a cache of embeddings 2026-01-12 17:02:23 +02:00
Yiorgis Gozadinos
ce326d80ff
Disable ocr in tests previously marked as integration 2026-01-12 16:19:25 +02:00
Yiorgis Gozadinos
52363639a6
Remove integration marker from tests that have vcr 2026-01-12 16:03:05 +02:00
Yiorgis Gozadinos
51836a1c55
Add tests for chat agent & friends 2026-01-12 16:00:07 +02:00
Yiorgis Gozadinos
f4ddb0f5c6
Allow chat agent to specify the search limit 2026-01-12 15:52:40 +02:00
Yiorgis Gozadinos
2da2c6d13a
Search tool in chat agent needs no search context 2026-01-12 15:13:43 +02:00
Yiorgis Gozadinos
5adab04e50
Update prompts 2026-01-12 15:10:04 +02:00
Yiorgis Gozadinos
d43821659c
Filter low-confidence answers 2026-01-12 15:01:32 +02:00
Yiorgis Gozadinos
e4a7d86348
Rank q/a history with respect to cosine similarity to new queries. Adapt the chat agent to only pass relevant q/as 2026-01-12 14:15:36 +02:00
Yiorgis Gozadinos
a9ed178a90
Research graph test cassette 2026-01-12 12:43:51 +02:00
Yiorgis Gozadinos
7cc561d1db
Refactor to make agents a top-level module. Bring in the conversational agent from the app 2026-01-12 12:37:08 +02:00
Yiorgis Gozadinos
fc29b0c3f7
Update docs 2026-01-12 12:37:08 +02:00
Yiorgis Gozadinos
bb61e8fab6
Remove custom AG-UI infrastructure in favor of pydantic-ai native support 2026-01-12 12:37:07 +02:00
Yiorgis Gozadinos
baefbe9416
Tool calling in the frontend 2026-01-12 12:36:32 +02:00
Yiorgis Gozadinos
f2fb64cbca
Remove custom ag-ui from app backend. 2026-01-12 12:36:32 +02:00
Yiorgis Gozadinos
26bc02ef14
Build a "conversational" research graph 2026-01-12 12:36:31 +02:00
Yiorgis Gozadinos
df71e7a893
Adapt search to show complete results 2026-01-12 12:36:31 +02:00
Yiorgis Gozadinos
8579ded7bb
Natural language filtering 2026-01-12 12:36:31 +02:00
Yiorgis Gozadinos
36f964eaa9
get_document() 2026-01-12 12:36:31 +02:00
Yiorgis Gozadinos
a6a60963c0
Add SSE heartbeat to prevent connection timeouts
Sends SSE comment every 15 seconds during long LLM operations
to keep the connection alive and prevent body timeout errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:36:30 +02:00
Yiorgis Gozadinos
8b7c679159
Add SearchAgent for internal query expansion
SearchAgent generates 2-4 diverse search queries internally,
runs them against the knowledge base, deduplicates by chunk_id,
and returns consolidated results. This prevents the outer chat
agent from making multiple search calls.

Also updates system prompt to enforce single tool calls per message.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:36:30 +02:00
Yiorgis Gozadinos
49f5c20757
Mount haiku.rag.yaml config in docker compose
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:36:30 +02:00
Yiorgis Gozadinos
18cec25fc9
Visual grounding; 2026-01-12 12:36:30 +02:00
Yiorgis Gozadinos
72b66dd3d8
Styling 2026-01-12 12:36:30 +02:00
Yiorgis Gozadinos
6ba28244d8
Logfire integration for debugging 2026-01-12 12:36:29 +02:00
Yiorgis Gozadinos
14873862f8
Q/A history 2026-01-12 12:36:29 +02:00
Yiorgis Gozadinos
5a1cb253e5
Message history 2026-01-12 12:36:29 +02:00
Yiorgis Gozadinos
9f1d82daa8
Citations 2026-01-12 12:36:29 +02:00
Yiorgis Gozadinos
f8964f6efc
Basic frontend 2026-01-12 12:36:28 +02:00
Yiorgis Gozadinos
050ea8df70
Basic backend for app 2026-01-12 12:36:28 +02:00
Yiorgis Gozadinos
747acd6f6a
vb 2026-01-12 12:08:28 +02:00
Yiorgis Gozadinos
061095e855
Remove obsolete migrations 2026-01-12 12:07:49 +02:00
Yiorgis Gozadinos
119ca84da8
Merge pull request #227 from ggozad/feat/docling-doc-compression
Fix large storage overflow by changing docling_document_json (str) to docling_document (compressed bytes)
2026-01-12 11:57:31 +02:00
Yiorgis Gozadinos
29c682d8eb
Add migration 2026-01-11 11:40:57 +02:00
Yiorgis Gozadinos
9c4fd08c47
Update client serialization for compressed docling documents 2026-01-11 09:23:56 +02:00
Yiorgis Gozadinos
be715dd456
Revert docling core back to 1.8.0 for compatibility with docling-serve 2026-01-11 09:23:19 +02:00
Yiorgis Gozadinos
afc5b02285
Update schema to store docling_document as compressed bytes
- Change DocumentRecord.docling_document_json (str) to docling_document (bytes)
- Use large_binary Arrow type (64-bit offsets) to avoid 2GB column limit
- Decompress in Document.get_docling_document() using gzip
- Update DocumentRepository field mappings
2026-01-10 15:17:27 +02:00
Yiorgis Gozadinos
face6f95bc
compression utils 2026-01-10 15:11:58 +02:00
Yiorgis Gozadinos
09acc845c0
Update docling, docling-core, lancedb deps 2026-01-10 15:06:56 +02:00
Yiorgis Gozadinos
ff1f19bb08
vb 2026-01-08 18:50:39 +02:00
Yiorgis Gozadinos
bd1ea4e8a2
Merge pull request #222 from ggozad/fix/image-in-chunks
Fix base64 images leaking into expanded search results
2026-01-08 18:49:32 +02:00
Yiorgis Gozadinos
3603b00513
Fix base64 images leaking into expanded search results
PictureItem.export_to_markdown() defaults to ImageRefMode.EMBEDDED,
which embeds base64 image data. When _extract_item_text() called this
method during context expansion, base64 data would leak into results.

Now explicitly handles PictureItem with ImageRefMode.PLACEHOLDER to
prevent base64 while still including VLM descriptions and captions.
2026-01-08 18:42:58 +02:00
Yiorgis Gozadinos
4cae2cf26a
Clarify docs about using a VLM with docling-serve 2026-01-08 10:57:52 +02:00
Yiorgis Gozadinos
92f0fd956f
vb 2026-01-08 10:25:25 +02:00
Yiorgis Gozadinos
af380c8e52
Merge pull request #220 from ggozad/fix/openai-non-reasoning-model
Fix non-reasoning OpenAI models that do not support reasoning config
2026-01-08 10:18:50 +02:00
Yiorgis Gozadinos
1616891c40
Fix non-reasoning OpenAI models that do not support reasoning config 2026-01-07 18:47:31 +02:00
Yiorgis Gozadinos
ef187767ed
vb 2026-01-07 11:20:48 +02:00
Yiorgis Gozadinos
8939b8a313
Merge pull request #218 from ggozad/feat/vlm-image-handling
Support for VLM-based picture description for embedded images
2026-01-07 11:18:23 +02:00
Yiorgis Gozadinos
2f413e937b
Add VLM model to download_models() 2026-01-07 11:09:24 +02:00
Yiorgis Gozadinos
248eed8d7b
Update docs 2026-01-07 11:09:23 +02:00
Yiorgis Gozadinos
f861664656
Extract shared DoclingServeClient for async workflow & use it for VLM support, chunking, converting 2026-01-07 11:09:23 +02:00
Yiorgis Gozadinos
3b7fd440cc
End-to-end test for VLM annotation 2026-01-07 11:09:23 +02:00
Yiorgis Gozadinos
769e0d4746
Update picture description prompt, move its config under prompts 2026-01-07 11:09:23 +02:00
Yiorgis Gozadinos
3c4116397d
Add VLM picture description support for image handling 2026-01-07 11:09:22 +02:00
Yiorgis Gozadinos
9bf3a83b5d
Fix typo 2026-01-05 16:39:36 +02:00
Yiorgis Gozadinos
af340ac410
vb 2026-01-05 12:15:41 +02:00
Yiorgis Gozadinos
dc9a99e8f5
Merge pull request #217 from ggozad/chore/dependencies
Update dependencies
2026-01-05 12:12:43 +02:00
Yiorgis Gozadinos
ff58adb549
Update ag-ui-research example 2026-01-05 12:04:30 +02:00
Yiorgis Gozadinos
7ddd29d108
Update a2a example 2026-01-05 11:53:30 +02:00
Yiorgis Gozadinos
271af6cb3c
Update core dependencies 2026-01-05 11:52:38 +02:00
Yiorgis Gozadinos
d4ff1febbc
Merge pull request #216 from ggozad/fix/concurrent-search-emitter
Fix concurrent step tracking in AG-UI emitter
2026-01-05 11:15:36 +02:00
Yiorgis Gozadinos
5b34b9ed0e
Track potentially more than one running step in agui emitter 2026-01-05 10:41:35 +02:00
Yiorgis Gozadinos
c4b1733339
vb 2025-12-29 18:45:06 +02:00
Yiorgis Gozadinos
679d0bfafd
Merge pull request #212 from ggozad/feat/contextualize-fts
Add additional context (headers/sections etc) to the FTS index
2025-12-29 18:42:19 +02:00
Yiorgis Gozadinos
1dc40abab3
get_reranker() tests 2025-12-29 17:13:14 +02:00
Yiorgis Gozadinos
22f22d2181
Add vcr to tests 2025-12-29 14:59:15 +02:00
Yiorgis Gozadinos
a4d33c9e71
Delete test_agui_server.py, it was accidentally commited 2025-12-29 14:58:04 +02:00
Yiorgis Gozadinos
fc3530258d
cl 2025-12-29 14:50:59 +02:00
Yiorgis Gozadinos
c9cb005d07
Use FTS on contextualized text 2025-12-29 14:50:07 +02:00
Yiorgis Gozadinos
02f5dc4fb9
Merge pull request #210 from ggozad/feat/tests-vcr
VCR Cassette Recording for API-less tests.
2025-12-29 14:42:31 +02:00
Yiorgis Gozadinos
68ac50acff
Add coverage and badges 2025-12-29 14:36:01 +02:00
Yiorgis Gozadinos
1609582c1f
Move cassettes to default location, add development.md to docs 2025-12-29 14:13:25 +02:00
Yiorgis Gozadinos
bfae711a17
Do not run test requiring docling OCR in CI 2025-12-29 13:44:57 +02:00
Yiorgis Gozadinos
1e9a10a235
Record cassettes for embedder, ignore huggingface while recording 2025-12-29 13:23:53 +02:00
Yiorgis Gozadinos
5844d07c5d
Add lint github action 2025-12-27 18:50:57 +02:00
Yiorgis Gozadinos
b64a2b08ee
Commit dataset for CI and github workflow 2025-12-26 19:30:26 +02:00
Yiorgis Gozadinos
0da8ba97c4
Docling-serve cassettes 2025-12-26 18:59:46 +02:00
Yiorgis Gozadinos
c3a3b4a5b4
Extended VCR cassette recording to embedder, client, research graph, and search filter tests 2025-12-26 18:53:17 +02:00
Yiorgis Gozadinos
4a82e47d03
Use cassette to record and reply llms in qa tests 2025-12-26 15:41:11 +02:00
Yiorgis Gozadinos
1f654917b8
vb 2025-12-26 13:56:29 +02:00
Yiorgis Gozadinos
e9657c2044
Merge pull request #207 from ggozad/feat/prompt-overrides
Add prompt customization and domain preable
2025-12-26 13:36:07 +02:00
Yiorgis Gozadinos
08fb99c6f8
Add prompt customization and domain preable 2025-12-26 13:34:51 +02:00
Yiorgis Gozadinos
c44ebcd371
Merge pull request #209 from ggozad/feat/pydantic-ai-embeddings
Use Pydantic AI embeddings
2025-12-26 13:32:30 +02:00
Yiorgis Gozadinos
958fe43f2a
Remove vllm and lmstudio custom configs, they can now use the openai base 2025-12-26 12:02:55 +02:00
Yiorgis Gozadinos
d4861b6408
Docs & changelog 2025-12-26 11:57:27 +02:00
Yiorgis Gozadinos
86e31b8d29
VoyageAI embeddings, should end up as a PR for pydantic-ai 2025-12-26 11:50:47 +02:00
Yiorgis Gozadinos
3852e961b9
Update tests and client for explicit embed_query/embed_documents API 2025-12-26 11:26:56 +02:00
Yiorgis Gozadinos
132b8a36bc
Differentiate between embedding a query and a document 2025-12-24 12:43:11 +02:00
Yiorgis Gozadinos
5a30c197af
Delete obsolete embedders, rewrite get_embedder 2025-12-24 12:37:17 +02:00
Yiorgis Gozadinos
3141c91023
Add base_url to ModelConfig and EmbeddingModelConfig. Deprecate vllm and lm_studio configs, now through open ai 2025-12-24 12:33:44 +02:00
Yiorgis Gozadinos
271c50e225
pydantic-ai update 2025-12-24 12:25:22 +02:00
Yiorgis Gozadinos
eac87d8603
Update readme 2025-12-19 16:28:23 +02:00
Yiorgis Gozadinos
4a43cd0501
Update mcp registry schema 2025-12-19 12:39:39 +02:00
Yiorgis Gozadinos
5c7f033d2c
vb 2025-12-19 12:35:35 +02:00
Yiorgis Gozadinos
13cc453e9d
Make tests ignore haiku.rag.yaml 2025-12-19 12:35:06 +02:00
Yiorgis Gozadinos
4164895514
Update critical dependencies 2025-12-19 12:21:57 +02:00
Yiorgis Gozadinos
54f7336904
Merge pull request #206 from ggozad/feat/time-travel
Time-travel, query the database as it existed at a previous point in time
2025-12-19 12:03:59 +02:00
Yiorgis Gozadinos
d7b3b6859c
document time-travel 2025-12-19 12:03:19 +02:00
Yiorgis Gozadinos
716cf420be
Tests for history 2025-12-19 12:03:19 +02:00
Yiorgis Gozadinos
2678c1aaad
history command, shows available lancedb versions per table 2025-12-19 12:03:18 +02:00
Yiorgis Gozadinos
77f9a2a1b9
before parameter in client, app, inspector, cli 2025-12-19 12:03:18 +02:00
Yiorgis Gozadinos
1e5eebfbf0
Checkout version logic and datetime utils 2025-12-19 12:03:16 +02:00
Yiorgis Gozadinos
34c32f941c
Merge pull request #203 from ggozad/feat/read-only-db
Read-only mode
2025-12-19 12:01:48 +02:00
Yiorgis Gozadinos
8c0adb47e1
Merge branch 'main' into feat/read-only-db 2025-12-19 12:01:36 +02:00
Yiorgis Gozadinos
30f6bac388
Merge pull request #205 from ggozad/fix/docker-paths
Clarify how folder mounts work in docker docs
2025-12-19 11:59:00 +02:00
Yiorgis Gozadinos
090be293d2
Clarify folder mounts in docker docs, check monitor paths exist in FileWatcher 2025-12-19 11:58:08 +02:00
Yiorgis Gozadinos
1ff8fc1b56
Merge pull request #201 from ggozad/fix/allow-custom-config
Fix get_model() uses where default Config is forced
2025-12-19 11:57:30 +02:00
Yiorgis Gozadinos
fb1d3c97b3
Add additional programming language specifiers 2025-12-19 11:09:57 +02:00
Yiorgis Gozadinos
fdd926eeba
Update docs 2025-12-18 16:47:32 +02:00
Yiorgis Gozadinos
98727ad080
Add global --read-only mode in CLI 2025-12-18 16:43:49 +02:00
Yiorgis Gozadinos
1a8f72f99a
Add is_read_only property to Store. Guard all write operations in repositories 2025-12-18 15:55:24 +02:00
Yiorgis Gozadinos
e8e67d336c
Fix get_model() uses where default Config is forced 2025-12-18 15:10:31 +02:00
Yiorgis Gozadinos
1dbbaf6dd8
vb 2025-12-18 12:21:16 +02:00
Yiorgis Gozadinos
242ea2cb8f
Merge pull request #199 from ggozad/chore/ag_ui-core
Replace custom event classes with `ag_ui.core` types
2025-12-18 12:08:03 +02:00
Yiorgis Gozadinos
a1f5d944ba
Replace custom event classes with ag_ui.core types 2025-12-18 12:07:37 +02:00
Yiorgis Gozadinos
f6618e6037
Merge pull request #198 from ggozad/feat/interactive-research
Interactive research through AG-UI in CLI & web example
2025-12-18 11:49:27 +02:00
Yiorgis Gozadinos
440ec123c6
If documents have no title display uri alone, ag-ui-example 2025-12-18 11:48:35 +02:00
Yiorgis Gozadinos
bf5711f7ab
Give hints in interactive cli 2025-12-18 11:48:35 +02:00
Yiorgis Gozadinos
3ec17c65e1
Update docs for interactive cli 2025-12-18 11:48:35 +02:00
Yiorgis Gozadinos
08349e5c0e
QOL fixes for interactive cli research 2025-12-18 11:48:35 +02:00
Yiorgis Gozadinos
9c766846c0
Use proper ag-ui tool calls in ag-ui-example. Fuck copilotkit 2025-12-18 11:48:35 +02:00
Yiorgis Gozadinos
72a39d07a2
Use development version through docker compose for ease of development 2025-12-18 11:48:35 +02:00
Yiorgis Gozadinos
34092e4cdc
Wrap client side tool call in a message 2025-12-18 11:48:34 +02:00
Yiorgis Gozadinos
ff13be1210
Human-in-the-loop in ag-ui-example 2025-12-18 11:48:34 +02:00
Yiorgis Gozadinos
9818cac4eb
Fix agui tool call args being a json string 2025-12-18 11:48:34 +02:00
Yiorgis Gozadinos
218126de8d
Interactive research agent through AGUI client-side tool calls in CLI 2025-12-18 11:48:34 +02:00
Yiorgis Gozadinos
b60c5583aa
Merge pull request #197 from ggozad/feat/simplify-graphs
Siimplify and unifyresearch and deep QA into a single configurable graph
2025-12-18 11:42:45 +02:00
Yiorgis Gozadinos
921893cff5
Remove deep ask from ag-ui server 2025-12-18 11:41:57 +02:00
Yiorgis Gozadinos
45de9bf0d5
Flatten graph structure, simplify 2025-12-18 11:41:57 +02:00
Yiorgis Gozadinos
6ee0d74f92
Update docs 2025-12-18 11:41:56 +02:00
Yiorgis Gozadinos
557a296341
Update ag-ui example 2025-12-18 11:41:53 +02:00
Yiorgis Gozadinos
6e621ea34d
Provide full report on deep ask, modify prompt to not just summarize 2025-12-18 11:41:11 +02:00
Yiorgis Gozadinos
f0a7abd953
Remove deep qa graph, everything now uses the simplified research graph 2025-12-18 11:41:10 +02:00
Yiorgis Gozadinos
bdcf81774d
Remove analyze insights node, simplify research context, state, prompts & models 2025-12-18 11:41:10 +02:00
Yiorgis Gozadinos
50b7fb4461
Chunkers now set chunk.order directly 2025-12-18 11:35:48 +02:00
Yiorgis Gozadinos
d77bf89aad
Update retrieval/qa benchmark for hotpotqa 2025-12-17 08:27:40 +02:00
Yiorgis Gozadinos
7bcd205a37
Merge pull request #194 from ggozad/fix/text-handling-format
Add 'plain' format and fallback for text conversion
2025-12-12 18:19:09 +02:00
Yiorgis Gozadinos
3149552a61
Add 'plain' format and fallback for text conversion 2025-12-12 18:11:25 +02:00
Yiorgis Gozadinos
679f58aff1
Merge pull request #193 from ggozad/fix/vacuum-evaluations
Evaluations: use periodic vacuum to prevent disk exhaustion with large datasets
2025-12-12 16:10:23 +02:00
Yiorgis Gozadinos
40e0ea090d
Add option to override vacuum interval 2025-12-12 16:04:06 +02:00
Yiorgis Gozadinos
056b9ad090
Use periodic vacuum to prevent disk exhaustion with large datasets 2025-12-12 15:53:23 +02:00
Yiorgis Gozadinos
3a96843851
Merge pull request #186 from ggozad/feat/hotpotqa
HotPotQA evaluations
2025-12-12 15:13:58 +02:00
Yiorgis Gozadinos
f726229f60
Reformat benchmarks and add hotpotqa placeholder 2025-12-12 12:33:31 +02:00
Yiorgis Gozadinos
7526735059
hotpotqa adapter 2025-12-12 10:56:37 +02:00
Yiorgis Gozadinos
5c4799164c
Escape when getting by URI 2025-12-12 10:56:37 +02:00
Yiorgis Gozadinos
f6073a0db9
vb 2025-12-12 10:48:21 +02:00
Yiorgis Gozadinos
1bc9d8579f
Merge pull request #192 from ggozad/fix/citations-formatting
Use rich rendering for citations in cli
2025-12-12 10:42:54 +02:00
Yiorgis Gozadinos
011f527579
Use rich rendering for citations in cli 2025-12-12 10:02:30 +02:00
Yiorgis Gozadinos
457354d45b
Merge pull request #191 from ggozad/fix/qa-research-prompts
Adjust prompts for better LLM schema compliance.
2025-12-12 08:52:19 +02:00
Yiorgis Gozadinos
abe01e4b99
Fix ag-ui-example frontend 2025-12-12 08:32:51 +02:00
Yiorgis Gozadinos
6f36085ac3
Fix synthesis prompt 2025-12-12 07:44:15 +02:00
Yiorgis Gozadinos
9ee8dfdc0c
Adapt prompts to clarify all lists must contain strings. Hopefully fixes LLM producing wrong output 2025-12-12 07:40:25 +02:00
Yiorgis Gozadinos
00d621e211
vb 2025-12-11 16:31:21 +02:00
Yiorgis Gozadinos
f25a949758
Fix tests 2025-12-11 16:05:49 +02:00
Yiorgis Gozadinos
ada24eaa11
Merge pull request #190 from ggozad/feat/chunk-lazy-load
Lazy load chunks & info modal for inspector
2025-12-11 15:39:19 +02:00
Yiorgis Gozadinos
c0cbc22137
Info modal 2025-12-11 15:38:45 +02:00
Yiorgis Gozadinos
09be31f021
Lazy load chunks in inspector 2025-12-11 15:38:19 +02:00
Yiorgis Gozadinos
448d00f9b2
Add pagination to chunk repo's get_by_document_id 2025-12-11 15:38:19 +02:00
Yiorgis Gozadinos
c43c8fab22
Switch modals, do not stack them 2025-12-11 15:38:19 +02:00
Yiorgis Gozadinos
1f7f27e68f
Merge pull request #189 from ggozad/feat/bidirectional-agui
Filtering in graphs & cli
2025-12-11 15:33:59 +02:00
Yiorgis Gozadinos
d7c755f454
Add filter to simple qa agent 2025-12-11 15:28:41 +02:00
Yiorgis Gozadinos
ecba8cfd38
Docs and cli support for filter in deep QA, Research 2025-12-11 15:23:50 +02:00
Yiorgis Gozadinos
9fd33a2d83
DocumentSelector in frontend to select documents to do research on. 2025-12-11 15:06:16 +02:00
Yiorgis Gozadinos
39cbec1b88
Set search filters from ducment filter in example 2025-12-11 14:38:23 +02:00
Yiorgis Gozadinos
5f4f5f70f0
Search filter in qa & research graphs 2025-12-11 14:37:43 +02:00
Yiorgis Gozadinos
b70fd8b7b4
Api for listing documents 2025-12-11 12:44:14 +02:00
Yiorgis Gozadinos
110e020122
Fix ag-ui example 2025-12-11 11:09:20 +02:00
Yiorgis Gozadinos
99176ba0a1
Merge pull request #188 from ggozad/fix/logfire-production
Use logfire in production if available, disable console output only.
2025-12-11 10:48:39 +02:00
Yiorgis Gozadinos
7ccabf4fb9
Log to console with logfire only in development 2025-12-11 10:47:18 +02:00
Yiorgis Gozadinos
5edae13cc0
Run logfire regardless of environment 2025-12-11 10:43:25 +02:00
Yiorgis Gozadinos
26b9d2ed20
Merge pull request #187 from ggozad/fix/custom-pipelines-docs
Fix docs to clarify the embedding includes contextualization
2025-12-11 10:37:37 +02:00
Yiorgis Gozadinos
4b5dc83552
Fix docs to clarify the embedding includes contextualization 2025-12-11 10:36:24 +02:00
Yiorgis Gozadinos
748a465591
vb 2025-12-10 13:10:28 +02:00
Yiorgis Gozadinos
2b69c4565d
Update server.json 2025-12-10 13:08:53 +02:00
Yiorgis Gozadinos
727f10a9a8
Merge pull request #172 from ggozad/epic/docling-document-store
DoclingDocument storage with visual grounding and processing primitives
2025-12-10 13:06:07 +02:00
Yiorgis Gozadinos
8206498af8
Documentation updates 2025-12-10 13:02:49 +02:00
Yiorgis Gozadinos
be0e3c3472
Update benchmarks 2025-12-10 12:04:07 +02:00
Yiorgis Gozadinos
2161727716
Add auto_vacuum config option to control automatic vacuuming 2025-12-10 11:42:14 +02:00
Yiorgis Gozadinos
ed809759d5
Fix mxbai rerank default model 2025-12-10 11:39:33 +02:00
Yiorgis Gozadinos
68457f251e
Do not generate base64 images by default 2025-12-09 18:42:56 +02:00
Yiorgis Gozadinos
2e06cf5277
Delete unecessary tests 2025-12-09 18:33:13 +02:00
Yiorgis Gozadinos
156bba3359
Infinite scroll lazy load document list in inspector 2025-12-09 17:48:27 +02:00
Yiorgis Gozadinos
9e16e8dc98
Fix text_context_radius references 2025-12-09 17:42:18 +02:00
Yiorgis Gozadinos
52acdcb56a
Update docs, include tuning document 2025-12-09 12:55:28 +02:00
Yiorgis Gozadinos
5a593e928e
Set max_context_items to 10 2025-12-09 12:47:42 +02:00
Yiorgis Gozadinos
802f058205
Move search related config under SearchConfig 2025-12-09 12:28:28 +02:00
Yiorgis Gozadinos
4da50873bd
Make search limit configurable 2025-12-09 11:46:43 +02:00
Yiorgis Gozadinos
a56e1ba67c
Simplify import_document, update_document 2025-12-08 18:19:12 +02:00
Yiorgis Gozadinos
a1162f8020
rebase from main 2025-12-08 16:07:50 +02:00
Yiorgis Gozadinos
67684c799b
Rebuild in batches to avoid creating multiple lancedb versions 2025-12-08 15:56:41 +02:00
Yiorgis Gozadinos
1b96937ec6
Update dependencies 2025-12-08 15:56:22 +02:00
Yiorgis Gozadinos
06b4f6a224
Add format parameter for text-to-DoclingDocument conversion 2025-12-08 15:56:21 +02:00
Yiorgis Gozadinos
1b14a4643d
Remove unused bounding box calculations 2025-12-08 15:56:21 +02:00
Yiorgis Gozadinos
9a5f8e4368
Add type-aware context expansion for search results 2025-12-08 15:56:21 +02:00
Yiorgis Gozadinos
dab04668bd
Dependency updates 2025-12-08 15:56:02 +02:00
Yiorgis Gozadinos
4f0f214f26
Remove unecessary tests, add no coverage pragmas where appropriate 2025-12-08 15:56:02 +02:00
Yiorgis Gozadinos
0ea3717497
Update docs 2025-12-08 15:56:02 +02:00
Yiorgis Gozadinos
2572125804
Move embedding logic from ChunkRepository to client._ensure_chunks_embedded() 2025-12-08 15:56:02 +02:00
Yiorgis Gozadinos
e97d235c98
Unify update_document, update_document_fields 2025-12-08 15:56:02 +02:00
Yiorgis Gozadinos
16ba3a9962
Remove create_chunks_for_document from repositories 2025-12-08 15:56:01 +02:00
Yiorgis Gozadinos
17c7147a49
Remove pre-processor, no longer needed 2025-12-08 15:56:01 +02:00
Yiorgis Gozadinos
69b1afa534
Remove _create_and_chunk & _update_and_rechunk, keep storage independent of chunking 2025-12-08 15:55:31 +02:00
Yiorgis Gozadinos
0ab43d62cd
Refactor create_document(), create_document_from_source() to use primitives 2025-12-08 15:55:30 +02:00
Yiorgis Gozadinos
e299aed788
contextualize() and embed_chunks() utilities in embeddings 2025-12-08 15:55:30 +02:00
Yiorgis Gozadinos
4fbb4609b6
HaikuRag.chunk() utility method 2025-12-08 15:55:30 +02:00
Yiorgis Gozadinos
ace915473c
HaikuRag.convert() utility method 2025-12-08 15:55:30 +02:00
Yiorgis Gozadinos
ab57c55eaa
Drop ChunkWithMetadata, use good old Chunk 2025-12-08 15:55:30 +02:00
Yiorgis Gozadinos
33e6a36290
Make document import/update handling consistent for DoclingDocument 2025-12-08 15:55:29 +02:00
Yiorgis Gozadinos
60ab864fc1
Add docling_document_json to update_document_fields() and 2025-12-08 15:55:29 +02:00
Yiorgis Gozadinos
8e30f67266
Introduce import_document(), remove chunks from create_document() 2025-12-08 15:55:29 +02:00
Yiorgis Gozadinos
fe551066d8
Visual grounding docs 2025-12-08 15:55:29 +02:00
Yiorgis Gozadinos
de14bb9f90
Avoid citation meta inside LLM context 2025-12-08 15:55:28 +02:00
Yiorgis Gozadinos
a57203065a
Fix test 2025-12-08 15:55:28 +02:00
Yiorgis Gozadinos
0f5b9ae208
Update ag-ui-example UI with STATE_DELTA instead of ACTIVITY_SNAPSHOT since these are not handled by copilotkit 2025-12-08 15:55:28 +02:00
Yiorgis Gozadinos
84f25f0627
Visual grounding in ag-ui-example 2025-12-08 15:55:28 +02:00
Yiorgis Gozadinos
6e31ea38a8
Remove bounding boxes from citations, pass sources meta info from ResearchContext 2025-12-08 15:55:28 +02:00
Yiorgis Gozadinos
75a086bae0
Visualize chunk command, format citations to show chunk id 2025-12-08 15:55:28 +02:00
Yiorgis Gozadinos
910d382748
Introduce visualize_chunk(), refactor inspector to use it 2025-12-08 15:55:27 +02:00
Yiorgis Gozadinos
bfdaa4652a
Configure logging regardsless of the env 2025-12-08 15:55:27 +02:00
Yiorgis Gozadinos
b5bd56e110
Fix after rebase 2025-12-08 15:55:27 +02:00
Yiorgis Gozadinos
9382a6b132
expose citations in ag-ui-example 2025-12-08 15:55:27 +02:00
Yiorgis Gozadinos
d808c6c425
Simplify citations in qa & graph agents 2025-12-08 15:55:27 +02:00
Yiorgis Gozadinos
16a97cc140
Properly expand context in the case of docling documents and merge chunks and meta 2025-12-08 15:55:26 +02:00
Yiorgis Gozadinos
ac439b6c69
Store raw text (do not contextualize) in chunk.text. Embed with contextualized text. 2025-12-08 15:55:26 +02:00
Yiorgis Gozadinos
0d8691342a
Show bounding boxes for chunks in inspector 2025-12-08 15:55:26 +02:00
Yiorgis Gozadinos
6ab990b2ed
Always generate page images, default in docling-serve 2025-12-08 15:54:57 +02:00
Yiorgis Gozadinos
ace58a7034
Update evaluations and a2a 2025-12-08 15:54:56 +02:00
Yiorgis Gozadinos
c69b934eac
Incorporate additional docling meta in search(), expand_context() and relevant qa/research agents 2025-12-08 15:54:56 +02:00
Yiorgis Gozadinos
fe4ac530be
DB migration 2025-12-08 15:54:56 +02:00
Yiorgis Gozadinos
d267b2c433
LRU cache for DoclingDocument 2025-12-08 15:54:31 +02:00
Yiorgis Gozadinos
a5d8af07e9
Resolve DoclingDocument 2025-12-08 15:54:31 +02:00
Yiorgis Gozadinos
84aa46a10b
Update rebuild to handle DoclingDocument json 2025-12-08 15:54:31 +02:00
Yiorgis Gozadinos
ad4e79ab5d
Store DoclingDocument json 2025-12-08 15:54:31 +02:00
Yiorgis Gozadinos
343272d6fb
Chunkers return chunks with metadata, i.e. (refs, labels, headings, page_numbers) 2025-12-08 15:54:31 +02:00
Yiorgis Gozadinos
44e8d7b340
Update Document & Chunk schemas to include DoclingDocument references. 2025-12-08 15:54:30 +02:00
Yiorgis Gozadinos
e7f2981b13
Merge pull request #179 from ggozad/feat/client-download-models
Make download_models() a HaikuRAG client method, show updates in app.
2025-12-08 15:51:54 +02:00
Yiorgis Gozadinos
0dc4458070
Fix qa accuracy table for wix 2025-12-06 12:01:13 +02:00
Yiorgis Gozadinos
80447c9024
Make download_models() a HaikuRAG client method. Update app to show progress of what happens. 2025-12-04 16:30:26 +02:00
Yiorgis Gozadinos
e890add8cc
pyproject keywords 2025-12-03 08:59:22 +02:00
Yiorgis Gozadinos
4e30637d61
vb 2025-12-03 08:57:14 +02:00
Yiorgis Gozadinos
f631e74bf1
Merge pull request #176 from ggozad/fix/embedding-model-config
Always perform db upgrades, introduce `init` to create a db, update embeddings configuration.
2025-12-03 08:54:59 +02:00
Yiorgis Gozadinos
78f1d0cef5
No warnings, just info on db upgrade 2025-12-03 08:51:25 +02:00
Yiorgis Gozadinos
33b254ae3d
Always upgrade database on access. Explicitly create db by running init or HaikuRAG(path, create=True) 2025-12-02 16:49:55 +02:00
Yiorgis Gozadinos
47d8f7ba3f
Update docs 2025-12-02 12:01:10 +02:00
Yiorgis Gozadinos
3525fae625
Use EmbeddingModelConfig similar to ModelConfig for embeddings 2025-12-02 11:55:31 +02:00
Yiorgis Gozadinos
f07590bd5b
vb 2025-12-01 17:58:47 +02:00
Yiorgis Gozadinos
80ff2fa509
Merge pull request #174 from ggozad/fix/optimize-batch-update-chunks
Optimized `rebuild --embed-only` to use batch updates
2025-12-01 17:57:51 +02:00
Yiorgis Gozadinos
e548a51ff7
Optimized rebuild --embed-only to use batch updates via LanceDB merge_insert instead of individual chunk updates 2025-12-01 17:53:27 +02:00
Yiorgis Gozadinos
3e18a5f2ff
Update project description 2025-11-29 11:18:53 +02:00
Yiorgis Gozadinos
37c9d088af
Merge pull request #173 from ggozad/chore/improve-documentation
Improve documentation
2025-11-28 13:11:30 +02:00
Yiorgis Gozadinos
e10b207b47
Improve documentation 2025-11-28 13:10:03 +02:00
Yiorgis Gozadinos
5dfa07cb2c
Update benchmarks 2025-11-28 12:10:08 +02:00
Yiorgis Gozadinos
10aa2a7cf2
vb 2025-11-28 10:15:59 +02:00
Yiorgis Gozadinos
fc44cb2286
Merge pull request #171 from ggozad/feat/rebuild-options
New options for `rebuild` command to control what gets rebuilt
2025-11-28 10:13:37 +02:00
Yiorgis Gozadinos
88a4edb537
--embed-only, --rechunk options in rebuild command 2025-11-28 10:11:52 +02:00
Yiorgis Gozadinos
9302de0aff
Merge pull request #170 from ggozad/fix/docling-tables-local
Add opencv-python-headless to docling optional dependency, required for table detection.
2025-11-27 16:23:41 +02:00
Yiorgis Gozadinos
8a0cc04181
Add opencv-python-headless to docling optional dependency, required for TableStructureModel which uses TableFormer for table detection. 2025-11-27 16:22:37 +02:00
Yiorgis Gozadinos
eee29578ba
vb 2025-11-27 15:24:39 +02:00
Yiorgis Gozadinos
5d00bbf6dd
Merge pull request #169 from ggozad/fix/docling-fixes
Fix docling OCR options.
2025-11-27 15:24:02 +02:00
Yiorgis Gozadinos
b4602b0cab
use OcrAutoOptions for automatic OCR engine selection. 2025-11-27 15:23:17 +02:00
Yiorgis Gozadinos
51104ddb5a
Use httpx for docling-serve 2025-11-27 15:06:13 +02:00
Yiorgis Gozadinos
3959ce39f7
vb 2025-11-27 13:06:04 +02:00
Yiorgis Gozadinos
f6956341ec
Merge pull request #168 from ggozad/fix/async-fixes
Wrap synchronous calls to async.
2025-11-27 13:01:56 +02:00
Yiorgis Gozadinos
bdbd3ce556
cl 2025-11-27 10:43:23 +02:00
Yiorgis Gozadinos
ed53ec461a
Convert prefetch_models() to async 2025-11-26 18:08:48 +02:00
Yiorgis Gozadinos
e40c352026
Switch from requests to httpx.AsyncClient for docling-serve 2025-11-26 18:00:29 +02:00
Yiorgis Gozadinos
14bdb7cb27
async versions for convert_file() convert_text() 2025-11-26 17:46:25 +02:00
Yiorgis Gozadinos
6e24d5772b
Make sure haiku.rag always requires same version of haiku.rag-slim 2025-11-26 17:14:58 +02:00
Yiorgis Gozadinos
ca2445d869
vb 2025-11-26 10:33:46 +02:00
Yiorgis Gozadinos
e188051fb3
Merge pull request #164 from ggozad/feat/lm_studio
Support LMStudio
2025-11-26 10:32:11 +02:00
Yiorgis Gozadinos
0303e6a8cd
Support LMStudio 2025-11-26 10:31:40 +02:00
Yiorgis Gozadinos
7876830eca
Merge pull request #166 from ggozad/fix/init-config
Fix `init-config` command generating invalid configuration files
2025-11-26 10:30:44 +02:00
Yiorgis Gozadinos
d30ac60a75
cl 2025-11-26 10:29:00 +02:00
Yiorgis Gozadinos
5612ecaee7
Fix documentation configuration index 2025-11-26 10:24:53 +02:00
Yiorgis Gozadinos
283c44867d
Generate the config from the AppConfig model directly, add tests 2025-11-26 10:17:22 +02:00
Yiorgis Gozadinos
1008e6e10f
Fix broken links 2025-11-25 14:27:44 +02:00
Yiorgis Gozadinos
2c554a9350
Do not use private methods in tests 2025-11-25 14:19:28 +02:00
Yiorgis Gozadinos
c22a5bb0ce
vb 2025-11-25 13:44:48 +02:00
Yiorgis Gozadinos
b9f608a039
Merge pull request #163 from ggozad/feat/model-customizations
Support for per-model configuration settings such as thinking, temperature, max_tokens
2025-11-25 13:14:30 +02:00
Yiorgis Gozadinos
8a8005ead0
Add qa/judge models meta to experiment meta 2025-11-25 13:09:29 +02:00
Yiorgis Gozadinos
f843fb8140
Use non-thinking judge in evals 2025-11-25 13:00:25 +02:00
Yiorgis Gozadinos
e83978f88b
Revert embeddings to use flat config 2025-11-25 12:51:58 +02:00
Yiorgis Gozadinos
ba4dddeeaf
Make thinking false by default 2025-11-25 12:35:38 +02:00
Yiorgis Gozadinos
a9701616c8
Rename model to name under model 2025-11-25 12:28:43 +02:00
Yiorgis Gozadinos
41ea675964
Update and restructure docs 2025-11-25 12:14:24 +02:00
Yiorgis Gozadinos
1bb7b6d5bd
Add support for per-model configuration settings including thinking, temperature and max_tokens 2025-11-25 12:14:24 +02:00
Yiorgis Gozadinos
bfcbbbb91f
Merge pull request #162 from ggozad/feat/eval-dbs
Set default evaluation  dataset db location, create evaluation script
2025-11-25 11:09:04 +02:00
Yiorgis Gozadinos
4e1752a740
Default eval db location, evaluation script 2025-11-25 11:07:20 +02:00
Yiorgis Gozadinos
b41a7db21a
Merge pull request #159 from ggozad/feat/agui-improvements
Enhanced ActivitySnapshot events with richer structured data
2025-11-24 16:27:08 +02:00
Yiorgis Gozadinos
13d3a366e8
Enhanced ActivitySnapshot events with richer structured data 2025-11-24 16:18:27 +02:00
Yiorgis Gozadinos
3bdbd10516
Merge pull request #160 from ggozad/feat/evaluation-enhancements
Evaluation improvements (metrics/utils).
2025-11-24 15:02:42 +02:00
Yiorgis Gozadinos
03c1f356b2
Update qa agent search limit to 5 2025-11-24 15:01:31 +02:00
Yiorgis Gozadinos
b5892699a0
Use Mean Reciprocal Rank for single document evaluation as metric. Use Mean Average Precision for variable document evaluation as metric 2025-11-24 14:49:30 +02:00
Yiorgis Gozadinos
bb3526c957
Merge pull request #158 from ggozad/feat/evaluations-meta
Record useful meta in evaluations experiments
2025-11-24 10:46:36 +02:00
Yiorgis Gozadinos
8d0846b88a
Record useful meta in the experiment 2025-11-24 10:46:25 +02:00
Yiorgis Gozadinos
b4c6f117a4
Merge pull request #155 from ggozad/feat/inspect
TUI db inspector
2025-11-22 17:53:49 +02:00
Yiorgis Gozadinos
0c95a9f2e7
cl 2025-11-22 17:53:13 +02:00
Yiorgis Gozadinos
727efe6b71
Update screenshot 2025-11-22 17:51:06 +02:00
Yiorgis Gozadinos
0b92eae75f
Docs 2025-11-22 17:46:21 +02:00
Yiorgis Gozadinos
fe14e73514
Benchmarks for win qwen3-embedding:4b mxbai-rerank-base-v2 2025-11-21 18:57:07 +02:00
Yiorgis Gozadinos
2c6efc55c6
Make search a screen 2025-11-21 17:06:30 +02:00
Yiorgis Gozadinos
0d126d894e
Keep documents when moving from main view to search and back 2025-11-21 16:48:35 +02:00
Yiorgis Gozadinos
59eba31267
Handle missing textual dependencies 2025-11-21 16:48:35 +02:00
Yiorgis Gozadinos
8450c07085
Fix markdown 2025-11-21 16:48:35 +02:00
Yiorgis Gozadinos
82831c8941
Search in inspector 2025-11-21 16:48:34 +02:00
Yiorgis Gozadinos
2c1c530f3a
Fix tabbing 2025-11-21 16:48:34 +02:00
Yiorgis Gozadinos
89a2668c25
inspector command that runs a textual app to browse documents and chunks in the database 2025-11-21 16:48:34 +02:00
Yiorgis Gozadinos
ada2b66d5b
vb 2025-11-21 16:38:52 +02:00
Yiorgis Gozadinos
7fefd1116d
Merge pull request #154 from ggozad/chore/default-embedding
Change default embedding model to qwen3-embedding:4b
2025-11-21 16:37:28 +02:00
Yiorgis Gozadinos
8c02e865c7
Change default embedding model to qwen3-embedding:4b 2025-11-21 16:30:41 +02:00
Yiorgis Gozadinos
28088aa31d
Merge pull request #148 from ggozad/feat/vector-index
Vector indexing & Search accuracy improvements
2025-11-21 16:28:45 +02:00
Yiorgis Gozadinos
6d8ab2575d
Clarify refine_factor, set default to 30 2025-11-21 15:53:24 +02:00
Yiorgis Gozadinos
9a1e7f211c
Update benchmarks 2025-11-21 15:31:36 +02:00
Yiorgis Gozadinos
2f54ee7d59
Replace allow_create with cleaner read_only. Elaborate on the test_info tests 2025-11-21 14:37:33 +02:00
Yiorgis Gozadinos
dbb2837550
Add a note explaining when indexing is necessary 2025-11-21 14:11:31 +02:00
Yiorgis Gozadinos
5477446c5d
Update docs 2025-11-21 13:54:04 +02:00
Yiorgis Gozadinos
fdcf9a1a12
Show index stats in cli info command 2025-11-21 13:54:04 +02:00
Yiorgis Gozadinos
6a2f33b464
Create vector index from CLI, use configurable refine_factor 2025-11-21 13:54:01 +02:00
Yiorgis Gozadinos
cfe54c53bb
Merge pull request #153 from ggozad/feat/eval-naming
Name evaluation runs. Use only LLMJudge evaluator.
2025-11-21 13:39:06 +02:00
Yiorgis Gozadinos
63b374c7e9
Name evaluation runs. Use only LLMJudge evaluator. 2025-11-21 13:26:39 +02:00
Yiorgis Gozadinos
a4b82982c4
Merge pull request #151 from ggozad/fix/graph-config
Allow custom config when building research/deep ask graphs.
2025-11-21 12:45:07 +02:00
Yiorgis Gozadinos
c45d507213
Allow custom config when building research/deep ask graphs. Closes #149 2025-11-21 12:37:41 +02:00
Yiorgis Gozadinos
d8f1f3fe92
Merge pull request #152 from ggozad/fix/agui-activity
Fix ACTIVITY_SNAPSHOT payloads.
2025-11-21 12:35:28 +02:00
Yiorgis Gozadinos
5c4a32f1c8
Fix ACTIVITY_SNAPSHOT payloads. Closes #150 2025-11-21 12:27:54 +02:00
Yiorgis Gozadinos
09900f8e8b
vb 2025-11-19 13:08:37 +02:00
Yiorgis Gozadinos
cd55c5be5b
Merge pull request #147 from ggozad/chore/batch-chunk-insertion
Accept both single chunks and lists for batch insertion in ChunkRepoitory.create()
2025-11-19 12:41:59 +02:00
Yiorgis Gozadinos
30a0bbcff2
Accept both single chunks and lists for batch insertion in ChunkRepository.create() 2025-11-19 12:35:02 +02:00
Yiorgis Gozadinos
1ecc7152d3
Merge pull request #146 from ggozad/feat/update-document-chunks
New `update_document_fields()` method for partial document updates
2025-11-19 11:38:54 +02:00
Yiorgis Gozadinos
5089ea4203
New update_document_fields() method for partial document updates 2025-11-19 11:37:36 +02:00
Yiorgis Gozadinos
e5de98020a
Merge pull request #145 from ggozad/chore/updates
Update dependencies
2025-11-19 10:25:16 +02:00
Yiorgis Gozadinos
0ea5f1e10e
Update dependencies 2025-11-19 10:24:37 +02:00
Yiorgis Gozadinos
826986708d
vb 2025-11-18 15:52:52 +02:00
Yiorgis Gozadinos
e3627c92d4
Merge pull request #144 from ggozad/feat/additional-conversion-options
Additional options for document conversion with docling
2025-11-18 15:49:48 +02:00
Yiorgis Gozadinos
0bb3f4301c
Additional options for document conversion with docling 2025-11-18 15:44:04 +02:00
Yiorgis Gozadinos
2dca72d670
Revert "Handle client-side tool calls in graph ag-ui emitter"
This reverts commit b1d19651d5.
2025-11-18 15:19:28 +02:00
Yiorgis Gozadinos
927bf59189
Merge pull request #142 from ggozad/chore/build-docker-slim-only
Main `haiku.rag` docker image no longer automatically built and published
2025-11-18 14:48:58 +02:00
Yiorgis Gozadinos
f728fea489
Stop building full docker image 2025-11-18 14:48:34 +02:00
Yiorgis Gozadinos
0a9c31cf74
Merge pull request #143 from ggozad/fix/rerank-config
Increase reranking candidate retrieval multiplier from 3x to 10x for improved result quality
2025-11-18 14:46:52 +02:00
Yiorgis Gozadinos
cc09ce87c2
Increase reranking candidate retrieval multiplier from 3x to 10x for improved result quality 2025-11-18 14:35:57 +02:00
Yiorgis Gozadinos
b1d19651d5
Handle client-side tool calls in graph ag-ui emitter 2025-11-18 14:10:21 +02:00
Yiorgis Gozadinos
efbd5b92f7
vb 2025-11-17 16:30:39 +02:00
Yiorgis Gozadinos
bfe871026e
Merge pull request #141 from ggozad/feat/docling-serve
Add remote processing with docling-serve for conversion and chunking
2025-11-17 16:29:48 +02:00
Yiorgis Gozadinos
4b0a1f0038
Update ag-ui-research example with haiku.rag-slim image 2025-11-17 14:42:14 +02:00
Yiorgis Gozadinos
d197fb5a35
Update docker example & relevant docs 2025-11-17 14:21:08 +02:00
Yiorgis Gozadinos
a31fcad6b0
haiku.rag-slim docker image 2025-11-17 14:18:41 +02:00
Yiorgis Gozadinos
2f0811da28
Run uv sync when bumping versions 2025-11-17 13:17:46 +02:00
Yiorgis Gozadinos
da4179678b
Update changelog 2025-11-17 13:11:11 +02:00
Yiorgis Gozadinos
973522ffe2
Documentation 2025-11-17 13:07:46 +02:00
Yiorgis Gozadinos
81b7c7d9d1
Tests for docling-serve chunker 2025-11-17 13:05:06 +02:00
Yiorgis Gozadinos
91de82ec14
docling-serve chunker implementation. Lacks tests 2025-11-17 13:05:06 +02:00
Yiorgis Gozadinos
75cbdb47a8
Add chunker configuration options
- Add `chunker_type` config field to choose between "hybrid" (default) and "hierarchical" chunking strategies
  - Add `chunking_merge_peers` config field for HybridChunker (default: true)
  - Add `chunking_use_markdown_tables` config field to control table serialization format (default: false, matching docling's default)
2025-11-17 13:05:06 +02:00
Yiorgis Gozadinos
de5bb117fe
Chunker abstraction 2025-11-17 13:05:06 +02:00
Yiorgis Gozadinos
541552215e
Switch to using HuggingFace tokenizers 2025-11-17 13:05:05 +02:00
Yiorgis Gozadinos
fe7d0c5c24
Implement docling-serve converter 2025-11-17 13:05:05 +02:00
Yiorgis Gozadinos
92def1c6a5
Rebase after haiku.rag-slim migration 2025-11-17 13:05:05 +02:00
Yiorgis Gozadinos
eb2b65135a
Lazy load docling & friends 2025-11-17 13:05:05 +02:00
Yiorgis Gozadinos
7ba1c2376e
Introduce converters for supporting more than local docling document conversion. Transform existing FileReader and utils to "docling-local" converter 2025-11-17 13:05:05 +02:00
Yiorgis Gozadinos
636d5261e6
vb 2025-11-14 22:29:37 +02:00
Yiorgis Gozadinos
dcba9d9a0e
Benchmarks for qwen3-embedding:0.6b 2025-11-14 15:36:36 +02:00
Yiorgis Gozadinos
8a697249f4
Merge pull request #140 from ggozad/fix/evals-experiment
Run entire evaluation dataset as an experiment instead of individual test cases
2025-11-14 11:48:14 +02:00
Yiorgis Gozadinos
adb7d9093d
Run entire evaluation dataset as one so that it appears properly in logfire 2025-11-14 11:39:58 +02:00
Yiorgis Gozadinos
c8999a89b8
Update and lock pydantic-ai-slim 2025-11-14 11:26:09 +02:00
Yiorgis Gozadinos
f55b8c8f19
Load .env in evals 2025-11-14 11:00:58 +02:00
Yiorgis Gozadinos
1fa843c3de
Update uv.lock 2025-11-14 10:40:58 +02:00
Yiorgis Gozadinos
6eb0d59a28
vb 2025-11-13 15:00:57 +02:00
Yiorgis Gozadinos
7140b3410f
Merge pull request #139 from ggozad/fix/create-db-only-on-write
Drop disable_auto_create flag, simply create only on write operations
2025-11-13 13:58:14 +02:00
Yiorgis Gozadinos
4599dd1481
Drop disable_auto_create flag, simply create only on write operations 2025-11-13 13:53:11 +02:00
Yiorgis Gozadinos
8e802d4a91
Default vacuum_retention_seconds increased from 60 seconds to 86400 seconds (1 day) for better version retention in typical workflows 2025-11-13 13:31:53 +02:00
Yiorgis Gozadinos
cb1001dc29
Merge pull request #136 from ggozad/feat/ag-ui
AG-UI support in research / deep ask graphs.
2025-11-13 13:30:41 +02:00
Yiorgis Gozadinos
2c43f034de
Refactor ag-ui-research. Drop human-in-the-loop, use MemoryObjectSendStream to merge the graph and agent streams together 2025-11-13 13:22:53 +02:00
Yiorgis Gozadinos
4a623934b2
Log additional ag-ui events in the cli renderer 2025-11-13 13:22:52 +02:00
Yiorgis Gozadinos
0c8d2838b2
Better typing 2025-11-13 13:22:52 +02:00
Yiorgis Gozadinos
68d1127e46
Compute and emit state deltas instead of full state snapshots by default 2025-11-13 13:22:52 +02:00
Yiorgis Gozadinos
657245f686
Simplify InsightRecord, GapRecord 2025-11-13 13:22:52 +02:00
Yiorgis Gozadinos
2060898018
Use a node factor for common nodes 2025-11-13 13:22:52 +02:00
Yiorgis Gozadinos
888c9e338e
Changelog 2025-11-13 13:22:51 +02:00
Yiorgis Gozadinos
032625b0bb
Refactor everything graph-related under the graph module 2025-11-13 13:22:03 +02:00
Yiorgis Gozadinos
8f0597e89e
Update deep ask graph 2025-11-13 13:22:03 +02:00
Yiorgis Gozadinos
ed27acc1a2
Update docs 2025-11-13 13:22:02 +02:00
Yiorgis Gozadinos
d5b6ad544d
Bring back verbose 2025-11-13 13:22:02 +02:00
Yiorgis Gozadinos
dccef431b0
Update tests 2025-11-13 13:22:02 +02:00
Yiorgis Gozadinos
60ef8fe04c
AGUI starlette server 2025-11-13 13:22:02 +02:00
Yiorgis Gozadinos
2c1f79614a
Use StateSnapshot updates 2025-11-13 13:22:02 +02:00
Yiorgis Gozadinos
663a7fac9a
Replace console/stream with AG-UI event protocol 2025-11-13 13:22:01 +02:00
Yiorgis Gozadinos
da77e0f2f9
Convert ResearchState from dataclass to Pydantic model 2025-11-13 13:22:01 +02:00
Yiorgis Gozadinos
765ebb698f
AG-UI Event-emitter for graphs. Compute state-deltas from from BaseModel pydantic state models 2025-11-13 13:22:01 +02:00
Yiorgis Gozadinos
d2771399a7
Merge pull request #138 from ggozad/fix/create-with-chunks
Do not create unecessary DoclingDocument when chunks are provided.
2025-11-13 13:20:39 +02:00
Yiorgis Gozadinos
908296e155
Do not create unecessary DoclingDocument when chunks are provided. Rename private _create_with_docling & _update_with_docling to _create_and_chunk & _update_and_rechunk 2025-11-13 13:16:15 +02:00
Yiorgis Gozadinos
942ad2818f
Filereader error messages now include both original exception details and file path for easier debugging 2025-11-10 18:26:25 +02:00
740 changed files with 395504 additions and 33948 deletions

View file

@ -0,0 +1,20 @@
{
"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

@ -0,0 +1,14 @@
{
"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

@ -0,0 +1,295 @@
---
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

@ -0,0 +1,25 @@
#!/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

@ -0,0 +1,203 @@
---
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,26 +3,37 @@ on:
push:
branches:
- main
pull_request:
branches:
- main
permissions:
contents: write
contents: read
pages: write
id-token: write
concurrency:
group: pages-${{ github.ref }}
cancel-in-progress: false
jobs:
deploy:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- 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
- 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'
with:
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
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

View file

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

View file

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

View file

@ -14,9 +14,9 @@ jobs:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Generate server.json from template
@ -26,9 +26,11 @@ jobs:
mv server.json.tmp server.json
echo "Generated server.json with version: $VERSION"
- name: Install MCP Publisher
env:
GH_TOKEN: ${{ github.token }}
run: |
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
gh release download --repo modelcontextprotocol/registry \
--pattern '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/

105
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,105 @@
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,6 +5,7 @@ build/
dist/
wheels/
*.egg-info
**/.DS_Store
# Virtual environments
.venv
@ -12,6 +13,8 @@ wheels/
# tests
.coverage*
evaluations/evaluations/data/
evaluations/scripts/*
!evaluations/scripts/build_t2_submission.py
tests/data/
.pytest_cache/
.ruff_cache/
@ -27,5 +30,14 @@ DEVNOTES.md
.mcpregistry_github_token
.mcpregistry_registry_token
# MkDocs site directory when doing local docs builds
# zensical 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,22 +1,36 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
- id: check-toml
- id: debug-statements
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.14.3
- repo: local
hooks:
# Run the linter.
- id: ruff
# Run the formatter.
name: ruff check
entry: uv run ruff check --force-exclude
language: system
types: [python]
- 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: https://github.com/RobertCraigie/pyright-python
rev: v1.1.407
- repo: local
hooks:
- id: pyright
- 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]

File diff suppressed because it is too large Load diff

216
README.md
View file

@ -1,22 +1,37 @@
# Haiku RAG
# haiku.rag
Retrieval-Augmented Generation (RAG) library built on LanceDB.
[![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)
`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.
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/).
## Features
- **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
- **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
## Installation
@ -25,146 +40,139 @@ Retrieval-Augmented Generation (RAG) library built on LanceDB.
### Full Package (Recommended)
```bash
uv pip install haiku.rag
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
uv pip install haiku.rag-slim
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
# 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
# Index a PDF
haiku-rag add-src paper.pdf
# Search
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?"
haiku-rag search "attention mechanism"
# Ask questions with citations
haiku-rag ask "Who is the author of haiku.rag?" --cite
haiku-rag ask "What datasets were used for evaluation?"
# Deep QA (multi-agent question decomposition)
haiku-rag ask "Who is the author of haiku.rag?" --deep --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 with verbose output
haiku-rag ask "Who is the author of haiku.rag?" --deep --verbose
# Analyze — complex analytical tasks via code execution
haiku-rag analyze "How many documents mention transformers?"
# 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
# Interactive chat — multi-turn conversations with memory
haiku-rag chat
# Rebuild database (re-chunk and re-embed all documents)
haiku-rag rebuild
# Start server with file monitoring
haiku-rag serve --monitor
# Continuously ingest from configured sources (FS, HTTP, S3, WebDAV)
haiku-ingester serve
```
To customize settings, create a `haiku.rag.yaml` config file (see [Configuration](https://ggozad.github.io/haiku.rag/configuration/)).
See [Configuration](https://ggozad.github.io/haiku.rag/configuration/) for customization options.
## Python Usage
## Python API
```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("database.lancedb") as client:
# Add document
doc = await client.create_document("Your content")
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")
# Search (reranking enabled by default)
results = await client.search("query")
for chunk, score in results:
print(f"{score:.3f}: {chunk.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]}")
# Ask questions
answer = await client.ask("Who is the author of haiku.rag?")
# QA with citations
answer, citations = await rag.ask("What is the complexity of self-attention?")
print(answer)
# 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)
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/).
## MCP Server
Use with AI assistants like Claude Desktop:
Use with AI assistants like Claude Code, Codex, and Claude Desktop:
```bash
haiku-rag serve --stdio
haiku-rag mcp --stdio
```
Provides tools for document management and search directly in your AI assistant.
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.
## Examples
See the [examples directory](examples/) for working examples:
- **[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
- **[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
## Documentation
Full documentation at: https://ggozad.github.io/haiku.rag/
- [Installation](https://ggozad.github.io/haiku.rag/installation/) - Provider setup
- [Configuration](https://ggozad.github.io/haiku.rag/configuration/) - YAML configuration
- [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
- [CLI](https://ggozad.github.io/haiku.rag/cli/) - Command reference
- [Python API](https://ggozad.github.io/haiku.rag/python/) - Complete API docs
- [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
- [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
## 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

11
app/.env.example Normal file
View file

@ -0,0 +1,11 @@
# 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

110
app/README.md Normal file
View file

@ -0,0 +1,110 @@
# 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

36
app/backend/Dockerfile Normal file
View file

@ -0,0 +1,36 @@
# 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"]

296
app/backend/main.py Normal file
View file

@ -0,0 +1,296 @@
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

@ -0,0 +1,26 @@
[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

@ -0,0 +1,46 @@
# 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:

31
app/docker-compose.yml Normal file
View file

@ -0,0 +1,31 @@
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

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

30
app/frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,30 @@
# 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

35
app/frontend/Dockerfile Normal file
View file

@ -0,0 +1,35 @@
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

@ -6,22 +6,16 @@ import {
} from "@copilotkit/runtime";
import type { NextRequest } from "next/server";
// Connect CopilotKit to PydanticAI via HttpAgent
// The HttpAgent creates a bridge between the Next.js frontend and the Python backend
// It communicates with the server created by agent.to_ag_ui()
const runtime = new CopilotRuntime({
agents: {
// "research_agent" maps to the agent name used in useCoAgent() on the frontend
research_agent: new HttpAgent({
url: `${process.env.BACKEND_URL || "http://backend:8000"}/agent`,
chat_agent: new HttpAgent({
url: `${process.env.BACKEND_URL || "http://backend:8000"}/v1/chat/stream`,
}),
},
});
// Service adapter for multi-agent support (empty since we only have one agent)
const serviceAdapter = new ExperimentalEmptyAdapter();
// Next.js API route handler that proxies requests between frontend and backend
export async function POST(request: NextRequest) {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,

View file

@ -0,0 +1,16 @@
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

@ -0,0 +1,16 @@
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

@ -0,0 +1,26 @@
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 });
}
}

1231
app/frontend/app/globals.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,10 @@
import type { Metadata } from "next";
import "@copilotkit/react-core/v2/styles.css";
import "./globals.css";
export const metadata: Metadata = {
title: "Haiku.rag Research Assistant",
description:
"Interactive research powered by Haiku.rag, Pydantic AI, and AG-UI",
title: "haiku.rag Chat",
description: "Conversational RAG powered by haiku.rag and AG-UI",
};
export default function RootLayout({

View file

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

View file

@ -1,9 +1,9 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.6/schema.json",
"vcs": {
"enabled": false,
"enabled": true,
"clientKind": "git",
"useIgnoreFile": false
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
@ -15,7 +15,11 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true
"recommended": true,
"a11y": {
"noAutofocus": "off",
"noSvgWithoutTitle": "off"
}
}
},
"javascript": {

View file

@ -0,0 +1,482 @@
"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

@ -0,0 +1,226 @@
"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

@ -0,0 +1,83 @@
"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

@ -0,0 +1,205 @@
"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

@ -0,0 +1,268 @@
"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

@ -0,0 +1,18 @@
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

@ -0,0 +1,46 @@
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

@ -0,0 +1,191 @@
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);
}

30
app/frontend/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"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"
}
}

7823
app/frontend/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,41 @@
{
"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

@ -0,0 +1,47 @@
# 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
uv sync --frozen --no-install-project --no-dev --extra ingester
# Install the project itself
# 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
uv sync --frozen --no-editable --no-dev --extra ingester
# Final layer
FROM python:3.13-slim
@ -33,8 +33,11 @@ ENV DEFAULT_DATA_DIR=/data
ENV PATH="/app/.venv/bin:$PATH"
# Expose port for MCP server
EXPOSE 8001
# 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
# Run all services (monitoring, MCP)
CMD ["python", "-m", "haiku.rag.cli", "serve", "--monitor", "--mcp", "--mcp-port", "8001", "--db", "/data/haiku.rag.lancedb"]
# 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"]

42
docker/Dockerfile.slim Normal file
View file

@ -0,0 +1,42 @@
# 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,13 +1,21 @@
# haiku.rag Docker Image
Pre-built images are available at `ghcr.io/ggozad/haiku.rag` with all extras (voyageai, mxbai).
The full haiku.rag Docker image includes all features and extras (docling, voyageai, cross-encoder). You can build it locally using the provided Dockerfile.
## Using Pre-built Image
## Building the Image
Build the full image with all features:
```bash
docker pull ghcr.io/ggozad/haiku.rag:latest
docker build -f docker/Dockerfile -t haiku-rag .
```
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`:
@ -17,13 +25,15 @@ Create a configuration file `haiku.rag.yaml`:
environment: production
embeddings:
provider: ollama
model: nomic-embed-text
vector_dim: 768
model:
provider: ollama
name: nomic-embed-text
vector_dim: 768
qa:
provider: ollama
model: qwen3
model:
provider: ollama
name: qwen3
```
See [Configuration docs](https://ggozad.github.io/haiku.rag/configuration/) for all available options.
@ -34,27 +44,50 @@ Mount your config file and data directory:
```bash
docker run -p 8001:8001 \
-v $(pwd)/haiku.rag.yaml:/app/haiku.rag.yaml \
-v $(pwd)/data:/data \
ghcr.io/ggozad/haiku.rag:latest
-v /path/to/haiku.rag.yaml:/app/haiku.rag.yaml \
-v /path/to/data:/data \
haiku-rag
```
The container will automatically use the mounted `haiku.rag.yaml` configuration file.
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.
For API keys (OpenAI, Anthropic, etc.), pass them as environment variables:
```bash
docker run -p 8001:8001 \
-v $(pwd)/haiku.rag.yaml:/app/haiku.rag.yaml \
-v $(pwd)/data:/data \
-v /path/to/haiku.rag.yaml:/app/haiku.rag.yaml \
-v /path/to/data:/data \
-e OPENAI_API_KEY=your-key-here \
ghcr.io/ggozad/haiku.rag:latest
```
## Building Locally
```bash
docker build -f docker/Dockerfile -t haiku-rag .
haiku-rag
```
## Docker Compose

View file

@ -1,282 +0,0 @@
## 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)
```

87
docs/apps.md Normal file
View file

@ -0,0 +1,87 @@
# 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,97 +1,291 @@
# Benchmarks
We use the [repliqa](https://huggingface.co/datasets/ServiceNow/repliqa) dataset for the evaluation of `haiku.rag`.
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.
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.
## Current results
## Configuration
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.
The benchmark script accepts a `--config` option to specify a custom `haiku.rag.yaml` configuration file:
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:
```bash
python -m evaluations.benchmark repliqa --config /path/to/haiku.rag.yaml
evaluations run hotpotqa
evaluations run orb_text
```
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
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.
You can also use command-line options:
### 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)
- `--skip-db` - Skip updating the evaluation database
- `--skip-retrieval` - Skip retrieval benchmark
- `--skip-qa` - Skip QA benchmark
- `--qa-limit N` - Limit number of QA cases to evaluate
- `--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)).
## Recall
If no config file is specified, the script searches standard locations: `./haiku.rag.yaml`, user config directory, then falls back to defaults.
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.
To pin the LLM judge in YAML (rather than the default `ollama:qwen3.8`). These are the recommended settings:
```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)
```
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).
### Restricting the corpus
| 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 |
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.
## Question/Answer evaluation
```bash
evaluations run orb_text --skip-db --config haiku.rag.s3.yaml \
--filter "uri LIKE '2407%'"
```
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:
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:
| 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 |
```bash
evaluations run orb_text --skip-db --filter "metadata LIKE '%\"corpus\": \"orb_text\"%'"
```
Note the significant degradation when very small models are used such as `qwen3:0.6b`.
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.
## 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` |
Filtering affects searches only — a run without `--skip-db` still populates the database with the dataset's full corpus.

View file

@ -0,0 +1,59 @@
# 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

@ -0,0 +1,50 @@
# 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.

150
docs/capabilities/index.md Normal file
View file

@ -0,0 +1,150 @@
# 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

@ -0,0 +1,54 @@
# 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.

69
docs/capabilities/rag.md Normal file
View file

@ -0,0 +1,69 @@
# 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).

1
docs/changelog.md Normal file
View file

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

87
docs/chat.md Normal file
View file

@ -0,0 +1,87 @@
# 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,46 +6,37 @@ 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` - Specify custom database path
- `--db` - Open the database at this path, named by its stem, whatever the configuration places
- `-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
```
@ -68,8 +59,19 @@ 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 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.
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.
!!! note
As you add documents to `haiku.rag` the database keeps growing. By default, LanceDB supports versioning
@ -77,6 +79,24 @@ haiku-rag add-src /path/to/documents/
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
@ -90,8 +110,6 @@ 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:
@ -101,10 +119,22 @@ haiku-rag search "machine learning"
With options:
```bash
haiku-rag search "python programming" --limit 10
haiku-rag search "python programming" --limit 10 # or -l 10
```
With filters (filter by document properties):
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`):
```bash
# Filter by URI pattern
haiku-rag search "neural networks" --filter "uri LIKE '%arxiv%'"
@ -116,6 +146,13 @@ 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:
@ -123,78 +160,130 @@ Ask questions about your documents:
haiku-rag ask "Who is the author of haiku.rag?"
```
Ask questions with citations showing source documents:
Filter to specific documents:
```bash
haiku-rag ask "Who is the author of haiku.rag?" --cite
haiku-rag ask "What are the main findings?" --filter "uri LIKE '%paper%'"
```
Use deep QA for complex questions (multi-agent decomposition):
Attach images to the question, for example to check an image against indexed documents:
```bash
haiku-rag ask "What are the main features and architecture of haiku.rag?" --deep --cite
haiku-rag ask "Does this photo satisfy the spec in the design document?" --image photo.jpg
```
Show verbose output with deep QA:
`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:
```bash
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
haiku-rag ask "What are the main findings?" --full-citations
```
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
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.
- `--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
## Server
## Analyze
Answer complex analytical questions via code execution:
Start services (requires at least one flag):
```bash
# 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
haiku-rag analyze "How many documents mention security?"
```
See [Server Mode](server.md) for details on available services.
Filter to specific documents:
## Settings
View current configuration settings:
```bash
haiku-rag settings
haiku-rag analyze "What is the total revenue?" --filter "title LIKE '%Financial%'"
```
## Maintenance
Flags:
### Info (Read-only)
- `--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
Display database metadata without upgrading or modifying it:
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:
```bash
haiku-rag info [--db /path/to/your.lancedb]
@ -204,33 +293,84 @@ Shows:
- path to the database
- stored haiku.rag version (from settings)
- embeddings provider/model and vector dimension
- number of documents
- table versions per table (documents, chunks)
- 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)
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
### Vacuum (Optimize and Cleanup)
### Doctor
Reduce disk usage by optimizing and pruning old table versions across all tables:
Check the database for consistency problems and print a pass/warn/fail report:
```bash
haiku-rag vacuum
haiku-rag doctor [--db /path/to/your.lancedb] [--duplicates-out groups.yaml]
```
**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.
While it runs, doctor shows a spinner naming the check currently in progress.
### Rebuild Database
`--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.
Rebuild the database by deleting all chunks & embeddings and re-indexing all documents. This is useful
when want to switch embeddings provider or model:
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 rebuild
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:
@ -239,6 +379,201 @@ Download required runtime models:
haiku-rag download-models
```
This command:
- Downloads Docling OCR/conversion models (no-op if already present).
- Pulls Ollama models referenced in your configuration (embeddings, QA, research, rerank).
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:
```bash
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.
## MCP Server
```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
```
See [MCP](mcp.md) for details. For continuous document ingestion
(filesystem watch, S3 polling, HTTP / WebDAV sources), use the
[ingester](ingester.md).
## 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.
```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
```
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
...
```

View file

@ -1,574 +0,0 @@
# 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)
```

211
docs/configuration/index.md Normal file
View file

@ -0,0 +1,211 @@
# 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

@ -0,0 +1,419 @@
# 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

@ -0,0 +1,76 @@
# 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

@ -0,0 +1,554 @@
# 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.

63
docs/configuration/qa.md Normal file
View file

@ -0,0 +1,63 @@
# 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

@ -0,0 +1,360 @@
# 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.

255
docs/custom-pipelines.md Normal file
View file

@ -0,0 +1,255 @@
# 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,
)
```

123
docs/development.md Normal file
View file

@ -0,0 +1,123 @@
# 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.

After

Width:  |  Height:  |  Size: 473 KiB

BIN
docs/img/chat-qa.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

BIN
docs/img/hero.mp4 Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

View file

@ -1,66 +1,38 @@
# haiku.rag
---
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` 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:
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.
```bash
uv pip install haiku.rag
haiku-rag init
haiku-rag add-src ~/Documents/some-paper.pdf
haiku-rag ask "what does it conclude?"
```
Use from Python:
[Quickstart](tutorial.md) covers provider setup and the first ingestion.
```python
from haiku.rag.client import HaikuRAG
## Why haiku.rag
async with HaikuRAG("database.lancedb") as client:
# Add a document
doc = await client.create_document("Your content here")
**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.
# Search documents
results = await client.search("query")
**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.
# Ask questions
answer = await client.ask("Who is the author of haiku.rag?")
```
**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.
Or use the CLI:
**Measured, not asserted.** Retrieval and answer quality are tracked against public benchmarks with runnable configs. See [Benchmarks](benchmarks.md).
```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?"
```
## Start here
## Documentation
- [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.
- [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).
MIT licensed. Source on [GitHub](https://github.com/ggozad/haiku.rag).

684
docs/ingester.md Normal file
View file

@ -0,0 +1,684 @@
# 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,40 +10,56 @@
uv pip install haiku.rag
```
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
The full package pulls the `docling`, `voyageai`, `cohere`, `zeroentropy`,
`cross-encoder`, `jina` and `tui` extras. It does not include `s3` or `ingester`:
This is the easiest way to get started with all features enabled.
```bash
uv pip install 'haiku.rag[ingester]' # the haiku-ingester service
uv pip install 'haiku.rag[s3]' # S3 and object storage
```
### Slim Package (Minimal Dependencies)
```bash
# Minimal installation (no document processing)
uv pip install haiku.rag-slim
# With document processing
uv pip install haiku.rag-slim[docling]
# With specific providers
uv pip install haiku.rag-slim[docling,voyageai,mxbai]
uv pip install 'haiku.rag-slim[docling]'
uv pip install 'haiku.rag-slim[docling,voyageai,cross-encoder]'
```
The slim package has minimal dependencies and lets you install only what you need:
### Extras
- `docling` - PDF, DOCX, PPTX, images, and other document formats
- `voyageai` - VoyageAI embeddings
- `mxbai` - MixedBread AI reranking
- `cohere` - Cohere reranking
- `zeroentropy` - Zero Entropy reranking
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.
**Built-in providers** (no extras needed):
- **Ollama** (default embedding provider)
- **OpenAI** (GPT models for QA and embeddings)
- **Anthropic** (Claude models for QA)
- **vLLM** and other OpenAI-compatible endpoints (embeddings, QA, reranking)
- **Jina** reranking via `provider: jina`, which calls the Jina HTTP API
See [Configuration](configuration.md) for configuring providers including advanced options like vLLM.
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.
## Requirements
@ -58,18 +74,45 @@ You can prefetch all required runtime models before first use:
haiku-rag download-models
```
This will download Docling models and pull any Ollama models referenced by your current configuration.
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.
## Docker
```bash
docker pull ghcr.io/ggozad/haiku.rag:latest
```
Only the slim image is published. Build the full image yourself:
Run the container with all services:
### Slim Image (Minimal)
Pre-built slim image with minimal dependencies - use with external docling-serve for document processing:
```bash
docker run -p 8000:8000 -p 8001:8001 -v $(pwd)/data:/data ghcr.io/ggozad/haiku.rag:latest
docker pull ghcr.io/ggozad/haiku.rag-slim:latest
```
This starts the MCP server on port 8001, with data persisted to `./data`.
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:
```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
```
See `docker/README.md` for complete build and configuration instructions, including how to run the [ingester](ingester.md) service for continuous document ingestion.

View file

@ -1,30 +1,210 @@
# Model Context Protocol (MCP)
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)
The MCP server exposes `haiku.rag` as MCP tools for compatible MCP clients like Claude Desktop.
## Starting MCP Server
The MCP server starts automatically with the serve command and supports Streamable HTTP and stdio transports:
The MCP server supports Streamable HTTP and stdio transports:
```bash
# Default streamable HTTP transport
haiku-rag serve
# 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
# stdio transport (for Claude Desktop)
haiku-rag serve --stdio
haiku-rag mcp --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.

90
docs/overview.md Normal file
View file

@ -0,0 +1,90 @@
# 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,12 +8,33 @@ Use `haiku.rag` directly in your Python applications.
from pathlib import Path
from haiku.rag.client import HaikuRAG
# Use as async context manager (recommended)
async with HaikuRAG("Path(path/to/database.lancedb")) as client:
# Create a new database
async with HaikuRAG("path/to/database.lancedb", create=True) 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
@ -28,31 +49,25 @@ doc = await client.create_document(
)
```
With custom externally generated chunks:
From HTML content (preserves document structure):
```python
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
),
]
html_content = "<h1>Title</h1><p>Paragraph</p><ul><li>Item 1</li></ul>"
doc = await client.create_document(
content="Full document content",
uri="doc://custom",
metadata={"source": "manual"},
chunks=chunks # Use provided chunks instead of auto-generating
content=html_content,
uri="doc://html-example",
format="html" # parse as HTML instead of markdown
)
```
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(
@ -67,11 +82,15 @@ 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(1)
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")
```
By URI:
@ -79,9 +98,21 @@ 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:
@ -99,43 +130,62 @@ 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
doc.content = "Updated content"
await client.update_document(doc)
# 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)
```
**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)
```
### 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.
Deleting a document also removes any child Documents linked to it via `metadata.parent_uri` (PDF attachment children, primarily). The cascade is transitive.
## Searching Documents
@ -144,12 +194,14 @@ 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 chunk, score in results:
print(f"Score: {score:.3f}")
print(f"Content: {chunk.content}")
print(f"Document ID: {chunk.document_id}")
for result in results:
print(f"Score: {result.score:.3f}")
print(f"Content: {result.content}")
print(f"Document ID: {result.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
@ -174,15 +226,68 @@ results = await client.search(
)
# Process results
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}")
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
```
### 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:
@ -225,60 +330,276 @@ 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 adjacent chunks for more complete context:
Expand search results with surrounding content from the document:
```python
# Get initial search results
search_results = await client.search("machine learning", limit=3)
# Expand with adjacent chunks using config setting
# Expand with section-bounded context
expanded_results = await client.expand_context(search_results)
# 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
for result in expanded_results:
print(f"Expanded content: {result.content}")
```
**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.
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.
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.
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.
## Question Answering
Ask questions about your documents:
```python
answer = await client.ask("Who is the author of haiku.rag?")
answer, citations = 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}")
```
Ask questions with citations showing source documents:
Filter to specific documents:
```python
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
answer, citations = await client.ask(
"What are the main findings?",
filter="uri LIKE '%paper%'"
)
```
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.
Attach images to the question, for example to check an image against indexed documents:
The QA provider and model are configured in `haiku.rag.yaml` or can be passed directly to the client (see [Configuration](configuration.md)).
```python
answer, citations = await client.ask(
"Does this image satisfy the requirements in the design spec?",
images=[Path("photo.jpg").read_bytes()],
)
```
See also: [Agents](agents.md) for details on the QA agent and the multiagent research workflow.
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.

130
docs/remote-processing.md Normal file
View file

@ -0,0 +1,130 @@
# 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)

View file

@ -1,95 +0,0 @@
# 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

@ -0,0 +1,53 @@
.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;
}

66
docs/tools.md Normal file
View file

@ -0,0 +1,66 @@
# 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.

124
docs/tuning.md Normal file
View file

@ -0,0 +1,124 @@
# 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,218 +1,86 @@
# Tutorial
# Quickstart
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 haiku.rag, index a document, and chat with it.
The tutorial covers:
## Install
- 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
```bash
uv pip install haiku.rag
```
Configure haiku.rag to use OpenAI. Create a `haiku.rag.yaml` file:
```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.
```
Set your OpenAI API key as an environment variable (API keys should not be stored in the YAML file):
You also need [Ollama](https://ollama.com/) for the default embedding and answering models:
```bash
export OPENAI_API_KEY="<your OpenAI API key>"
ollama pull qwen3-embedding:4b
ollama pull qwen3.8
```
For the list of available OpenAI models and their vector dimensions, see the [OpenAI documentation](https://platform.openai.com/docs/guides/embeddings).
!!! note "Prefer OpenAI?"
Drop this into a `haiku.rag.yaml` next to where you'll run the CLI:
See [Configuration](configuration.md) for all available options.
```yaml
embeddings:
model:
provider: openai
name: text-embedding-3-small
vector_dim: 1536
## Adding the first documents
qa:
model:
provider: openai
name: gpt-4o-mini
```
Now you can add some pieces of text in the database:
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).
```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"
## Initialize
```bash
haiku-rag init
```
What will happen
This creates a LanceDB database in your platform's user directory. Pass `--db` to any subcommand to use a different path:
- 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
Now you can view your [LanceDB](https://lancedb.com/) database, and the embeddings it is configured for:
```shell
haiku-rag info
```bash
haiku-rag init --db /tmp/test.lancedb
```
You should get the back the information:
## Add a document
```
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
Add a file, a URL, or a whole folder:
```bash
haiku-rag add-src https://arxiv.org/pdf/2408.09134
haiku-rag add-src ~/Documents/papers/
```
## Asking questions and retrieving information
Or paste text inline:
Now we can use OpenAI LLMs to retrieve information from our embeddings database.
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"
```bash
haiku-rag add "Yiorgis wrote haiku.rag in 2025."
```
```
Question: What is the best programming language in the world
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.
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.
## Chat
```bash
haiku-rag chat
```
## Programmatic interaction in Python
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.
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).
You can also ask a single question directly from the CLI without launching the TUI:
First we need to install `ipython`, as built-in Python REPL does not support async blocks.
```shell
uv pip install ipython
```bash
haiku-rag ask "Who wrote haiku.rag?"
```
Run IPython:
## Where to go next
```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.
- [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.

View file

@ -1,4 +1,4 @@
# Haiku RAG - Evaluations
# haiku.rag - Evaluations
Internal benchmarking and evaluation scripts for haiku.rag.
@ -6,6 +6,99 @@ This package is not published to PyPI and is only used for development and testi
## Overview
Contains evaluation scripts for benchmarking RAG performance using datasets like:
- RepliQA
- WiX
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.

View file

@ -0,0 +1,52 @@
# 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

@ -0,0 +1,37 @@
# 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

@ -0,0 +1,56 @@
# 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

@ -0,0 +1,41 @@
# 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

@ -0,0 +1,42 @@
# 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

@ -0,0 +1,43 @@
# 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

@ -0,0 +1,49 @@
# 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

@ -0,0 +1,106 @@
"""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,338 +1,237 @@
import asyncio
from collections.abc import Mapping
from pathlib import Path
from typing import Any, cast
from typing import cast
import logfire
import typer
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 dotenv import find_dotenv, load_dotenv
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.qa import get_qa_agent
from haiku.rag.telemetry import configure as configure_telemetry
from haiku.rag.utils import parse_model_option
QA_JUDGE_MODEL = "qwen3"
logfire.configure(send_to_logfire="if-token-present", service_name="evals")
logfire.instrument_pydantic_ai()
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)
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,
qa_limit: int | None,
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,
) -> 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)
await populate_db(
spec, config, db_path=db_path, vacuum_interval=vacuum_interval
)
if not skip_retrieval:
console.print("Running retrieval benchmarks...", style="bold blue")
await run_retrieval_benchmark(spec, config)
await run_retrieval_benchmark(
spec,
config,
limit=limit,
name=name,
db_path=db_path,
multimodal_only=multimodal_only,
document_filter=document_filter,
)
if not skip_qa:
console.print("\nRunning QA benchmarks...", style="bold yellow")
await run_qa_benchmark(spec, config, qa_limit=qa_limit)
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,
)
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 updateing the evaluation db."
False, "--skip-db", help="Skip updating 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."),
qa_limit: int | None = typer.Option(
None, "--qa-limit", help="Limit number of QA cases."
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."
),
),
) -> None:
spec = DATASETS.get(dataset.lower())
if spec is None:
valid_datasets = ", ".join(sorted(DATASETS))
spec = _resolve_dataset(dataset)
app_config = _load_config(config)
if target not in TARGETS:
raise typer.BadParameter(
f"Unknown dataset '{dataset}'. Choose from: {valid_datasets}"
f"Unknown target {target!r}. Choose from: {', '.join(TARGETS)}"
)
# 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()
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
)
asyncio.run(
evaluate_dataset(
@ -341,10 +240,38 @@ def run(
skip_db=skip_db,
skip_retrieval=skip_retrieval,
skip_qa=skip_qa,
qa_limit=qa_limit,
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,
)
)
@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

@ -0,0 +1,296 @@
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,
)

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