Commit graph

2440 commits

Author SHA1 Message Date
Yiorgis Gozadinos
523fd3edaa
Note the multi-database acceptance datasets in the changelog 2026-08-27 09:26:53 +03:00
Yiorgis Gozadinos
ad06d6a497
Stop failing a correct answer for naming the other document
Both S6 cases were scored wrong while answering correctly: the model gave
the right uri and database, then added a note distinguishing the near-name
document, and the forbidden-distractor rule rejected it for mentioning the
twin's uri at all.

Naming the other document as a different document is correct behaviour, and
better than the minimum. Forbidding a distractor belongs on B3, where two
elevations cannot both be the answer; here it punished disambiguation. S6
now requires the uri and then the database that holds it.
2026-08-27 09:26:52 +03:00
Yiorgis Gozadinos
d2b6b78d16
Pin the chunk size the table actually splits at
The build-time assertion fired on the first real build: the chunker keeps the
whole twelve-row table in one chunk at chunk_size 256 and at 128, which would
have let a single search hit answer the whole-document question. 64 splits it.

The cost is a corpus chunked more finely than a real one. The alternative is a
longer table at a realistic chunk size, which would change what the question
asks.
2026-08-27 09:26:52 +03:00
Yiorgis Gozadinos
18e1127375
Report the multi-database gates and which surfaces were reached
The two hard gates are pass/fail, not rates: any scope leak or attribution
error is a bug. The report names the offending cases and prints a greppable
GATES: PASSED / FAILED line, since this dataset is an acceptance gate rather
than a number to watch drift on.

The attribution gate covers only families where exactly one database is
correct. B5 is answered by two, so citing both is right there and policing
it would report a bug that is not one.

Surface coverage comes from the model's own Python, now carried on the run
result: counting executions said how often code ran but never which surface
it reached. Coverage is measured, not required, so a surface nothing reaches
is a finding about whether it earns its place in the VFS.

answer_correct is recorded as a score rather than a boolean, since booleans
become assertions and the run reads its accuracy headline from scores. The
two gates stay boolean assertions, which is what they are.

DatasetSpec grows a report_hook so this lives with the dataset instead of
becoming a key check in the shared QA reporting.

The reference-config invariant said a dataset with a deterministic evaluator
must declare no judge. That is not true here: RefusalJudge runs on any case
carrying an answerability label, which B6 and B7 do, so its sampling has to
be pinned. The assertion now requires a pinned block wherever a judge runs
and allows its absence only as the claim that none does.
2026-08-27 09:26:52 +03:00
Yiorgis Gozadinos
3f9066f4f9
Ask the multi-database questions and score them deterministically
Two dataset keys, because an S-family surface question cannot pass under
the RAG target and scoring it there would be noise: multidb carries the
behaviour families, multidb_surfaces the sandbox surfaces.

Scoring is deterministic. The answers are known numbers, names and counts,
so a red gate means the code is broken rather than that a judge spiralled.
Numbers are extracted and compared, since "1,240 metres" and "1240 m" are
both legitimate. B1 and B3 require the gold value present AND the twin's
absent: a hedge naming both elevations passes a presence check while
demonstrating the confusion those families exist to provoke. Refusals stay
judged, via the answerability labels the existing RefusalJudge reads, since
a phrase matcher keys on wording the model may never use.

B3 and B4 carry 9 and 10 instances because their gates are pass/fail and a
handful of cases is not evidence of absence. Half the B4 instances ask about
a near-name pair member with its twin excluded, so honouring scope costs the
model the other strong match instead of being free.

B2 rotates the order it lists the databases. RRF ties resolve to insertion
order, which is the configured order, so a fixed order would measure
ordering rather than fusion.

A scope travels in the case inputs via ScopedQuestion, since the task
function receives inputs and never metadata.
2026-08-27 09:26:52 +03:00
Yiorgis Gozadinos
7ae395a107
Generate the multi-database corpus and build its databases
The acceptance dataset for lancedb.databases needs a corpus where the
expected database is known per question, which no existing dataset gives.
Three databases: northern and southern hold station reports on one schema,
equipment holds spec sheets that share the vocabulary and answer nothing.

Names are invented throughout. A real station lets the model answer from
priors, which would measure memorisation rather than retrieval.

Three near-name pairs and one shared entity carry the attribution cases.
Pair members are identical apart from the name and the numbers, since a
difference in instrument or technician would hand the model a free
discriminator. Elevations and years are unique across the corpus so a
number identifies one station, and document counts differ per database so
a count cannot be right by luck while attribution is wrong.

Questions and gold answers both derive from STATIONS and INSTRUMENTS, so
they cannot drift apart. Readings are derived with SHA-256 over database
and name: hash() is salted per process, and keying on the name alone gave
the two Station Auk reports identical tables.

evaluations run populates one database and refuses a configured set, so
the builder opens each database itself and the run is --skip-db. It
asserts no single chunk holds all twelve monthly readings, because
without that S3 collapses into a search question and the guarantee has to
survive a chunker change.
2026-08-27 09:26:52 +03:00
Yiorgis Gozadinos
ba760b76c2
Thread per-case source scope through the capability runner
The runner passed document_filter onto capability state but not sources,
so an eval case could not be scoped to named databases. Scoping is what
the multi-database acceptance dataset has to assert, both that a scoped
question honours its databases and that an empty scope refuses rather
than answering without evidence.

sources=[] covers no database while None covers every one the client
covers, so the empty list is threaded on an is-not-None check and a test
pins the two apart.
2026-08-27 09:26:52 +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