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/`.
This commit is contained in:
parent
4c2bfc4fc1
commit
ca2e28559e
20 changed files with 2316 additions and 2220 deletions
0
tests/multi_db/__init__.py
Normal file
0
tests/multi_db/__init__.py
Normal file
31
tests/multi_db/conftest.py
Normal file
31
tests/multi_db/conftest.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from haiku.rag.config import get_config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def vcr_cassette_dir(request):
|
||||||
|
"""Cassettes sit with the rest, under `tests/cassettes/multi_db/`."""
|
||||||
|
module = request.module.__name__.rsplit(".", 1)[-1]
|
||||||
|
return str(Path(__file__).parent.parent / "cassettes" / "multi_db" / module)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def query_embedding(monkeypatch):
|
||||||
|
"""Vector search with no embedder behind it, recording the queries embedded.
|
||||||
|
|
||||||
|
These tests are about which databases are asked and how often, not about
|
||||||
|
retrieval quality, and CI has no embedding endpoint.
|
||||||
|
"""
|
||||||
|
from haiku.rag.embeddings import EmbedderWrapper
|
||||||
|
|
||||||
|
embedded: list[str] = []
|
||||||
|
|
||||||
|
async def embed_query(self, text):
|
||||||
|
embedded.append(text)
|
||||||
|
return [0.1] * get_config().embeddings.model.vector_dim
|
||||||
|
|
||||||
|
monkeypatch.setattr(EmbedderWrapper, "embed_query", embed_query)
|
||||||
|
return embedded
|
||||||
96
tests/multi_db/helpers.py
Normal file
96
tests/multi_db/helpers.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""Databases to run the multi-database tests against."""
|
||||||
|
|
||||||
|
from docling_core.types.doc.document import DoclingDocument
|
||||||
|
from docling_core.types.doc.labels import DocItemLabel
|
||||||
|
|
||||||
|
from haiku.rag.client import HaikuRAG
|
||||||
|
from haiku.rag.config import get_config
|
||||||
|
from haiku.rag.config.models import AppConfig, LanceDBConfig
|
||||||
|
from haiku.rag.store.models import Chunk
|
||||||
|
from haiku.rag.utils import locate_database
|
||||||
|
|
||||||
|
|
||||||
|
def _config(tmp_path, names) -> AppConfig:
|
||||||
|
return AppConfig(
|
||||||
|
lancedb=LanceDBConfig(
|
||||||
|
databases={n: str(tmp_path / f"{n}.lancedb") for n in names}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed(config, name, contents):
|
||||||
|
"""Precomputed embeddings and FTS queries keep the embedder out of the way:
|
||||||
|
these tests are about fusion, not retrieval quality."""
|
||||||
|
dim = get_config().embeddings.model.vector_dim
|
||||||
|
async with HaikuRAG(config=config, create=True, sources=[name]) as rag:
|
||||||
|
for content in contents:
|
||||||
|
doc = DoclingDocument(name=content)
|
||||||
|
doc.add_text(label=DocItemLabel.TEXT, text=content)
|
||||||
|
await rag.import_document(
|
||||||
|
doc,
|
||||||
|
[Chunk(content=content, embedding=[0.1] * dim, order=0)],
|
||||||
|
uri=f"test://{name}/{content}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _restore_embedder(config, name, *, provider=None, model_name=None):
|
||||||
|
"""Rewrite what one database records about the embedder that wrote it,
|
||||||
|
standing in for a database built elsewhere with another model."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import lancedb
|
||||||
|
|
||||||
|
_, db_path = locate_database(config.lancedb.databases[name])
|
||||||
|
assert db_path is not None
|
||||||
|
db = await lancedb.connect_async(str(db_path.resolve()))
|
||||||
|
table = await db.open_table("settings")
|
||||||
|
rows = (
|
||||||
|
await table.query().where("id = 'settings'").limit(1).to_arrow()
|
||||||
|
).to_pylist()
|
||||||
|
stored = json.loads(rows[0]["settings"])
|
||||||
|
model = stored["embeddings"]["model"]
|
||||||
|
if provider is not None:
|
||||||
|
model["provider"] = provider
|
||||||
|
if model_name is not None:
|
||||||
|
model["name"] = model_name
|
||||||
|
await table.update({"settings": json.dumps(stored)}, where="id = 'settings'")
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_expandable(config, name, sentences):
|
||||||
|
"""One document whose chunk covers a single item, so expansion has
|
||||||
|
neighbours to pull in and rebuilds the result rather than passing it
|
||||||
|
through."""
|
||||||
|
dim = get_config().embeddings.model.vector_dim
|
||||||
|
doc = DoclingDocument(name=name)
|
||||||
|
for sentence in sentences:
|
||||||
|
doc.add_text(label=DocItemLabel.TEXT, text=sentence)
|
||||||
|
async with HaikuRAG(config=config, create=True, sources=[name]) as rag:
|
||||||
|
await rag.import_document(
|
||||||
|
doc,
|
||||||
|
[
|
||||||
|
Chunk(
|
||||||
|
content=sentences[0],
|
||||||
|
embedding=[0.1] * dim,
|
||||||
|
order=0,
|
||||||
|
metadata={"doc_item_refs": ["#/texts/0"]},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
uri=f"test://{name}/expandable",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StubReranker:
|
||||||
|
"""Scores the union, reversing it so the ordering is unmistakably its own."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.seen: list[str] = []
|
||||||
|
|
||||||
|
async def rerank(self, query, chunks, top_n):
|
||||||
|
self.seen = [c.content for c in chunks]
|
||||||
|
# Whatever the caller attached before handing them over.
|
||||||
|
self.attached = {
|
||||||
|
c.content.split()[0]: c._picture_data
|
||||||
|
for c in chunks
|
||||||
|
if getattr(c, "_picture_data", None)
|
||||||
|
}
|
||||||
|
return [(c, 1.0 - i) for i, c in enumerate(reversed(chunks))][:top_n]
|
||||||
207
tests/multi_db/test_capabilities.py
Normal file
207
tests/multi_db/test_capabilities.py
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
"""Asking and analyzing across the databases a question covers."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from haiku.rag.capabilities.rag import RAGState, create_capability
|
||||||
|
from haiku.rag.client import HaikuRAG
|
||||||
|
from haiku.rag.sandbox import AnalysisContext, Sandbox
|
||||||
|
from haiku.rag.store.models import SearchResult
|
||||||
|
from tests.multi_db.helpers import (
|
||||||
|
_config,
|
||||||
|
_seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAskAcrossDatabases:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.vcr()
|
||||||
|
async def test_the_capability_searches_the_selected_databases(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
capability = create_capability(config=config, rag=rag, defer_loading=False)
|
||||||
|
capability.state = RAGState(sources=["alpha"])
|
||||||
|
|
||||||
|
formatted = await capability._search("cats", limit=10)
|
||||||
|
|
||||||
|
assert isinstance(formatted, str)
|
||||||
|
assert "alpha" in formatted
|
||||||
|
assert "beta document" not in formatted
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.vcr()
|
||||||
|
async def test_searching_all_databases_reaches_both(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
capability = create_capability(config=config, rag=rag, defer_loading=False)
|
||||||
|
capability.state = RAGState()
|
||||||
|
|
||||||
|
formatted = await capability._search("cats", limit=10)
|
||||||
|
|
||||||
|
assert isinstance(formatted, str)
|
||||||
|
assert "alpha document" in formatted
|
||||||
|
assert "beta document" in formatted
|
||||||
|
|
||||||
|
|
||||||
|
class TestStandaloneCapabilities:
|
||||||
|
"""A capability nobody hands a client opens its own. It has to reach the
|
||||||
|
configured set, or a host that only registers capabilities gets one
|
||||||
|
database while the configuration names several."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.vcr()
|
||||||
|
async def test_a_rag_capability_opens_the_configured_set(self, tmp_path):
|
||||||
|
from tests.capabilities.test_capabilities import Deps, make_context
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
capability = create_capability(config=config, defer_loading=False)
|
||||||
|
assert capability.scope.names == ("alpha", "beta")
|
||||||
|
run = await capability.for_run(make_context(Deps()))
|
||||||
|
try:
|
||||||
|
formatted = await run._search("cats", limit=10)
|
||||||
|
finally:
|
||||||
|
await run._close()
|
||||||
|
|
||||||
|
assert isinstance(formatted, str)
|
||||||
|
assert "alpha document" in formatted
|
||||||
|
assert "beta document" in formatted
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.vcr()
|
||||||
|
async def test_an_analysis_capability_mounts_the_configured_set(self, tmp_path):
|
||||||
|
from haiku.rag.capabilities.analysis import (
|
||||||
|
create_capability as create_analysis,
|
||||||
|
)
|
||||||
|
from tests.capabilities.test_capabilities import Deps, make_context
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
capability = create_analysis(config=config, defer_loading=False)
|
||||||
|
run = await capability.for_run(make_context(Deps()))
|
||||||
|
try:
|
||||||
|
sandbox = await run._ensure_sandbox()
|
||||||
|
docs, owners = await sandbox._documents()
|
||||||
|
finally:
|
||||||
|
await run._close()
|
||||||
|
|
||||||
|
assert len(docs) == 2
|
||||||
|
assert {owner.source for owner in owners.values()} == {"alpha", "beta"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_single_configured_database_is_still_opened(self, tmp_path):
|
||||||
|
"""One named database is a set of one, not a path to guess."""
|
||||||
|
config = _config(tmp_path, ["alpha"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
capability = create_capability(config=config, defer_loading=False)
|
||||||
|
rag = await capability._ensure_rag()
|
||||||
|
try:
|
||||||
|
assert rag.source == "alpha"
|
||||||
|
finally:
|
||||||
|
await capability._close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalyzeAcrossDatabases:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.vcr()
|
||||||
|
async def test_the_capability_searches_the_selected_databases(self, tmp_path):
|
||||||
|
"""`analysis_search` is the same tool as the RAG one, and the sandbox is
|
||||||
|
scoped by the same selection."""
|
||||||
|
from haiku.rag.capabilities.analysis import AnalysisState
|
||||||
|
from haiku.rag.capabilities.analysis import (
|
||||||
|
create_capability as create_analysis,
|
||||||
|
)
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
capability = create_analysis(config=config, rag=rag, defer_loading=False)
|
||||||
|
capability.state = AnalysisState(sources=["alpha"])
|
||||||
|
|
||||||
|
formatted = await capability._search("cats", limit=10)
|
||||||
|
sandbox = await capability._ensure_sandbox()
|
||||||
|
await capability._close()
|
||||||
|
|
||||||
|
assert isinstance(formatted, str)
|
||||||
|
assert "alpha document" in formatted
|
||||||
|
assert "beta document" not in formatted
|
||||||
|
assert sandbox._context.sources == ["alpha"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDatabaseIdentityForTheModel:
|
||||||
|
def test_a_result_names_its_database(self):
|
||||||
|
"""The model has to attribute and compare evidence by database while it
|
||||||
|
composes the answer, not only afterwards through the citations."""
|
||||||
|
result = SearchResult(content="body", score=0.9, source="alpha", chunk_id="c1")
|
||||||
|
|
||||||
|
assert "Database: alpha" in result.format_for_agent()
|
||||||
|
|
||||||
|
def test_an_unnamed_database_is_not_mentioned(self):
|
||||||
|
"""A single unnamed database renders as it always has."""
|
||||||
|
result = SearchResult(content="body", score=0.9, chunk_id="c1")
|
||||||
|
|
||||||
|
assert "Database" not in result.format_for_agent()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.vcr()
|
||||||
|
async def test_in_code_search_names_the_database(self, tmp_path):
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
sandbox = Sandbox(
|
||||||
|
db_path=None,
|
||||||
|
config=config,
|
||||||
|
context=AnalysisContext(),
|
||||||
|
rag=rag,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await sandbox.execute(
|
||||||
|
"rows = await search('cats', limit=10)\n"
|
||||||
|
"print(sorted(r['source'] for r in rows))\n"
|
||||||
|
"docs = await list_documents()\n"
|
||||||
|
"print(sorted(d['source'] for d in docs))"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await sandbox.close()
|
||||||
|
|
||||||
|
assert result.success, result.stderr
|
||||||
|
assert "['alpha', 'beta']" in result.stdout
|
||||||
|
assert result.stdout.count("['alpha', 'beta']") == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestActionableFailures:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_migration_error_survives_being_named(self, tmp_path, temp_db_path):
|
||||||
|
"""The remedy is the whole value of the message, and it names no location,
|
||||||
|
so it is not replaced by the database's name."""
|
||||||
|
from haiku.rag.store.exceptions import MigrationRequiredError
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config, sources=["alpha"]) as rag:
|
||||||
|
await rag.store.set_haiku_version("0.20.0")
|
||||||
|
|
||||||
|
with pytest.raises(MigrationRequiredError) as raised:
|
||||||
|
async with HaikuRAG(config=config, sources=["alpha"]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Both halves: which database failed, and what to run about it.
|
||||||
|
assert "haiku-rag migrate" in str(raised.value)
|
||||||
|
assert "alpha" in str(raised.value)
|
||||||
|
assert str(tmp_path) not in str(raised.value)
|
||||||
365
tests/multi_db/test_citations.py
Normal file
365
tests/multi_db/test_citations.py
Normal file
|
|
@ -0,0 +1,365 @@
|
||||||
|
"""Citing evidence drawn from several databases."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic_ai import ModelRetry
|
||||||
|
|
||||||
|
from haiku.rag.capabilities.rag import RAGState, create_capability
|
||||||
|
from haiku.rag.client import HaikuRAG
|
||||||
|
from haiku.rag.store.exceptions import (
|
||||||
|
AmbiguousCitationError,
|
||||||
|
)
|
||||||
|
from haiku.rag.store.models import SearchResult
|
||||||
|
from haiku.rag.store.models.citation import Citation, resolve_citations
|
||||||
|
from tests.multi_db.helpers import (
|
||||||
|
_config,
|
||||||
|
_seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSharedChunkIds:
|
||||||
|
"""A database copied from another holds the same chunk ids."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_shared_id_does_not_confuse_the_fused_order(self, tmp_path):
|
||||||
|
"""Arrival order breaks score ties, so it has to tell two databases'
|
||||||
|
identically-numbered chunks apart."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one about cats"])
|
||||||
|
await _seed(config, "beta", ["beta one about cats"])
|
||||||
|
fused = [
|
||||||
|
SearchResult(content="a0", score=0.5, chunk_id="a0", source="alpha"),
|
||||||
|
SearchResult(content="beta", score=0.5, chunk_id="shared", source="beta"),
|
||||||
|
SearchResult(content="alpha", score=0.5, chunk_id="shared", source="alpha"),
|
||||||
|
]
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
expanded = await rag.expand_context(fused)
|
||||||
|
|
||||||
|
assert [(r.source, r.chunk_id) for r in expanded] == [
|
||||||
|
(r.source, r.chunk_id) for r in fused
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_a_shared_id_cannot_be_cited(self):
|
||||||
|
"""A citation records the id alone, so resolving one held by two
|
||||||
|
databases would attribute the answer to whichever came last."""
|
||||||
|
results = [
|
||||||
|
SearchResult(
|
||||||
|
content="alpha body",
|
||||||
|
score=0.9,
|
||||||
|
source="alpha",
|
||||||
|
chunk_id="c1",
|
||||||
|
document_id="d1",
|
||||||
|
document_uri="test://alpha/one",
|
||||||
|
),
|
||||||
|
SearchResult(
|
||||||
|
content="beta body",
|
||||||
|
score=0.8,
|
||||||
|
source="beta",
|
||||||
|
chunk_id="c1",
|
||||||
|
document_id="d1",
|
||||||
|
document_uri="test://beta/one",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
with pytest.raises(AmbiguousCitationError, match="c1"):
|
||||||
|
resolve_citations(["c1"], results)
|
||||||
|
|
||||||
|
def test_a_repeated_id_from_one_database_still_collapses(self):
|
||||||
|
"""One database cannot hold two chunks under one id, so seeing it twice
|
||||||
|
is the same chunk seen twice."""
|
||||||
|
results = [
|
||||||
|
SearchResult(
|
||||||
|
content="first",
|
||||||
|
score=0.9,
|
||||||
|
source="alpha",
|
||||||
|
chunk_id="c1",
|
||||||
|
document_id="d1",
|
||||||
|
document_uri="test://alpha/one",
|
||||||
|
),
|
||||||
|
SearchResult(
|
||||||
|
content="second",
|
||||||
|
score=0.8,
|
||||||
|
source="alpha",
|
||||||
|
chunk_id="c1",
|
||||||
|
document_id="d1",
|
||||||
|
document_uri="test://alpha/one",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
[citation] = resolve_citations(["c1"], results)
|
||||||
|
|
||||||
|
assert citation.content == "first"
|
||||||
|
|
||||||
|
def test_only_a_cited_id_has_to_be_unambiguous(self):
|
||||||
|
"""An id the answer never cites attributes nothing."""
|
||||||
|
shared = [
|
||||||
|
SearchResult(
|
||||||
|
content=f"{name} body",
|
||||||
|
score=0.9,
|
||||||
|
source=name,
|
||||||
|
chunk_id="c1",
|
||||||
|
document_id="d1",
|
||||||
|
document_uri=f"test://{name}/one",
|
||||||
|
)
|
||||||
|
for name in ("alpha", "beta")
|
||||||
|
]
|
||||||
|
own = SearchResult(
|
||||||
|
content="alpha only",
|
||||||
|
score=0.7,
|
||||||
|
source="alpha",
|
||||||
|
chunk_id="c2",
|
||||||
|
document_id="d2",
|
||||||
|
document_uri="test://alpha/two",
|
||||||
|
)
|
||||||
|
|
||||||
|
[citation] = resolve_citations(["c2"], [*shared, own])
|
||||||
|
|
||||||
|
assert citation.source == "alpha"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_unsearched_shared_id_is_refused_by_the_fallback(self, tmp_path):
|
||||||
|
"""The direct lookup is the only place a collision shows for an id no
|
||||||
|
search returned, so it has to ask every database rather than take the
|
||||||
|
first that answers."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(
|
||||||
|
config, "alpha", ["alpha document about cats", "alpha on aardvarks"]
|
||||||
|
)
|
||||||
|
shutil.copytree(tmp_path / "alpha.lancedb", tmp_path / "beta.lancedb")
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
alpha = (await rag.clients_for(["alpha"]))[0]
|
||||||
|
chunks = await alpha.chunk_repository.list_all()
|
||||||
|
[aardvark] = [c for c in chunks if "aardvark" in c.content]
|
||||||
|
assert aardvark.id is not None
|
||||||
|
|
||||||
|
capability = create_capability(config=config, rag=rag, defer_loading=False)
|
||||||
|
capability.state = RAGState()
|
||||||
|
|
||||||
|
# No search ran, so the id can only resolve through the fallback.
|
||||||
|
with pytest.raises(ModelRetry, match="more than one database"):
|
||||||
|
await capability._cite([aardvark.id])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_unsearched_id_in_one_database_still_resolves(self, tmp_path):
|
||||||
|
"""The refusal is for a collision, not for looking through several
|
||||||
|
databases: an id only one of them holds still resolves."""
|
||||||
|
from tests.capabilities.test_capabilities import Deps, make_context
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(
|
||||||
|
config, "alpha", ["alpha document about cats", "alpha on aardvarks"]
|
||||||
|
)
|
||||||
|
await _seed(config, "beta", ["beta document about dogs"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
alpha = (await rag.clients_for(["alpha"]))[0]
|
||||||
|
chunks = await alpha.chunk_repository.list_all()
|
||||||
|
[aardvark] = [c for c in chunks if "aardvark" in c.content]
|
||||||
|
assert aardvark.id is not None
|
||||||
|
|
||||||
|
capability = create_capability(config=config, rag=rag, defer_loading=False)
|
||||||
|
run = await capability.for_run(make_context(Deps()))
|
||||||
|
|
||||||
|
await run._cite([aardvark.id])
|
||||||
|
|
||||||
|
assert run.state is not None
|
||||||
|
[citation] = list(run.state.citation_index.values())
|
||||||
|
assert citation.source == "alpha"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cite_asks_for_other_evidence(self, tmp_path):
|
||||||
|
capability = create_capability(
|
||||||
|
config=_config(tmp_path, ["alpha", "beta"]), defer_loading=False
|
||||||
|
)
|
||||||
|
capability.state = RAGState(
|
||||||
|
searches={
|
||||||
|
"cats": [
|
||||||
|
SearchResult(
|
||||||
|
content=f"{name} body",
|
||||||
|
score=0.9,
|
||||||
|
source=name,
|
||||||
|
chunk_id="c1",
|
||||||
|
document_id="d1",
|
||||||
|
document_uri=f"test://{name}/one",
|
||||||
|
)
|
||||||
|
for name in ("alpha", "beta")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ModelRetry, match="appears once"):
|
||||||
|
await capability._cite(["c1"])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cite_refuses_an_id_already_cited_from_another_database(
|
||||||
|
self, tmp_path
|
||||||
|
):
|
||||||
|
"""The citation index outlives the question, so the collision can arrive
|
||||||
|
a turn later than the search that would have shown it."""
|
||||||
|
capability = create_capability(
|
||||||
|
config=_config(tmp_path, ["alpha", "beta"]), defer_loading=False
|
||||||
|
)
|
||||||
|
capability.state = RAGState(
|
||||||
|
citation_index={
|
||||||
|
"c1": Citation(
|
||||||
|
document_id="d1",
|
||||||
|
source="alpha",
|
||||||
|
chunk_id="c1",
|
||||||
|
document_uri="test://alpha/one",
|
||||||
|
content="alpha body",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
searches={
|
||||||
|
"cats": [
|
||||||
|
SearchResult(
|
||||||
|
content="beta body",
|
||||||
|
score=0.9,
|
||||||
|
source="beta",
|
||||||
|
chunk_id="c1",
|
||||||
|
document_id="d1",
|
||||||
|
document_uri="test://beta/one",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ModelRetry, match="another database"):
|
||||||
|
await capability._cite(["c1"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestCitationSource:
|
||||||
|
def test_a_citation_carries_the_result_source(self):
|
||||||
|
result = SearchResult(
|
||||||
|
content="body",
|
||||||
|
score=0.9,
|
||||||
|
source="alpha",
|
||||||
|
chunk_id="c1",
|
||||||
|
document_id="d1",
|
||||||
|
document_uri="test://alpha/one",
|
||||||
|
)
|
||||||
|
|
||||||
|
[citation] = resolve_citations(["c1"], [result])
|
||||||
|
|
||||||
|
assert citation.source == "alpha"
|
||||||
|
|
||||||
|
def test_a_result_without_an_id_is_skipped(self):
|
||||||
|
"""A result built by hand carries no chunk id, so nothing can cite it
|
||||||
|
and it takes part in no collision."""
|
||||||
|
handmade = SearchResult(content="loose text", score=0.5)
|
||||||
|
real = SearchResult(
|
||||||
|
content="body",
|
||||||
|
score=0.9,
|
||||||
|
source="alpha",
|
||||||
|
chunk_id="c1",
|
||||||
|
document_id="d1",
|
||||||
|
document_uri="test://alpha/one",
|
||||||
|
)
|
||||||
|
|
||||||
|
[citation] = resolve_citations(["c1"], [handmade, real])
|
||||||
|
|
||||||
|
assert citation.chunk_id == "c1"
|
||||||
|
|
||||||
|
def test_a_single_database_citation_has_no_source(self):
|
||||||
|
result = SearchResult(
|
||||||
|
content="body",
|
||||||
|
score=0.9,
|
||||||
|
chunk_id="c1",
|
||||||
|
document_id="d1",
|
||||||
|
document_uri="test://one",
|
||||||
|
)
|
||||||
|
|
||||||
|
[citation] = resolve_citations(["c1"], [result])
|
||||||
|
|
||||||
|
assert citation.source is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCiteFallback:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.vcr()
|
||||||
|
async def test_an_id_from_a_selected_database_resolves_with_its_source(
|
||||||
|
self, tmp_path
|
||||||
|
):
|
||||||
|
"""The fallback exists for a real id this run's searches did not return.
|
||||||
|
Across databases it looks through the selected ones and records which
|
||||||
|
held it."""
|
||||||
|
from tests.capabilities.test_capabilities import Deps, make_context
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(
|
||||||
|
config, "alpha", ["alpha document about cats", "alpha on aardvarks"]
|
||||||
|
)
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
alpha = (await rag.clients_for(["alpha"]))[0]
|
||||||
|
chunks = await alpha.chunk_repository.list_all()
|
||||||
|
[aardvark] = [c for c in chunks if "aardvark" in c.content]
|
||||||
|
assert aardvark.id is not None
|
||||||
|
|
||||||
|
capability = create_capability(config=config, rag=rag, defer_loading=False)
|
||||||
|
deps = Deps(
|
||||||
|
state={"rag": RAGState(sources=["alpha"]).model_dump(mode="json")}
|
||||||
|
)
|
||||||
|
run = await capability.for_run(make_context(deps))
|
||||||
|
# The search returns the cats chunk, never the aardvark one.
|
||||||
|
await run._search("cats", limit=10)
|
||||||
|
|
||||||
|
await run._cite([aardvark.id])
|
||||||
|
|
||||||
|
assert run.state is not None
|
||||||
|
[citation] = list(run.state.citation_index.values())
|
||||||
|
assert citation.chunk_id == aardvark.id
|
||||||
|
assert citation.source == "alpha"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.vcr()
|
||||||
|
async def test_an_id_outside_the_selected_databases_does_not_resolve(
|
||||||
|
self, tmp_path
|
||||||
|
):
|
||||||
|
"""A question scoped to one database must not produce a citation from
|
||||||
|
another: the fallback looks only where the question looked."""
|
||||||
|
from tests.capabilities.test_capabilities import Deps, make_context
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about dogs"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
beta = (await rag.clients_for(["beta"]))[0]
|
||||||
|
[outside] = await beta.chunk_repository.list_all(limit=1)
|
||||||
|
assert outside.id is not None
|
||||||
|
|
||||||
|
capability = create_capability(config=config, rag=rag, defer_loading=False)
|
||||||
|
deps = Deps(
|
||||||
|
state={"rag": RAGState(sources=["alpha"]).model_dump(mode="json")}
|
||||||
|
)
|
||||||
|
run = await capability.for_run(make_context(deps))
|
||||||
|
await run._search("cats", limit=10)
|
||||||
|
|
||||||
|
with pytest.raises(ModelRetry):
|
||||||
|
await run._cite([outside.id])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_selecting_no_databases_cites_nothing(self, tmp_path):
|
||||||
|
"""`sources=[]` selected nothing, which is not the same as everything:
|
||||||
|
the fallback must not go looking where the question never looked."""
|
||||||
|
from tests.capabilities.test_capabilities import Deps, make_context
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
alpha = (await rag.clients_for(["alpha"]))[0]
|
||||||
|
[chunk] = await alpha.chunk_repository.list_all(limit=1)
|
||||||
|
assert chunk.id is not None
|
||||||
|
|
||||||
|
capability = create_capability(config=config, rag=rag, defer_loading=False)
|
||||||
|
deps = Deps(state={"rag": RAGState(sources=[]).model_dump(mode="json")})
|
||||||
|
run = await capability.for_run(make_context(deps))
|
||||||
|
|
||||||
|
with pytest.raises(ModelRetry):
|
||||||
|
await run._cite([chunk.id])
|
||||||
232
tests/multi_db/test_documents.py
Normal file
232
tests/multi_db/test_documents.py
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
"""Listing and looking up documents across databases."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from docling_core.types.doc.document import DoclingDocument
|
||||||
|
from docling_core.types.doc.labels import DocItemLabel
|
||||||
|
|
||||||
|
from haiku.rag.client import HaikuRAG
|
||||||
|
from haiku.rag.config import get_config
|
||||||
|
from haiku.rag.store.models import Chunk
|
||||||
|
from tests.multi_db.helpers import (
|
||||||
|
_config,
|
||||||
|
_seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestListingAcrossDatabases:
|
||||||
|
"""The chat TUI's document filter lists documents through the client, and a
|
||||||
|
client covering a set has no repositories of its own."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_listing_covers_every_database(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one", "alpha two"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
docs = await rag.list_documents()
|
||||||
|
|
||||||
|
assert {d.uri for d in docs} == {
|
||||||
|
"test://alpha/alpha one",
|
||||||
|
"test://alpha/alpha two",
|
||||||
|
"test://beta/beta one",
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_counting_covers_every_database(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one", "alpha two"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
assert await rag.count_documents() == 3
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_limit_bounds_the_merged_listing(self, tmp_path):
|
||||||
|
"""A limit is that many documents in total, not that many per database."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one", "alpha two"])
|
||||||
|
await _seed(config, "beta", ["beta one", "beta two"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
assert len(await rag.list_documents(limit=3)) == 3
|
||||||
|
assert len(await rag.list_documents(limit=2, offset=2)) == 2
|
||||||
|
assert len(await rag.list_documents(offset=3)) == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_page_shows_every_database(self, tmp_path):
|
||||||
|
"""A window is taken across the databases, not filled from the first one:
|
||||||
|
concatenating hides every database after whichever was listed first."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", [f"alpha {i}" for i in range(5)])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
page = await rag.list_documents(limit=3)
|
||||||
|
|
||||||
|
assert len(page) == 3
|
||||||
|
assert {(d.uri or "").split("/")[2] for d in page} == {"alpha", "beta"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_filter_reaches_every_database(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
docs = await rag.list_documents(filter="uri LIKE 'test://beta/%'")
|
||||||
|
|
||||||
|
assert [d.uri for d in docs] == ["test://beta/beta one"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLookupByIdentifier:
|
||||||
|
"""An id or a URI says nothing about which database holds it, and a client
|
||||||
|
covering a set has no repositories of its own."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_document_is_found_in_whichever_database_holds_it(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
beta = (await rag.clients_for(["beta"]))[0]
|
||||||
|
[target] = await beta.document_repository.list_all(limit=1)
|
||||||
|
assert target.id is not None
|
||||||
|
|
||||||
|
found = await rag.get_document_by_id(target.id)
|
||||||
|
by_uri = await rag.get_document_by_uri("test://alpha/alpha one")
|
||||||
|
resolved = await rag.resolve_document(target.id)
|
||||||
|
|
||||||
|
assert found is not None and found.uri == "test://beta/beta one"
|
||||||
|
assert by_uri is not None and by_uri.uri == "test://alpha/alpha one"
|
||||||
|
assert resolved is not None and resolved.uri == "test://beta/beta one"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_chunk_is_found_in_whichever_database_holds_it(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
beta = (await rag.clients_for(["beta"]))[0]
|
||||||
|
[chunk] = await beta.chunk_repository.list_all(limit=1)
|
||||||
|
assert chunk.id is not None
|
||||||
|
|
||||||
|
found = await rag.get_chunk_by_id(chunk.id)
|
||||||
|
|
||||||
|
assert found is not None and found.content == "beta one"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_document_held_by_two_databases_answers_from_the_first(
|
||||||
|
self, tmp_path
|
||||||
|
):
|
||||||
|
"""A database copied from another holds the same ids. A read has an
|
||||||
|
answer wherever it finds one, and which one it is has to be the
|
||||||
|
configured order rather than whichever replied first."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
shutil.copytree(tmp_path / "alpha.lancedb", tmp_path / "beta.lancedb")
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
beta = (await rag.clients_for(["beta"]))[0]
|
||||||
|
[target] = await beta.document_repository.list_all(limit=1)
|
||||||
|
assert target.id is not None
|
||||||
|
|
||||||
|
found = await rag.get_document_by_id(target.id)
|
||||||
|
|
||||||
|
assert found is not None and found.source == "alpha"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_unknown_identifier_is_absent_rather_than_an_error(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
assert (
|
||||||
|
await rag.get_document_by_id("00000000-0000-4000-8000-000000000000")
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await rag.get_chunk_by_id("00000000-0000-4000-8000-000000000000")
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
assert await rag.get_document_by_uri("test://nowhere") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestDocumentsNameTheirDatabase:
|
||||||
|
"""A listing that spans databases is unreadable when the documents do not
|
||||||
|
say which one they came from, the same reason a search result carries one."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_listing_names_each_document_s_database(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one", "alpha two"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
docs = await rag.list_documents()
|
||||||
|
|
||||||
|
assert {d.uri: d.source for d in docs} == {
|
||||||
|
"test://alpha/alpha one": "alpha",
|
||||||
|
"test://alpha/alpha two": "alpha",
|
||||||
|
"test://beta/beta one": "beta",
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_looked_up_document_names_its_database(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
beta = (await rag.clients_for(["beta"]))[0]
|
||||||
|
[target] = await beta.document_repository.list_all(limit=1)
|
||||||
|
assert target.id is not None
|
||||||
|
|
||||||
|
by_id = await rag.get_document_by_id(target.id)
|
||||||
|
by_uri = await rag.get_document_by_uri("test://alpha/alpha one")
|
||||||
|
resolved = await rag.resolve_document(target.id)
|
||||||
|
|
||||||
|
assert by_id is not None and by_id.source == "beta"
|
||||||
|
assert by_uri is not None and by_uri.source == "alpha"
|
||||||
|
assert resolved is not None and resolved.source == "beta"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_one_named_database_still_names_itself(self, tmp_path):
|
||||||
|
"""`haiku-rag --database alpha list` opens one database, and its name is
|
||||||
|
the whole reason the option exists."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config, sources=["alpha"]) as rag:
|
||||||
|
[listed] = await rag.list_documents()
|
||||||
|
assert listed.id is not None
|
||||||
|
by_id = await rag.get_document_by_id(listed.id)
|
||||||
|
by_uri = await rag.get_document_by_uri("test://alpha/alpha one")
|
||||||
|
|
||||||
|
assert listed.source == "alpha"
|
||||||
|
assert by_id is not None and by_id.source == "alpha"
|
||||||
|
assert by_uri is not None and by_uri.source == "alpha"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_one_database_leaves_the_source_unset(self, tmp_path, temp_db_path):
|
||||||
|
"""Nothing names the database when there is only one to name."""
|
||||||
|
async with HaikuRAG(temp_db_path, create=True) as rag:
|
||||||
|
dim = get_config().embeddings.model.vector_dim
|
||||||
|
doc = DoclingDocument(name="solo")
|
||||||
|
doc.add_text(label=DocItemLabel.TEXT, text="solo")
|
||||||
|
await rag.import_document(
|
||||||
|
doc,
|
||||||
|
[Chunk(content="solo", embedding=[0.1] * dim, order=0)],
|
||||||
|
uri="test://solo",
|
||||||
|
)
|
||||||
|
|
||||||
|
[listed] = await rag.list_documents()
|
||||||
|
assert listed.source is None
|
||||||
|
assert listed.id is not None
|
||||||
|
assert (await rag.get_document_by_id(listed.id)).source is None
|
||||||
246
tests/multi_db/test_expansion.py
Normal file
246
tests/multi_db/test_expansion.py
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
"""Expanding and enriching results through the database each came from."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic_ai import ModelRetry
|
||||||
|
|
||||||
|
from haiku.rag.capabilities.rag import create_capability
|
||||||
|
from haiku.rag.client import HaikuRAG
|
||||||
|
from haiku.rag.store.models import Chunk, Document, DocumentItem, SearchResult
|
||||||
|
from tests.multi_db.helpers import (
|
||||||
|
_config,
|
||||||
|
_seed,
|
||||||
|
_seed_expandable,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpansionRouting:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_expansion_routes_each_result_to_its_database(self, tmp_path):
|
||||||
|
"""A federating client has no repositories of its own, so expansion has
|
||||||
|
to go through the database each result came from."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
results = await rag.search("cats", search_type="fts", limit=10)
|
||||||
|
expanded = await rag.expand_context(results)
|
||||||
|
|
||||||
|
assert {r.source for r in expanded} == {"alpha", "beta"}
|
||||||
|
for r in expanded:
|
||||||
|
assert r.source is not None
|
||||||
|
assert r.source in r.content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_expanded_result_keeps_its_source(self, tmp_path):
|
||||||
|
"""Expansion rebuilds the result, and the rebuilt one has to name the
|
||||||
|
database it was expanded through."""
|
||||||
|
config = _config(tmp_path, ["alpha"])
|
||||||
|
await _seed_expandable(
|
||||||
|
config, "alpha", ["cats sleep often", "cats also hunt", "cats purr"]
|
||||||
|
)
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
results = await rag.search("cats", search_type="fts", limit=10)
|
||||||
|
expanded = await rag.expand_context(results)
|
||||||
|
|
||||||
|
assert len(expanded) == 1
|
||||||
|
assert "cats also hunt" in expanded[0].content, "expansion did not run"
|
||||||
|
assert expanded[0].source == "alpha"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_federated_result_is_expanded_by_its_own_database(self, tmp_path):
|
||||||
|
"""Routing is not enough: each result has to come back carrying the
|
||||||
|
neighbours of the database it was expanded through, and only those."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed_expandable(
|
||||||
|
config, "alpha", ["cats sleep often", "alpha follows on"]
|
||||||
|
)
|
||||||
|
await _seed_expandable(config, "beta", ["cats also hunt", "beta follows on"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
results = await rag.search("cats", search_type="fts", limit=10)
|
||||||
|
expanded = await rag.expand_context(results)
|
||||||
|
|
||||||
|
content = {r.source: r.content for r in expanded}
|
||||||
|
assert "alpha follows on" in content["alpha"]
|
||||||
|
assert "beta follows on" not in content["alpha"]
|
||||||
|
assert "beta follows on" in content["beta"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_expansion_keeps_tied_results_in_fused_order(self, tmp_path):
|
||||||
|
"""Fused scores tie often, so grouping by database must not reorder
|
||||||
|
them: the tiebreak is the order they arrived in."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one about cats", "alpha two about cats"])
|
||||||
|
await _seed(config, "beta", ["beta one about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
found = await rag.search("cats", search_type="fts", limit=10)
|
||||||
|
by_source: dict[str, list[SearchResult]] = {}
|
||||||
|
for result in found:
|
||||||
|
by_source.setdefault(result.source or "", []).append(result)
|
||||||
|
# Interleaved, so grouping by database is visible as a reordering.
|
||||||
|
fused = [by_source["alpha"][0], by_source["beta"][0], by_source["alpha"][1]]
|
||||||
|
for result in fused:
|
||||||
|
result.score = 0.5
|
||||||
|
|
||||||
|
expanded = await rag.expand_context(fused)
|
||||||
|
|
||||||
|
assert [r.chunk_id for r in expanded] == [r.chunk_id for r in fused]
|
||||||
|
|
||||||
|
|
||||||
|
class TestPictureDeduplication:
|
||||||
|
"""One picture yields two chunks — a text-embedded one and an image-embedded
|
||||||
|
one — that collapse to the best. Two databases holding the same picture are
|
||||||
|
two results, not a duplicate."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _picture(source, score):
|
||||||
|
return SearchResult(
|
||||||
|
content="a figure",
|
||||||
|
score=score,
|
||||||
|
source=source,
|
||||||
|
chunk_id=f"{source}-c",
|
||||||
|
document_id="doc-1",
|
||||||
|
doc_item_refs=["#/pictures/0"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_same_picture_in_two_databases_survives(self):
|
||||||
|
from haiku.rag.client.search import _dedup_picture_chunks
|
||||||
|
|
||||||
|
kept = _dedup_picture_chunks(
|
||||||
|
[self._picture("alpha", 0.9), self._picture("clone", 0.5)]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [r.source for r in kept] == ["alpha", "clone"]
|
||||||
|
|
||||||
|
def test_duplicates_within_one_database_still_collapse(self):
|
||||||
|
from haiku.rag.client.search import _dedup_picture_chunks
|
||||||
|
|
||||||
|
lower = self._picture("alpha", 0.5)
|
||||||
|
higher = self._picture("alpha", 0.9)
|
||||||
|
|
||||||
|
kept = _dedup_picture_chunks([lower, higher])
|
||||||
|
|
||||||
|
assert kept == [higher]
|
||||||
|
|
||||||
|
|
||||||
|
class TestPictureRouting:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_picture_is_fetched_from_the_database_that_holds_it(self, tmp_path):
|
||||||
|
"""A `self_ref` repeats across databases, so the citation's source is
|
||||||
|
what decides where the bytes come from."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
beta = (await rag.clients_for(["beta"]))[0]
|
||||||
|
[document] = await beta.document_repository.list_all(limit=1)
|
||||||
|
assert document.id is not None
|
||||||
|
await beta.document_item_repository.create_all(
|
||||||
|
[
|
||||||
|
DocumentItem(
|
||||||
|
document_id=document.id,
|
||||||
|
self_ref="#/pictures/0",
|
||||||
|
position=99,
|
||||||
|
label="picture",
|
||||||
|
text="",
|
||||||
|
picture_data=b"beta-picture",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
await rag.get_picture_bytes(document.id, "#/pictures/0", "beta")
|
||||||
|
== b"beta-picture"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await rag.get_picture_bytes(document.id, "#/pictures/0", "alpha")
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_single_database_needs_no_source(self, temp_db_path):
|
||||||
|
"""One database is where the picture is, named or not."""
|
||||||
|
async with HaikuRAG(temp_db_path, create=True) as rag:
|
||||||
|
document = await rag.document_repository.create(
|
||||||
|
Document(content="body", uri="test://one")
|
||||||
|
)
|
||||||
|
assert document.id is not None
|
||||||
|
await rag.document_item_repository.create_all(
|
||||||
|
[
|
||||||
|
DocumentItem(
|
||||||
|
document_id=document.id,
|
||||||
|
self_ref="#/pictures/0",
|
||||||
|
position=0,
|
||||||
|
label="picture",
|
||||||
|
text="",
|
||||||
|
picture_data=b"the-picture",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
await rag.get_picture_bytes(document.id, "#/pictures/0")
|
||||||
|
== b"the-picture"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_picture_lookup_without_a_source_is_refused(self, tmp_path):
|
||||||
|
"""Federating, nothing can say which database holds an unqualified
|
||||||
|
reference."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
with pytest.raises(ValueError, match="source"):
|
||||||
|
await rag.get_picture_bytes("doc-1", "#/pictures/0")
|
||||||
|
|
||||||
|
|
||||||
|
class TestFederatedEdges:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_expansion_passes_through_results_without_a_source(self, tmp_path):
|
||||||
|
"""A caller can hand `expand_context` results it built itself. Those name
|
||||||
|
no database, so there is nowhere to expand them from."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
handmade = SearchResult(content="handmade", score=0.4, doc_item_refs=[])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
found = await rag.search("cats", search_type="fts", limit=10)
|
||||||
|
expanded = await rag.expand_context([*found, handmade])
|
||||||
|
|
||||||
|
assert "handmade" in [r.content for r in expanded]
|
||||||
|
scores = [r.score for r in expanded]
|
||||||
|
assert scores == sorted(scores, reverse=True), "merged in score order"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_chunk_without_a_document_is_not_cited(self, tmp_path):
|
||||||
|
"""`Chunk.document_id` is optional, and a citation without a document has
|
||||||
|
nothing to point at."""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from haiku.rag.capabilities.rag import RAGCapability
|
||||||
|
from tests.capabilities.test_capabilities import (
|
||||||
|
Deps,
|
||||||
|
_single_database_client,
|
||||||
|
make_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
orphan = _single_database_client()
|
||||||
|
orphan.get_chunk_by_id.return_value = Chunk(
|
||||||
|
id="orphan", document_id=None, content="no document"
|
||||||
|
)
|
||||||
|
|
||||||
|
capability = create_capability(config=config, defer_loading=False)
|
||||||
|
run = await capability.for_run(make_context(Deps()))
|
||||||
|
with patch.object(RAGCapability, "_ensure_rag", AsyncMock(return_value=orphan)):
|
||||||
|
with pytest.raises(ModelRetry):
|
||||||
|
await run._cite(["orphan"])
|
||||||
475
tests/multi_db/test_lifecycle.py
Normal file
475
tests/multi_db/test_lifecycle.py
Normal file
|
|
@ -0,0 +1,475 @@
|
||||||
|
"""Opening, borrowing and closing the databases a client covers."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from docling_core.types.doc.document import DoclingDocument
|
||||||
|
from docling_core.types.doc.labels import DocItemLabel
|
||||||
|
|
||||||
|
from haiku.rag.client import HaikuRAG
|
||||||
|
from haiku.rag.client.session import FederatedSession
|
||||||
|
from haiku.rag.config import get_config
|
||||||
|
from haiku.rag.store.exceptions import (
|
||||||
|
AmbiguousDatabaseError,
|
||||||
|
SourceUnavailableError,
|
||||||
|
)
|
||||||
|
from haiku.rag.store.models import Chunk
|
||||||
|
from tests.multi_db.helpers import (
|
||||||
|
_config,
|
||||||
|
_seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpeningDatabases:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_databases_open_together(self, tmp_path):
|
||||||
|
"""A cold fan-out costs one open, not their sum. On object storage a
|
||||||
|
serial loop is the difference between one round trip and N."""
|
||||||
|
names = ["alpha", "beta", "gamma"]
|
||||||
|
config = _config(tmp_path, names)
|
||||||
|
for name in names:
|
||||||
|
await _seed(config, name, [f"{name} document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
assert isinstance(rag._session, FederatedSession)
|
||||||
|
barrier = asyncio.Barrier(len(names))
|
||||||
|
open_one = rag._session._open
|
||||||
|
|
||||||
|
async def gated(ref):
|
||||||
|
# Every open has to be in flight before any of them finishes, so
|
||||||
|
# a serial loop cannot get past this and the wait times out.
|
||||||
|
await barrier.wait()
|
||||||
|
return await open_one(ref)
|
||||||
|
|
||||||
|
rag._session._open = gated
|
||||||
|
clients = await asyncio.wait_for(rag.clients_for(names), timeout=15)
|
||||||
|
|
||||||
|
assert {client.source for client in clients} == set(names)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_failed_open_does_not_leak_the_ones_that_worked(self, tmp_path):
|
||||||
|
"""Opening together means a failure has siblings already open. They are
|
||||||
|
tracked before it is reported, so closing the set closes them."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
config.lancedb.databases["beta"] = str(tmp_path / "absent.lancedb")
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
with pytest.raises(SourceUnavailableError, match="beta"):
|
||||||
|
await rag.clients_for(["alpha", "beta"])
|
||||||
|
|
||||||
|
assert isinstance(rag._session, FederatedSession)
|
||||||
|
assert set(rag._session._sessions) == {"alpha"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_database_named_twice_is_opened_once(self, tmp_path):
|
||||||
|
"""Fusion would count a repeated database as two rank lists."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
clients = await rag.clients_for(["alpha", "alpha", "beta"])
|
||||||
|
|
||||||
|
assert [client.source for client in clients] == ["alpha", "beta"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_database_named_twice_returns_each_result_once(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
results = await rag.search(
|
||||||
|
"cats", limit=10, search_type="fts", sources=["alpha", "alpha"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [r.source for r in results] == ["alpha"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_one_database_named_twice_is_still_that_database(self, tmp_path):
|
||||||
|
"""A client covering a single named database compares the selection
|
||||||
|
against its own name, so repeats have to collapse first."""
|
||||||
|
config = _config(tmp_path, ["alpha"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
covering = await rag.clients_covering(["alpha", "alpha"])
|
||||||
|
|
||||||
|
assert [client.source for client in covering] == ["alpha"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestClosingASet:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_every_database_opened_is_released(self, tmp_path):
|
||||||
|
"""A covered database owns an embedder and may owe a vacuum. Closing only
|
||||||
|
its connection would leave both behind."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
|
||||||
|
released: list[str | None] = []
|
||||||
|
drained: list[str | None] = []
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config, read_only=True) as rag:
|
||||||
|
assert isinstance(rag._session, FederatedSession)
|
||||||
|
await rag.clients_for(["alpha", "beta"])
|
||||||
|
for name, session in rag._session._sessions.items():
|
||||||
|
original = session.store.embedder.aclose
|
||||||
|
drain = session.drain_vacuum
|
||||||
|
|
||||||
|
async def release(_original=original, _name=name):
|
||||||
|
released.append(_name)
|
||||||
|
return await _original()
|
||||||
|
|
||||||
|
async def drain_it(_drain=drain, _name=name):
|
||||||
|
drained.append(_name)
|
||||||
|
return await _drain()
|
||||||
|
|
||||||
|
session.store.embedder.aclose = release
|
||||||
|
session.drain_vacuum = drain_it
|
||||||
|
|
||||||
|
assert sorted(released) == ["alpha", "beta"]
|
||||||
|
assert sorted(drained) == ["alpha", "beta"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestBorrowedDatabases:
|
||||||
|
"""A client for one of a set wraps a database the set opened."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_closing_a_borrowed_client_leaves_the_set_working(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
(alpha,) = await rag.clients_for(["alpha"])
|
||||||
|
store = alpha.store
|
||||||
|
|
||||||
|
alpha.close()
|
||||||
|
assert store.db.is_open(), "close() closed a database it borrowed"
|
||||||
|
|
||||||
|
await alpha.__aexit__(None, None, None)
|
||||||
|
assert store.db.is_open(), "exit closed a database it borrowed"
|
||||||
|
|
||||||
|
results = await rag.search("cats", search_type="fts")
|
||||||
|
|
||||||
|
assert {r.source for r in results} == {"alpha", "beta"}
|
||||||
|
assert not store.db.is_open(), "the set left a database open"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_entering_a_borrowed_client_reuses_its_database(self, tmp_path):
|
||||||
|
"""`async with` on a borrowed client is a plausible thing to write.
|
||||||
|
Opening a second session would leak it, since teardown declines to close
|
||||||
|
what this client did not open."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
(alpha,) = await rag.clients_for(["alpha"])
|
||||||
|
borrowed = alpha.store
|
||||||
|
|
||||||
|
async with alpha as entered:
|
||||||
|
assert entered is alpha
|
||||||
|
assert alpha.store is borrowed, "entry opened a second database"
|
||||||
|
|
||||||
|
assert borrowed.db.is_open(), "exit closed a database it borrowed"
|
||||||
|
assert alpha.store is borrowed
|
||||||
|
|
||||||
|
assert not borrowed.db.is_open(), "the set left a database open"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_borrowed_client_releases_what_it_built(self, tmp_path):
|
||||||
|
"""Its reranker is its own; the database it wraps is not."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
closed: list[str] = []
|
||||||
|
|
||||||
|
class Reranker:
|
||||||
|
async def aclose(self):
|
||||||
|
closed.append("reranker")
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
(alpha,) = await rag.clients_for(["alpha"])
|
||||||
|
alpha.__dict__["reranker"] = Reranker()
|
||||||
|
|
||||||
|
assert closed == ["reranker"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLazyOpening:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_entering_opens_nothing(self, tmp_path):
|
||||||
|
"""25 configured databases queried a few at a time must not all open."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
assert rag._clients == {}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_only_the_selected_database_opens(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
await rag.search("cats", search_type="fts", sources=["alpha"])
|
||||||
|
assert list(rag._clients) == ["alpha"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_unselected_broken_database_does_not_break_a_query(self, tmp_path):
|
||||||
|
"""A database nobody asked for cannot fail a query."""
|
||||||
|
config = _config(tmp_path, ["alpha", "missing"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
results = await rag.search("cats", search_type="fts", sources=["alpha"])
|
||||||
|
|
||||||
|
assert [r.source for r in results] == ["alpha"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_selected_broken_database_fails_the_query(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "missing"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
with pytest.raises(SourceUnavailableError, match="missing"):
|
||||||
|
await rag.search("cats", search_type="fts")
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadOnlyMode:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_client_covering_a_set_reports_its_mode(self, tmp_path):
|
||||||
|
"""A client covering a set has no store of its own to ask."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config, read_only=True) as rag:
|
||||||
|
assert rag.is_read_only is True
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
assert rag.is_read_only is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestFailureNaming:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_single_named_database_is_reported_by_name(self, tmp_path):
|
||||||
|
"""One configured database is still a named one: it must not fall back to
|
||||||
|
the raw error, which spells out the path."""
|
||||||
|
config = _config(tmp_path, ["alpha"])
|
||||||
|
|
||||||
|
with pytest.raises(SourceUnavailableError, match="alpha") as caught:
|
||||||
|
async with HaikuRAG(config=config):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert str(tmp_path) not in str(caught.value)
|
||||||
|
assert caught.value.__cause__ is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_legacy_uri_client_keeps_its_error(self, tmp_path):
|
||||||
|
"""Nothing named it, so there is no name to report instead."""
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
async with HaikuRAG(tmp_path / "nope.lancedb"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_location_is_absent_from_the_whole_chain(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "missing"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
with pytest.raises(SourceUnavailableError) as caught:
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
await rag.search("cats", search_type="fts")
|
||||||
|
|
||||||
|
rendered = str(caught.value)
|
||||||
|
error = caught.value.__cause__ or caught.value.__context__
|
||||||
|
assert "missing.lancedb" not in rendered
|
||||||
|
assert error is None, "the location-bearing cause is still attached"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreatingNeedsOneDatabase:
|
||||||
|
"""Creating names a database. Covering a set, the flag had nothing to act on
|
||||||
|
and was accepted anyway, leaving the first query to fail on whichever
|
||||||
|
database turned out to be missing."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_creating_a_set_is_refused(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
|
||||||
|
with pytest.raises(AmbiguousDatabaseError, match="alpha, beta"):
|
||||||
|
async with HaikuRAG(config=config, create=True):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_naming_one_of_the_set_creates_it(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config, create=True, sources=["alpha"]) as rag:
|
||||||
|
assert await rag.count_documents() == 0
|
||||||
|
|
||||||
|
assert (tmp_path / "alpha.lancedb").exists()
|
||||||
|
assert not (tmp_path / "beta.lancedb").exists()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_covering_a_set_without_creating_is_unaffected(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
assert await rag.count_documents() == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestOperationsThatNeedOneDatabase:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_writing_names_the_databases_it_covers(self, tmp_path):
|
||||||
|
"""A domain error, so a caller can tell an unsupported selection from a
|
||||||
|
missing attribute."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
with pytest.raises(AmbiguousDatabaseError, match="alpha, beta"):
|
||||||
|
await rag.create_document("orphan")
|
||||||
|
with pytest.raises(AmbiguousDatabaseError, match="clients_for"):
|
||||||
|
await rag.vacuum()
|
||||||
|
with pytest.raises(AmbiguousDatabaseError, match="close"):
|
||||||
|
rag.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_set_has_no_store_of_its_own(self, tmp_path):
|
||||||
|
"""A store and its repositories belong to one database. `clients_for`
|
||||||
|
reaches the one holding a given database."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
for name in (
|
||||||
|
"store",
|
||||||
|
"document_repository",
|
||||||
|
"chunk_repository",
|
||||||
|
"document_item_repository",
|
||||||
|
):
|
||||||
|
with pytest.raises(AttributeError, match=name):
|
||||||
|
getattr(rag, name)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_selected_database_is_still_writable(self, tmp_path):
|
||||||
|
"""Naming one of the set is how a write picks its database."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
|
||||||
|
dim = get_config().embeddings.model.vector_dim
|
||||||
|
written = DoclingDocument(name="written")
|
||||||
|
written.add_text(label=DocItemLabel.TEXT, text="written")
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
alpha = (await rag.clients_for(["alpha"]))[0]
|
||||||
|
assert alpha.is_read_only is False
|
||||||
|
document = await alpha.import_document(
|
||||||
|
written,
|
||||||
|
[Chunk(content="written", embedding=[0.1] * dim, order=0)],
|
||||||
|
uri="test://alpha/written",
|
||||||
|
)
|
||||||
|
assert await alpha.count_documents() == 2
|
||||||
|
|
||||||
|
assert document.id is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestDatabaseIndependentWork:
|
||||||
|
"""Converting, chunking and titling are functions of the configuration, not
|
||||||
|
of a database, so covering a set does not stop them."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chunking_opens_no_database(self, tmp_path, monkeypatch):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
|
||||||
|
opened: list[str] = []
|
||||||
|
|
||||||
|
async def refuse(self, ref):
|
||||||
|
opened.append(ref.name)
|
||||||
|
raise AssertionError("opened a database to chunk a document")
|
||||||
|
|
||||||
|
monkeypatch.setattr(FederatedSession, "_open", refuse)
|
||||||
|
|
||||||
|
doc = DoclingDocument(name="note")
|
||||||
|
doc.add_text(
|
||||||
|
label=DocItemLabel.TEXT, text="Boltzmann machines are energy based."
|
||||||
|
)
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config, read_only=True) as rag:
|
||||||
|
chunks = await rag.chunk(doc)
|
||||||
|
|
||||||
|
assert opened == []
|
||||||
|
assert [c.content for c in chunks]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_embedder_is_built_once_and_closed_once(
|
||||||
|
self, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""The parent owns the embedder it built, so leaving the context closes
|
||||||
|
it, once."""
|
||||||
|
from haiku.rag.embeddings import EmbedderWrapper
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
|
||||||
|
closed: list[object] = []
|
||||||
|
original = EmbedderWrapper.aclose
|
||||||
|
|
||||||
|
async def counting(self):
|
||||||
|
closed.append(self)
|
||||||
|
return await original(self)
|
||||||
|
|
||||||
|
monkeypatch.setattr(EmbedderWrapper, "aclose", counting)
|
||||||
|
|
||||||
|
rag = HaikuRAG(config=config, read_only=True)
|
||||||
|
async with rag:
|
||||||
|
built = rag.embedder
|
||||||
|
assert rag.embedder is built
|
||||||
|
|
||||||
|
assert closed == [built]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_re_entering_a_set_builds_a_fresh_embedder(self, tmp_path):
|
||||||
|
"""Teardown closes the embedder, so keeping it would hand the next
|
||||||
|
context one that is already closed."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
|
||||||
|
rag = HaikuRAG(config=config, read_only=True)
|
||||||
|
async with rag:
|
||||||
|
first = rag.embedder
|
||||||
|
async with rag:
|
||||||
|
assert rag.embedder is not first
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_re_entering_one_database_builds_a_fresh_embedder(self, temp_db_path):
|
||||||
|
"""One database opens a new store on re-entry, and the embedder is that
|
||||||
|
store's."""
|
||||||
|
rag = HaikuRAG(temp_db_path, create=True)
|
||||||
|
async with rag:
|
||||||
|
first = rag.embedder
|
||||||
|
async with rag:
|
||||||
|
assert rag.embedder is rag.store.embedder
|
||||||
|
assert rag.embedder is not first
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_set_nobody_asked_anything_of_builds_no_embedder(self, tmp_path):
|
||||||
|
"""Built on first use, so a client that answered nothing holds nothing."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config, read_only=True) as rag:
|
||||||
|
assert "embedder" not in rag.__dict__
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_one_database_still_uses_its_store_s_embedder(self, temp_db_path):
|
||||||
|
async with HaikuRAG(temp_db_path, create=True) as rag:
|
||||||
|
assert rag.embedder is rag.store.embedder
|
||||||
288
tests/multi_db/test_scope.py
Normal file
288
tests/multi_db/test_scope.py
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
"""Resolving which databases an operation covers."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from haiku.rag.client import HaikuRAG
|
||||||
|
from haiku.rag.client.scope import DatabaseScope
|
||||||
|
from haiku.rag.config.models import AppConfig, LanceDBConfig
|
||||||
|
from haiku.rag.utils import locate_database
|
||||||
|
from tests.multi_db.helpers import (
|
||||||
|
_config,
|
||||||
|
_seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfig:
|
||||||
|
def test_databases_and_uri_are_mutually_exclusive(self):
|
||||||
|
with pytest.raises(ValidationError, match="databases"):
|
||||||
|
LanceDBConfig(
|
||||||
|
uri="s3://b/one.lancedb", databases={"one": "s3://b/one.lancedb"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_databases_alone_is_fine(self):
|
||||||
|
config = LanceDBConfig(databases={"one": "s3://b/one.lancedb"})
|
||||||
|
assert config.databases == {"one": "s3://b/one.lancedb"}
|
||||||
|
|
||||||
|
def test_uri_alone_is_fine(self):
|
||||||
|
assert LanceDBConfig(uri="s3://b/one.lancedb").databases == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestNamingIsRequired:
|
||||||
|
def test_a_blank_name_is_rejected(self):
|
||||||
|
"""An unnamed database is unreachable: every source check reads the
|
||||||
|
empty name as no name at all."""
|
||||||
|
with pytest.raises(ValidationError, match="entry with no name"):
|
||||||
|
LanceDBConfig(databases={"": "/tmp/a.lancedb"})
|
||||||
|
with pytest.raises(ValidationError, match="entry with no name"):
|
||||||
|
LanceDBConfig(databases={" ": "/tmp/a.lancedb"})
|
||||||
|
|
||||||
|
def test_a_blank_location_is_rejected(self):
|
||||||
|
"""A blank location resolves to the working directory."""
|
||||||
|
with pytest.raises(
|
||||||
|
ValidationError, match=r"databases\[alpha\] has no location"
|
||||||
|
):
|
||||||
|
LanceDBConfig(databases={"alpha": ""})
|
||||||
|
|
||||||
|
|
||||||
|
class TestNamingADatabaseDirectly:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_explicit_db_path_wins_over_the_configured_set(
|
||||||
|
self, tmp_path, temp_db_path
|
||||||
|
):
|
||||||
|
"""A caller that names a path means that database, not the configured
|
||||||
|
set: the CLI resolves `--db` to one and must not fan out instead."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(temp_db_path, config=config, create=True) as rag:
|
||||||
|
assert not rag.covers_multiple
|
||||||
|
assert rag.source is None
|
||||||
|
assert rag.store.db_path == temp_db_path
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_one_configured_database_is_opened_by_name(self, tmp_path):
|
||||||
|
"""A set of one is not federated, and the client resolves it."""
|
||||||
|
config = _config(tmp_path, ["alpha"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
assert not rag.covers_multiple
|
||||||
|
assert rag.source == "alpha"
|
||||||
|
results = await rag.search("cats", search_type="fts", limit=10)
|
||||||
|
|
||||||
|
assert [r.source for r in results] == ["alpha"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestOneConfiguredLocation:
|
||||||
|
"""`lancedb.uri` places one unnamed database, at a URI or at a local path."""
|
||||||
|
|
||||||
|
def _config(self, location) -> AppConfig:
|
||||||
|
return AppConfig(lancedb=LanceDBConfig(uri=str(location)))
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_local_uri_opens_the_configured_database(self, tmp_path):
|
||||||
|
located = tmp_path / "notes.lancedb"
|
||||||
|
config = self._config(located)
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config, create=True) as rag:
|
||||||
|
assert rag.store.db_path == located
|
||||||
|
# It places a database without naming one: only `lancedb.databases`
|
||||||
|
# assigns the name results and citations carry.
|
||||||
|
assert rag.source is None
|
||||||
|
assert located.exists()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_explicit_path_overrides_a_local_uri(self, tmp_path):
|
||||||
|
"""`--db` overrides the configured location for one invocation."""
|
||||||
|
config = self._config(tmp_path / "configured.lancedb")
|
||||||
|
chosen = tmp_path / "chosen.lancedb"
|
||||||
|
|
||||||
|
async with HaikuRAG(chosen, config=config, create=True) as rag:
|
||||||
|
assert rag.store.db_path == chosen
|
||||||
|
assert chosen.exists()
|
||||||
|
assert not (tmp_path / "configured.lancedb").exists()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_local_uri_that_does_not_exist_is_refused(self, tmp_path):
|
||||||
|
"""A mistyped path fails instead of quietly becoming an empty database,
|
||||||
|
which is what a value carrying a scheme would do."""
|
||||||
|
config = self._config(tmp_path / "typo.lancedb")
|
||||||
|
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
async with HaikuRAG(config=config):
|
||||||
|
pass
|
||||||
|
assert not (tmp_path / "typo.lancedb").exists()
|
||||||
|
|
||||||
|
def test_a_uri_with_a_scheme_stays_a_uri(self, tmp_path):
|
||||||
|
"""Object storage has no local path to check, and a location that does
|
||||||
|
not exist yet is normal there."""
|
||||||
|
from haiku.rag.store.engine import ConnectionMode
|
||||||
|
|
||||||
|
config = self._config("s3://bucket/one.lancedb")
|
||||||
|
|
||||||
|
[ref] = DatabaseScope.resolve(config).databases
|
||||||
|
one, db_path = ref.connection(config)
|
||||||
|
|
||||||
|
assert db_path is None
|
||||||
|
assert ConnectionMode.from_config(one) == ConnectionMode.OBJECT_STORAGE
|
||||||
|
|
||||||
|
|
||||||
|
class TestLocate:
|
||||||
|
def test_a_scheme_is_a_uri(self):
|
||||||
|
assert locate_database("s3://bucket/one.lancedb") == (
|
||||||
|
"s3://bucket/one.lancedb",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_anything_else_is_a_local_path(self):
|
||||||
|
uri, db_path = locate_database("/data/one.lancedb")
|
||||||
|
assert uri == ""
|
||||||
|
assert db_path is not None and str(db_path) == "/data/one.lancedb"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSelection:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_source_at_construction_is_rejected(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
|
||||||
|
with pytest.raises(KeyError, match="nope"):
|
||||||
|
async with HaikuRAG(config=config, sources=["nope"]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_source_across_several_databases_is_rejected(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
with pytest.raises(KeyError, match="nope"):
|
||||||
|
await rag.search("cats", search_type="fts", sources=["nope"])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_matches_anywhere_returns_nothing(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
assert await rag.search("aardvarks", search_type="fts") == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlacingADatabase:
|
||||||
|
"""What a client says about the databases it covers, so nothing outside has
|
||||||
|
to read its private state to find out."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_set_names_every_database_it_covers(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config, read_only=True) as rag:
|
||||||
|
assert rag.covers_multiple
|
||||||
|
assert rag.source_names == ("alpha", "beta")
|
||||||
|
assert rag.source is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_one_named_database_names_itself(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config, read_only=True, sources=["alpha"]) as rag:
|
||||||
|
assert not rag.covers_multiple
|
||||||
|
assert rag.source_names == ("alpha",)
|
||||||
|
assert rag.source == "alpha"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_named_database_keeps_its_name_on_re_entry(self, tmp_path):
|
||||||
|
"""Entering derives a single-database configuration from what was
|
||||||
|
configured. Deriving it from the last derivation loses the name."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
rag = HaikuRAG(config=config, read_only=True, sources=["alpha"])
|
||||||
|
async with rag:
|
||||||
|
assert rag.source == "alpha"
|
||||||
|
async with rag:
|
||||||
|
assert rag.source == "alpha"
|
||||||
|
assert rag.source_names == ("alpha",)
|
||||||
|
results = await rag.search("cats", search_type="fts")
|
||||||
|
|
||||||
|
assert {r.source for r in results} == {"alpha"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_unnamed_database_names_nothing(self, temp_db_path):
|
||||||
|
async with HaikuRAG(temp_db_path, create=True) as rag:
|
||||||
|
assert rag.source_names == ()
|
||||||
|
assert rag.source is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_reader_for_a_database_is_the_client_holding_it(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config, read_only=True) as rag:
|
||||||
|
reader = await rag.reader_for("beta")
|
||||||
|
|
||||||
|
assert reader is not None
|
||||||
|
assert reader.source == "beta"
|
||||||
|
# Asked twice, the same wrapper comes back.
|
||||||
|
assert await rag.reader_for("beta") is reader
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_client_reading_one_database_is_its_own_reader(self, temp_db_path):
|
||||||
|
async with HaikuRAG(temp_db_path, create=True) as rag:
|
||||||
|
assert await rag.reader_for(None) is rag
|
||||||
|
assert await rag.reader_for("anything") is rag
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_set_cannot_place_evidence_that_names_no_database(self, tmp_path):
|
||||||
|
"""Evidence recorded before databases could be named carries no source."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config, read_only=True) as rag:
|
||||||
|
assert await rag.reader_for(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestNamingOneOfTheSetOnTheCommandLine:
|
||||||
|
"""`--database NAME` reaches the application layer as a name, and every
|
||||||
|
client it opens has to honour it — one that ignores it covers the set and
|
||||||
|
quietly answers from the wrong database."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_named_database_is_the_one_read(self, tmp_path, capsys):
|
||||||
|
from haiku.rag.app import HaikuRAGApp
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
scope = DatabaseScope.resolve(config).select(["beta"])
|
||||||
|
app = HaikuRAGApp(scope=scope, config=config, read_only=True)
|
||||||
|
await app.list_documents()
|
||||||
|
|
||||||
|
# Rich wraps long lines, so match the unwrapped part of the URI.
|
||||||
|
printed = capsys.readouterr().out
|
||||||
|
assert "test://beta/" in printed
|
||||||
|
assert "test://alpha/" not in printed
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_naming_none_of_them_covers_the_set(self, tmp_path, capsys):
|
||||||
|
from haiku.rag.app import HaikuRAGApp
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
app = HaikuRAGApp(
|
||||||
|
scope=DatabaseScope.resolve(config), config=config, read_only=True
|
||||||
|
)
|
||||||
|
await app.list_documents()
|
||||||
|
|
||||||
|
printed = capsys.readouterr().out
|
||||||
|
assert "test://alpha/" in printed
|
||||||
|
assert "test://beta/" in printed
|
||||||
375
tests/multi_db/test_search.py
Normal file
375
tests/multi_db/test_search.py
Normal file
|
|
@ -0,0 +1,375 @@
|
||||||
|
"""Searching several databases and fusing what they return."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from docling_core.types.doc.document import DoclingDocument
|
||||||
|
from docling_core.types.doc.labels import DocItemLabel
|
||||||
|
|
||||||
|
from haiku.rag.client import HaikuRAG
|
||||||
|
from haiku.rag.client.session import FederatedSession
|
||||||
|
from haiku.rag.config import get_config
|
||||||
|
from haiku.rag.store.exceptions import (
|
||||||
|
ConfigMismatchError,
|
||||||
|
SourceUnavailableError,
|
||||||
|
)
|
||||||
|
from haiku.rag.store.models import Chunk, DocumentItem
|
||||||
|
from tests.multi_db.helpers import (
|
||||||
|
StubReranker,
|
||||||
|
_config,
|
||||||
|
_restore_embedder,
|
||||||
|
_seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFederatedSearch:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_results_carry_their_source(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
results = await rag.search("cats", limit=10, search_type="fts")
|
||||||
|
|
||||||
|
assert {r.source for r in results} == {"alpha", "beta"}
|
||||||
|
for r in results:
|
||||||
|
assert r.source is not None
|
||||||
|
assert r.source in r.content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sources_selects_a_subset(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
results = await rag.search(
|
||||||
|
"cats", limit=10, search_type="fts", sources=["alpha"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {r.source for r in results} == {"alpha"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_source_is_rejected(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
with pytest.raises(KeyError, match="nope"):
|
||||||
|
await rag.search("cats", search_type="fts", sources=["nope"])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_unopenable_database_fails_the_query(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "missing"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
with pytest.raises(SourceUnavailableError, match="missing"):
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
await rag.search("cats", search_type="fts")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSingleDatabaseUnchanged:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_source_is_unset_without_configured_databases(self, temp_db_path):
|
||||||
|
async with HaikuRAG(temp_db_path, create=True) as rag:
|
||||||
|
doc = DoclingDocument(name="one")
|
||||||
|
doc.add_text(label=DocItemLabel.TEXT, text="a document about cats")
|
||||||
|
await rag.import_document(
|
||||||
|
doc,
|
||||||
|
[
|
||||||
|
Chunk(
|
||||||
|
content="a document about cats",
|
||||||
|
embedding=[0.1] * get_config().embeddings.model.vector_dim,
|
||||||
|
order=0,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
uri="test://one",
|
||||||
|
)
|
||||||
|
results = await rag.search("cats", search_type="fts")
|
||||||
|
|
||||||
|
assert results
|
||||||
|
assert all(r.source is None for r in results)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOneQueryVector:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_search_embeds_the_query_once_for_the_whole_set(
|
||||||
|
self, tmp_path, query_embedding
|
||||||
|
):
|
||||||
|
"""Each database owns an embedder, so embedding per database costs a
|
||||||
|
round trip each on a remote endpoint."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta", "gamma"])
|
||||||
|
for name in ("alpha", "beta", "gamma"):
|
||||||
|
await _seed(config, name, [f"{name} one"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config, read_only=True) as rag:
|
||||||
|
await rag.search("one")
|
||||||
|
|
||||||
|
assert query_embedding == ["one"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestOneEmbedderAcrossTheSet:
|
||||||
|
"""A set is searched with one query vector, so a database written with
|
||||||
|
another model would answer from a different space."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disagreeing_databases_cannot_be_searched_together(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
await _restore_embedder(config, "beta", model_name="some-other-model")
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config, read_only=True) as rag:
|
||||||
|
with pytest.raises(ConfigMismatchError, match="different embedders"):
|
||||||
|
await rag.search("one")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_database_asked_for_alone_is_never_compared(
|
||||||
|
self, tmp_path, query_embedding
|
||||||
|
):
|
||||||
|
"""Only databases searched together have to agree."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
await _restore_embedder(config, "beta", model_name="some-other-model")
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config, read_only=True) as rag:
|
||||||
|
assert await rag.search("one", sources=["alpha"]) is not None
|
||||||
|
assert await rag.count_documents(filter=None) is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_text_search_needs_no_agreement(self, tmp_path):
|
||||||
|
"""Full-text search embeds nothing, so which model wrote each database
|
||||||
|
does not come into it."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
await _restore_embedder(config, "beta", model_name="some-other-model")
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config, read_only=True) as rag:
|
||||||
|
results = await rag.search("one", search_type="fts")
|
||||||
|
|
||||||
|
assert {r.source for r in results} == {"alpha", "beta"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_agreeing_databases_search_together(self, tmp_path, query_embedding):
|
||||||
|
"""The databases agree with each other; that they were written by a
|
||||||
|
differently-spelled provider than the config is the soft case."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
await _restore_embedder(config, "alpha", provider="openai")
|
||||||
|
await _restore_embedder(config, "beta", provider="openai")
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config, read_only=True) as rag:
|
||||||
|
assert len(await rag.search("one")) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestRerankerFusion:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_reranker_scores_the_union_and_owners_survive(
|
||||||
|
self, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
stub = StubReranker()
|
||||||
|
monkeypatch.setattr(HaikuRAG, "reranker", property(lambda self: stub))
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
results = await rag.search("cats", limit=2, search_type="fts")
|
||||||
|
|
||||||
|
# It saw both databases' candidates, not one database at a time.
|
||||||
|
assert len(stub.seen) == 2
|
||||||
|
assert {c.split()[0] for c in stub.seen} == {"alpha", "beta"}
|
||||||
|
# Each result still knows which database it came from.
|
||||||
|
for r in results:
|
||||||
|
assert r.source is not None
|
||||||
|
assert r.content.startswith(r.source)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_closing_failure_does_not_mask_the_exit(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
rag = HaikuRAG(config=config)
|
||||||
|
await rag.__aenter__()
|
||||||
|
await rag.clients_for(["alpha", "beta"])
|
||||||
|
assert isinstance(rag._session, FederatedSession)
|
||||||
|
sessions = rag._session._sessions
|
||||||
|
|
||||||
|
async def boom():
|
||||||
|
raise RuntimeError("close failed")
|
||||||
|
|
||||||
|
sessions["alpha"].aclose = boom # ty: ignore[invalid-assignment]
|
||||||
|
beta = sessions["beta"].store
|
||||||
|
|
||||||
|
await rag.__aexit__(None, None, None)
|
||||||
|
|
||||||
|
# The failure is swallowed, and the sibling is still closed after it.
|
||||||
|
assert rag._clients == {}
|
||||||
|
assert rag._session._sessions == {}
|
||||||
|
assert not beta.db.is_open()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multimodal_reranking_attaches_each_database_own_pictures(
|
||||||
|
self, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""Picture self_refs repeat across databases exactly as they do across
|
||||||
|
documents, so the pre-rerank attach must stay per database."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
config.reranking.multimodal = True
|
||||||
|
dim = get_config().embeddings.model.vector_dim
|
||||||
|
|
||||||
|
for name in ("alpha", "beta"):
|
||||||
|
async with HaikuRAG(config=config, create=True, sources=[name]) as rag:
|
||||||
|
doc = DoclingDocument(name=name)
|
||||||
|
doc.add_text(label=DocItemLabel.TEXT, text=f"{name} figure of cats")
|
||||||
|
await rag.import_document(
|
||||||
|
doc,
|
||||||
|
[
|
||||||
|
Chunk(
|
||||||
|
content=f"{name} figure of cats",
|
||||||
|
embedding=[0.1] * dim,
|
||||||
|
order=0,
|
||||||
|
metadata={
|
||||||
|
"doc_item_refs": ["#/pictures/0"],
|
||||||
|
"labels": ["picture"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
uri=f"test://{name}/figure",
|
||||||
|
)
|
||||||
|
[document] = await rag.list_documents()
|
||||||
|
assert document.id is not None
|
||||||
|
await rag.document_item_repository.create_items(
|
||||||
|
document.id,
|
||||||
|
[
|
||||||
|
DocumentItem(
|
||||||
|
document_id=document.id,
|
||||||
|
position=0,
|
||||||
|
self_ref="#/pictures/0",
|
||||||
|
label="picture",
|
||||||
|
text=f"caption {name}",
|
||||||
|
picture_data=f"bytes-{name}".encode(),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
stub = StubReranker()
|
||||||
|
monkeypatch.setattr(HaikuRAG, "reranker", property(lambda self: stub))
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
await rag.search("cats", limit=2, search_type="fts")
|
||||||
|
|
||||||
|
assert stub.attached == {"alpha": b"bytes-alpha", "beta": b"bytes-beta"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestOneReranker:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_set_builds_one_reranker_for_a_text_query(
|
||||||
|
self, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""Local rerankers load model weights per instance, so a set of
|
||||||
|
databases must build one, not one each."""
|
||||||
|
built = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"haiku.rag.client.get_reranker",
|
||||||
|
lambda config: built.append(config) or StubReranker(),
|
||||||
|
)
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
built.clear()
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
await rag.search("cats", limit=2, search_type="fts")
|
||||||
|
|
||||||
|
assert len(built) == 1, f"built {len(built)} rerankers"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_image_query_builds_no_reranker(self, tmp_path, monkeypatch):
|
||||||
|
"""Opening a database must not build one either: an image query has no
|
||||||
|
text to score against and never uses it."""
|
||||||
|
built = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"haiku.rag.client.get_reranker",
|
||||||
|
lambda config: built.append(config) or StubReranker(),
|
||||||
|
)
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
built.clear()
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
await rag.clients_for(["alpha", "beta"])
|
||||||
|
|
||||||
|
assert built == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_reranker_is_closed_once(self, tmp_path, monkeypatch):
|
||||||
|
"""Handing the same object to every database and letting each close it
|
||||||
|
would close it N times, and the federator not at all."""
|
||||||
|
closes = []
|
||||||
|
|
||||||
|
class CountingReranker(StubReranker):
|
||||||
|
async def aclose(self):
|
||||||
|
closes.append(1)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"haiku.rag.client.get_reranker", lambda config: CountingReranker()
|
||||||
|
)
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
await rag.search("cats", limit=2, search_type="fts")
|
||||||
|
|
||||||
|
assert closes == [1], f"closed {len(closes)} times"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOneNamedDatabase:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_single_named_database_keeps_its_name(self, tmp_path):
|
||||||
|
"""Named in config is named in results, even as the only entry."""
|
||||||
|
config = _config(tmp_path, ["alpha"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
results = await rag.search("cats", search_type="fts")
|
||||||
|
|
||||||
|
assert results
|
||||||
|
assert all(r.source == "alpha" for r in results)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_selecting_nothing_at_construction_is_rejected(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="selects no database"):
|
||||||
|
async with HaikuRAG(config=config, sources=[]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_selecting_nothing_means_the_same_with_one_database(self, tmp_path):
|
||||||
|
"""`sources=[]` selects nothing whether one database is configured or
|
||||||
|
several, rather than raising on one path and returning nothing on the
|
||||||
|
other."""
|
||||||
|
config = _config(tmp_path, ["alpha"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
assert await rag.search("cats", search_type="fts", sources=[]) == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_selecting_nothing_per_query_returns_nothing(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
assert await rag.search("cats", search_type="fts", sources=[]) == []
|
||||||
|
|
@ -5,7 +5,7 @@ import pytest
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
from haiku.rag.client.scope import DatabaseRef, DatabaseScope
|
from haiku.rag.client.scope import DatabaseRef, DatabaseScope
|
||||||
from haiku.rag.sandbox import AnalysisContext, Sandbox
|
from haiku.rag.sandbox import AnalysisContext, Sandbox
|
||||||
from tests.test_multi_db import _config, _seed
|
from tests.multi_db.helpers import _config, _seed
|
||||||
|
|
||||||
|
|
||||||
async def _mounted(rag, sources=None):
|
async def _mounted(rag, sources=None):
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,814 +0,0 @@
|
||||||
import pytest
|
|
||||||
from docling_core.types.doc.document import DoclingDocument
|
|
||||||
from docling_core.types.doc.labels import DocItemLabel
|
|
||||||
from pydantic_ai import ModelRetry
|
|
||||||
|
|
||||||
from haiku.rag.capabilities.rag import RAGState, create_capability
|
|
||||||
from haiku.rag.client import HaikuRAG
|
|
||||||
from haiku.rag.config import get_config
|
|
||||||
from haiku.rag.store.exceptions import AmbiguousCitationError
|
|
||||||
from haiku.rag.store.models import Chunk, Document, DocumentItem, SearchResult
|
|
||||||
from haiku.rag.store.models.citation import Citation, resolve_citations
|
|
||||||
from tests.test_multi_db import _config, _seed
|
|
||||||
|
|
||||||
|
|
||||||
async def _seed_expandable(config, name, sentences):
|
|
||||||
"""One document whose chunk covers a single item, so expansion has
|
|
||||||
neighbours to pull in and rebuilds the result rather than passing it
|
|
||||||
through."""
|
|
||||||
dim = get_config().embeddings.model.vector_dim
|
|
||||||
doc = DoclingDocument(name=name)
|
|
||||||
for sentence in sentences:
|
|
||||||
doc.add_text(label=DocItemLabel.TEXT, text=sentence)
|
|
||||||
async with HaikuRAG(config=config, create=True, sources=[name]) as rag:
|
|
||||||
await rag.import_document(
|
|
||||||
doc,
|
|
||||||
[
|
|
||||||
Chunk(
|
|
||||||
content=sentences[0],
|
|
||||||
embedding=[0.1] * dim,
|
|
||||||
order=0,
|
|
||||||
metadata={"doc_item_refs": ["#/texts/0"]},
|
|
||||||
)
|
|
||||||
],
|
|
||||||
uri=f"test://{name}/expandable",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestExpansionRouting:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_expansion_routes_each_result_to_its_database(self, tmp_path):
|
|
||||||
"""A federating client has no repositories of its own, so expansion has
|
|
||||||
to go through the database each result came from."""
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
|
||||||
await _seed(config, "alpha", ["alpha document about cats"])
|
|
||||||
await _seed(config, "beta", ["beta document about cats"])
|
|
||||||
|
|
||||||
async with HaikuRAG(config=config) as rag:
|
|
||||||
results = await rag.search("cats", search_type="fts", limit=10)
|
|
||||||
expanded = await rag.expand_context(results)
|
|
||||||
|
|
||||||
assert {r.source for r in expanded} == {"alpha", "beta"}
|
|
||||||
for r in expanded:
|
|
||||||
assert r.source is not None
|
|
||||||
assert r.source in r.content
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_an_expanded_result_keeps_its_source(self, tmp_path):
|
|
||||||
"""Expansion rebuilds the result, and the rebuilt one has to name the
|
|
||||||
database it was expanded through."""
|
|
||||||
config = _config(tmp_path, ["alpha"])
|
|
||||||
await _seed_expandable(
|
|
||||||
config, "alpha", ["cats sleep often", "cats also hunt", "cats purr"]
|
|
||||||
)
|
|
||||||
|
|
||||||
async with HaikuRAG(config=config) as rag:
|
|
||||||
results = await rag.search("cats", search_type="fts", limit=10)
|
|
||||||
expanded = await rag.expand_context(results)
|
|
||||||
|
|
||||||
assert len(expanded) == 1
|
|
||||||
assert "cats also hunt" in expanded[0].content, "expansion did not run"
|
|
||||||
assert expanded[0].source == "alpha"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_a_federated_result_is_expanded_by_its_own_database(self, tmp_path):
|
|
||||||
"""Routing is not enough: each result has to come back carrying the
|
|
||||||
neighbours of the database it was expanded through, and only those."""
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
|
||||||
await _seed_expandable(
|
|
||||||
config, "alpha", ["cats sleep often", "alpha follows on"]
|
|
||||||
)
|
|
||||||
await _seed_expandable(config, "beta", ["cats also hunt", "beta follows on"])
|
|
||||||
|
|
||||||
async with HaikuRAG(config=config) as rag:
|
|
||||||
results = await rag.search("cats", search_type="fts", limit=10)
|
|
||||||
expanded = await rag.expand_context(results)
|
|
||||||
|
|
||||||
content = {r.source: r.content for r in expanded}
|
|
||||||
assert "alpha follows on" in content["alpha"]
|
|
||||||
assert "beta follows on" not in content["alpha"]
|
|
||||||
assert "beta follows on" in content["beta"]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_expansion_keeps_tied_results_in_fused_order(self, tmp_path):
|
|
||||||
"""Fused scores tie often, so grouping by database must not reorder
|
|
||||||
them: the tiebreak is the order they arrived in."""
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
|
||||||
await _seed(config, "alpha", ["alpha one about cats", "alpha two about cats"])
|
|
||||||
await _seed(config, "beta", ["beta one about cats"])
|
|
||||||
|
|
||||||
async with HaikuRAG(config=config) as rag:
|
|
||||||
found = await rag.search("cats", search_type="fts", limit=10)
|
|
||||||
by_source: dict[str, list[SearchResult]] = {}
|
|
||||||
for result in found:
|
|
||||||
by_source.setdefault(result.source or "", []).append(result)
|
|
||||||
# Interleaved, so grouping by database is visible as a reordering.
|
|
||||||
fused = [by_source["alpha"][0], by_source["beta"][0], by_source["alpha"][1]]
|
|
||||||
for result in fused:
|
|
||||||
result.score = 0.5
|
|
||||||
|
|
||||||
expanded = await rag.expand_context(fused)
|
|
||||||
|
|
||||||
assert [r.chunk_id for r in expanded] == [r.chunk_id for r in fused]
|
|
||||||
|
|
||||||
|
|
||||||
class TestSharedChunkIds:
|
|
||||||
"""A database copied from another holds the same chunk ids."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_a_shared_id_does_not_confuse_the_fused_order(self, tmp_path):
|
|
||||||
"""Arrival order breaks score ties, so it has to tell two databases'
|
|
||||||
identically-numbered chunks apart."""
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
|
||||||
await _seed(config, "alpha", ["alpha one about cats"])
|
|
||||||
await _seed(config, "beta", ["beta one about cats"])
|
|
||||||
fused = [
|
|
||||||
SearchResult(content="a0", score=0.5, chunk_id="a0", source="alpha"),
|
|
||||||
SearchResult(content="beta", score=0.5, chunk_id="shared", source="beta"),
|
|
||||||
SearchResult(content="alpha", score=0.5, chunk_id="shared", source="alpha"),
|
|
||||||
]
|
|
||||||
|
|
||||||
async with HaikuRAG(config=config) as rag:
|
|
||||||
expanded = await rag.expand_context(fused)
|
|
||||||
|
|
||||||
assert [(r.source, r.chunk_id) for r in expanded] == [
|
|
||||||
(r.source, r.chunk_id) for r in fused
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_a_shared_id_cannot_be_cited(self):
|
|
||||||
"""A citation records the id alone, so resolving one held by two
|
|
||||||
databases would attribute the answer to whichever came last."""
|
|
||||||
results = [
|
|
||||||
SearchResult(
|
|
||||||
content="alpha body",
|
|
||||||
score=0.9,
|
|
||||||
source="alpha",
|
|
||||||
chunk_id="c1",
|
|
||||||
document_id="d1",
|
|
||||||
document_uri="test://alpha/one",
|
|
||||||
),
|
|
||||||
SearchResult(
|
|
||||||
content="beta body",
|
|
||||||
score=0.8,
|
|
||||||
source="beta",
|
|
||||||
chunk_id="c1",
|
|
||||||
document_id="d1",
|
|
||||||
document_uri="test://beta/one",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
with pytest.raises(AmbiguousCitationError, match="c1"):
|
|
||||||
resolve_citations(["c1"], results)
|
|
||||||
|
|
||||||
def test_a_repeated_id_from_one_database_still_collapses(self):
|
|
||||||
"""One database cannot hold two chunks under one id, so seeing it twice
|
|
||||||
is the same chunk seen twice."""
|
|
||||||
results = [
|
|
||||||
SearchResult(
|
|
||||||
content="first",
|
|
||||||
score=0.9,
|
|
||||||
source="alpha",
|
|
||||||
chunk_id="c1",
|
|
||||||
document_id="d1",
|
|
||||||
document_uri="test://alpha/one",
|
|
||||||
),
|
|
||||||
SearchResult(
|
|
||||||
content="second",
|
|
||||||
score=0.8,
|
|
||||||
source="alpha",
|
|
||||||
chunk_id="c1",
|
|
||||||
document_id="d1",
|
|
||||||
document_uri="test://alpha/one",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
[citation] = resolve_citations(["c1"], results)
|
|
||||||
|
|
||||||
assert citation.content == "first"
|
|
||||||
|
|
||||||
def test_only_a_cited_id_has_to_be_unambiguous(self):
|
|
||||||
"""An id the answer never cites attributes nothing."""
|
|
||||||
shared = [
|
|
||||||
SearchResult(
|
|
||||||
content=f"{name} body",
|
|
||||||
score=0.9,
|
|
||||||
source=name,
|
|
||||||
chunk_id="c1",
|
|
||||||
document_id="d1",
|
|
||||||
document_uri=f"test://{name}/one",
|
|
||||||
)
|
|
||||||
for name in ("alpha", "beta")
|
|
||||||
]
|
|
||||||
own = SearchResult(
|
|
||||||
content="alpha only",
|
|
||||||
score=0.7,
|
|
||||||
source="alpha",
|
|
||||||
chunk_id="c2",
|
|
||||||
document_id="d2",
|
|
||||||
document_uri="test://alpha/two",
|
|
||||||
)
|
|
||||||
|
|
||||||
[citation] = resolve_citations(["c2"], [*shared, own])
|
|
||||||
|
|
||||||
assert citation.source == "alpha"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_an_unsearched_shared_id_is_refused_by_the_fallback(self, tmp_path):
|
|
||||||
"""The direct lookup is the only place a collision shows for an id no
|
|
||||||
search returned, so it has to ask every database rather than take the
|
|
||||||
first that answers."""
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
|
||||||
await _seed(
|
|
||||||
config, "alpha", ["alpha document about cats", "alpha on aardvarks"]
|
|
||||||
)
|
|
||||||
shutil.copytree(tmp_path / "alpha.lancedb", tmp_path / "beta.lancedb")
|
|
||||||
|
|
||||||
async with HaikuRAG(config=config) as rag:
|
|
||||||
alpha = (await rag.clients_for(["alpha"]))[0]
|
|
||||||
chunks = await alpha.chunk_repository.list_all()
|
|
||||||
[aardvark] = [c for c in chunks if "aardvark" in c.content]
|
|
||||||
assert aardvark.id is not None
|
|
||||||
|
|
||||||
capability = create_capability(config=config, rag=rag, defer_loading=False)
|
|
||||||
capability.state = RAGState()
|
|
||||||
|
|
||||||
# No search ran, so the id can only resolve through the fallback.
|
|
||||||
with pytest.raises(ModelRetry, match="more than one database"):
|
|
||||||
await capability._cite([aardvark.id])
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_an_unsearched_id_in_one_database_still_resolves(self, tmp_path):
|
|
||||||
"""The refusal is for a collision, not for looking through several
|
|
||||||
databases: an id only one of them holds still resolves."""
|
|
||||||
from tests.capabilities.test_capabilities import Deps, make_context
|
|
||||||
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
|
||||||
await _seed(
|
|
||||||
config, "alpha", ["alpha document about cats", "alpha on aardvarks"]
|
|
||||||
)
|
|
||||||
await _seed(config, "beta", ["beta document about dogs"])
|
|
||||||
|
|
||||||
async with HaikuRAG(config=config) as rag:
|
|
||||||
alpha = (await rag.clients_for(["alpha"]))[0]
|
|
||||||
chunks = await alpha.chunk_repository.list_all()
|
|
||||||
[aardvark] = [c for c in chunks if "aardvark" in c.content]
|
|
||||||
assert aardvark.id is not None
|
|
||||||
|
|
||||||
capability = create_capability(config=config, rag=rag, defer_loading=False)
|
|
||||||
run = await capability.for_run(make_context(Deps()))
|
|
||||||
|
|
||||||
await run._cite([aardvark.id])
|
|
||||||
|
|
||||||
assert run.state is not None
|
|
||||||
[citation] = list(run.state.citation_index.values())
|
|
||||||
assert citation.source == "alpha"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cite_asks_for_other_evidence(self, tmp_path):
|
|
||||||
capability = create_capability(
|
|
||||||
config=_config(tmp_path, ["alpha", "beta"]), defer_loading=False
|
|
||||||
)
|
|
||||||
capability.state = RAGState(
|
|
||||||
searches={
|
|
||||||
"cats": [
|
|
||||||
SearchResult(
|
|
||||||
content=f"{name} body",
|
|
||||||
score=0.9,
|
|
||||||
source=name,
|
|
||||||
chunk_id="c1",
|
|
||||||
document_id="d1",
|
|
||||||
document_uri=f"test://{name}/one",
|
|
||||||
)
|
|
||||||
for name in ("alpha", "beta")
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ModelRetry, match="appears once"):
|
|
||||||
await capability._cite(["c1"])
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cite_refuses_an_id_already_cited_from_another_database(
|
|
||||||
self, tmp_path
|
|
||||||
):
|
|
||||||
"""The citation index outlives the question, so the collision can arrive
|
|
||||||
a turn later than the search that would have shown it."""
|
|
||||||
capability = create_capability(
|
|
||||||
config=_config(tmp_path, ["alpha", "beta"]), defer_loading=False
|
|
||||||
)
|
|
||||||
capability.state = RAGState(
|
|
||||||
citation_index={
|
|
||||||
"c1": Citation(
|
|
||||||
document_id="d1",
|
|
||||||
source="alpha",
|
|
||||||
chunk_id="c1",
|
|
||||||
document_uri="test://alpha/one",
|
|
||||||
content="alpha body",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
searches={
|
|
||||||
"cats": [
|
|
||||||
SearchResult(
|
|
||||||
content="beta body",
|
|
||||||
score=0.9,
|
|
||||||
source="beta",
|
|
||||||
chunk_id="c1",
|
|
||||||
document_id="d1",
|
|
||||||
document_uri="test://beta/one",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ModelRetry, match="another database"):
|
|
||||||
await capability._cite(["c1"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestCitationSource:
|
|
||||||
def test_a_citation_carries_the_result_source(self):
|
|
||||||
result = SearchResult(
|
|
||||||
content="body",
|
|
||||||
score=0.9,
|
|
||||||
source="alpha",
|
|
||||||
chunk_id="c1",
|
|
||||||
document_id="d1",
|
|
||||||
document_uri="test://alpha/one",
|
|
||||||
)
|
|
||||||
|
|
||||||
[citation] = resolve_citations(["c1"], [result])
|
|
||||||
|
|
||||||
assert citation.source == "alpha"
|
|
||||||
|
|
||||||
def test_a_result_without_an_id_is_skipped(self):
|
|
||||||
"""A result built by hand carries no chunk id, so nothing can cite it
|
|
||||||
and it takes part in no collision."""
|
|
||||||
handmade = SearchResult(content="loose text", score=0.5)
|
|
||||||
real = SearchResult(
|
|
||||||
content="body",
|
|
||||||
score=0.9,
|
|
||||||
source="alpha",
|
|
||||||
chunk_id="c1",
|
|
||||||
document_id="d1",
|
|
||||||
document_uri="test://alpha/one",
|
|
||||||
)
|
|
||||||
|
|
||||||
[citation] = resolve_citations(["c1"], [handmade, real])
|
|
||||||
|
|
||||||
assert citation.chunk_id == "c1"
|
|
||||||
|
|
||||||
def test_a_single_database_citation_has_no_source(self):
|
|
||||||
result = SearchResult(
|
|
||||||
content="body",
|
|
||||||
score=0.9,
|
|
||||||
chunk_id="c1",
|
|
||||||
document_id="d1",
|
|
||||||
document_uri="test://one",
|
|
||||||
)
|
|
||||||
|
|
||||||
[citation] = resolve_citations(["c1"], [result])
|
|
||||||
|
|
||||||
assert citation.source is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestAskAcrossDatabases:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.vcr()
|
|
||||||
async def test_the_capability_searches_the_selected_databases(self, tmp_path):
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
|
||||||
await _seed(config, "alpha", ["alpha document about cats"])
|
|
||||||
await _seed(config, "beta", ["beta document about cats"])
|
|
||||||
|
|
||||||
async with HaikuRAG(config=config) as rag:
|
|
||||||
capability = create_capability(config=config, rag=rag, defer_loading=False)
|
|
||||||
capability.state = RAGState(sources=["alpha"])
|
|
||||||
|
|
||||||
formatted = await capability._search("cats", limit=10)
|
|
||||||
|
|
||||||
assert isinstance(formatted, str)
|
|
||||||
assert "alpha" in formatted
|
|
||||||
assert "beta document" not in formatted
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.vcr()
|
|
||||||
async def test_searching_all_databases_reaches_both(self, tmp_path):
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
|
||||||
await _seed(config, "alpha", ["alpha document about cats"])
|
|
||||||
await _seed(config, "beta", ["beta document about cats"])
|
|
||||||
|
|
||||||
async with HaikuRAG(config=config) as rag:
|
|
||||||
capability = create_capability(config=config, rag=rag, defer_loading=False)
|
|
||||||
capability.state = RAGState()
|
|
||||||
|
|
||||||
formatted = await capability._search("cats", limit=10)
|
|
||||||
|
|
||||||
assert isinstance(formatted, str)
|
|
||||||
assert "alpha document" in formatted
|
|
||||||
assert "beta document" in formatted
|
|
||||||
|
|
||||||
|
|
||||||
class TestCiteFallback:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.vcr()
|
|
||||||
async def test_an_id_from_a_selected_database_resolves_with_its_source(
|
|
||||||
self, tmp_path
|
|
||||||
):
|
|
||||||
"""The fallback exists for a real id this run's searches did not return.
|
|
||||||
Across databases it looks through the selected ones and records which
|
|
||||||
held it."""
|
|
||||||
from tests.capabilities.test_capabilities import Deps, make_context
|
|
||||||
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
|
||||||
await _seed(
|
|
||||||
config, "alpha", ["alpha document about cats", "alpha on aardvarks"]
|
|
||||||
)
|
|
||||||
await _seed(config, "beta", ["beta document about cats"])
|
|
||||||
|
|
||||||
async with HaikuRAG(config=config) as rag:
|
|
||||||
alpha = (await rag.clients_for(["alpha"]))[0]
|
|
||||||
chunks = await alpha.chunk_repository.list_all()
|
|
||||||
[aardvark] = [c for c in chunks if "aardvark" in c.content]
|
|
||||||
assert aardvark.id is not None
|
|
||||||
|
|
||||||
capability = create_capability(config=config, rag=rag, defer_loading=False)
|
|
||||||
deps = Deps(
|
|
||||||
state={"rag": RAGState(sources=["alpha"]).model_dump(mode="json")}
|
|
||||||
)
|
|
||||||
run = await capability.for_run(make_context(deps))
|
|
||||||
# The search returns the cats chunk, never the aardvark one.
|
|
||||||
await run._search("cats", limit=10)
|
|
||||||
|
|
||||||
await run._cite([aardvark.id])
|
|
||||||
|
|
||||||
assert run.state is not None
|
|
||||||
[citation] = list(run.state.citation_index.values())
|
|
||||||
assert citation.chunk_id == aardvark.id
|
|
||||||
assert citation.source == "alpha"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.vcr()
|
|
||||||
async def test_an_id_outside_the_selected_databases_does_not_resolve(
|
|
||||||
self, tmp_path
|
|
||||||
):
|
|
||||||
"""A question scoped to one database must not produce a citation from
|
|
||||||
another: the fallback looks only where the question looked."""
|
|
||||||
from tests.capabilities.test_capabilities import Deps, make_context
|
|
||||||
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
|
||||||
await _seed(config, "alpha", ["alpha document about cats"])
|
|
||||||
await _seed(config, "beta", ["beta document about dogs"])
|
|
||||||
|
|
||||||
async with HaikuRAG(config=config) as rag:
|
|
||||||
beta = (await rag.clients_for(["beta"]))[0]
|
|
||||||
[outside] = await beta.chunk_repository.list_all(limit=1)
|
|
||||||
assert outside.id is not None
|
|
||||||
|
|
||||||
capability = create_capability(config=config, rag=rag, defer_loading=False)
|
|
||||||
deps = Deps(
|
|
||||||
state={"rag": RAGState(sources=["alpha"]).model_dump(mode="json")}
|
|
||||||
)
|
|
||||||
run = await capability.for_run(make_context(deps))
|
|
||||||
await run._search("cats", limit=10)
|
|
||||||
|
|
||||||
with pytest.raises(ModelRetry):
|
|
||||||
await run._cite([outside.id])
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_selecting_no_databases_cites_nothing(self, tmp_path):
|
|
||||||
"""`sources=[]` selected nothing, which is not the same as everything:
|
|
||||||
the fallback must not go looking where the question never looked."""
|
|
||||||
from tests.capabilities.test_capabilities import Deps, make_context
|
|
||||||
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
|
||||||
await _seed(config, "alpha", ["alpha document about cats"])
|
|
||||||
await _seed(config, "beta", ["beta document about cats"])
|
|
||||||
|
|
||||||
async with HaikuRAG(config=config) as rag:
|
|
||||||
alpha = (await rag.clients_for(["alpha"]))[0]
|
|
||||||
[chunk] = await alpha.chunk_repository.list_all(limit=1)
|
|
||||||
assert chunk.id is not None
|
|
||||||
|
|
||||||
capability = create_capability(config=config, rag=rag, defer_loading=False)
|
|
||||||
deps = Deps(state={"rag": RAGState(sources=[]).model_dump(mode="json")})
|
|
||||||
run = await capability.for_run(make_context(deps))
|
|
||||||
|
|
||||||
with pytest.raises(ModelRetry):
|
|
||||||
await run._cite([chunk.id])
|
|
||||||
|
|
||||||
|
|
||||||
class TestStandaloneCapabilities:
|
|
||||||
"""A capability nobody hands a client opens its own. It has to reach the
|
|
||||||
configured set, or a host that only registers capabilities gets one
|
|
||||||
database while the configuration names several."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.vcr()
|
|
||||||
async def test_a_rag_capability_opens_the_configured_set(self, tmp_path):
|
|
||||||
from tests.capabilities.test_capabilities import Deps, make_context
|
|
||||||
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
|
||||||
await _seed(config, "alpha", ["alpha document about cats"])
|
|
||||||
await _seed(config, "beta", ["beta document about cats"])
|
|
||||||
|
|
||||||
capability = create_capability(config=config, defer_loading=False)
|
|
||||||
assert capability.scope.names == ("alpha", "beta")
|
|
||||||
run = await capability.for_run(make_context(Deps()))
|
|
||||||
try:
|
|
||||||
formatted = await run._search("cats", limit=10)
|
|
||||||
finally:
|
|
||||||
await run._close()
|
|
||||||
|
|
||||||
assert isinstance(formatted, str)
|
|
||||||
assert "alpha document" in formatted
|
|
||||||
assert "beta document" in formatted
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.vcr()
|
|
||||||
async def test_an_analysis_capability_mounts_the_configured_set(self, tmp_path):
|
|
||||||
from haiku.rag.capabilities.analysis import (
|
|
||||||
create_capability as create_analysis,
|
|
||||||
)
|
|
||||||
from tests.capabilities.test_capabilities import Deps, make_context
|
|
||||||
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
|
||||||
await _seed(config, "alpha", ["alpha document about cats"])
|
|
||||||
await _seed(config, "beta", ["beta document about cats"])
|
|
||||||
|
|
||||||
capability = create_analysis(config=config, defer_loading=False)
|
|
||||||
run = await capability.for_run(make_context(Deps()))
|
|
||||||
try:
|
|
||||||
sandbox = await run._ensure_sandbox()
|
|
||||||
docs, owners = await sandbox._documents()
|
|
||||||
finally:
|
|
||||||
await run._close()
|
|
||||||
|
|
||||||
assert len(docs) == 2
|
|
||||||
assert {owner.source for owner in owners.values()} == {"alpha", "beta"}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_a_single_configured_database_is_still_opened(self, tmp_path):
|
|
||||||
"""One named database is a set of one, not a path to guess."""
|
|
||||||
config = _config(tmp_path, ["alpha"])
|
|
||||||
await _seed(config, "alpha", ["alpha document about cats"])
|
|
||||||
|
|
||||||
capability = create_capability(config=config, defer_loading=False)
|
|
||||||
rag = await capability._ensure_rag()
|
|
||||||
try:
|
|
||||||
assert rag.source == "alpha"
|
|
||||||
finally:
|
|
||||||
await capability._close()
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnalyzeAcrossDatabases:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.vcr()
|
|
||||||
async def test_the_capability_searches_the_selected_databases(self, tmp_path):
|
|
||||||
"""`analysis_search` is the same tool as the RAG one, and the sandbox is
|
|
||||||
scoped by the same selection."""
|
|
||||||
from haiku.rag.capabilities.analysis import AnalysisState
|
|
||||||
from haiku.rag.capabilities.analysis import (
|
|
||||||
create_capability as create_analysis,
|
|
||||||
)
|
|
||||||
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
|
||||||
await _seed(config, "alpha", ["alpha document about cats"])
|
|
||||||
await _seed(config, "beta", ["beta document about cats"])
|
|
||||||
|
|
||||||
async with HaikuRAG(config=config) as rag:
|
|
||||||
capability = create_analysis(config=config, rag=rag, defer_loading=False)
|
|
||||||
capability.state = AnalysisState(sources=["alpha"])
|
|
||||||
|
|
||||||
formatted = await capability._search("cats", limit=10)
|
|
||||||
sandbox = await capability._ensure_sandbox()
|
|
||||||
await capability._close()
|
|
||||||
|
|
||||||
assert isinstance(formatted, str)
|
|
||||||
assert "alpha document" in formatted
|
|
||||||
assert "beta document" not in formatted
|
|
||||||
assert sandbox._context.sources == ["alpha"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestDatabaseIdentityForTheModel:
|
|
||||||
def test_a_result_names_its_database(self):
|
|
||||||
"""The model has to attribute and compare evidence by database while it
|
|
||||||
composes the answer, not only afterwards through the citations."""
|
|
||||||
result = SearchResult(content="body", score=0.9, source="alpha", chunk_id="c1")
|
|
||||||
|
|
||||||
assert "Database: alpha" in result.format_for_agent()
|
|
||||||
|
|
||||||
def test_an_unnamed_database_is_not_mentioned(self):
|
|
||||||
"""A single unnamed database renders as it always has."""
|
|
||||||
result = SearchResult(content="body", score=0.9, chunk_id="c1")
|
|
||||||
|
|
||||||
assert "Database" not in result.format_for_agent()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.vcr()
|
|
||||||
async def test_in_code_search_names_the_database(self, tmp_path):
|
|
||||||
from haiku.rag.sandbox import AnalysisContext, Sandbox
|
|
||||||
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
|
||||||
await _seed(config, "alpha", ["alpha document about cats"])
|
|
||||||
await _seed(config, "beta", ["beta document about cats"])
|
|
||||||
|
|
||||||
async with HaikuRAG(config=config) as rag:
|
|
||||||
sandbox = Sandbox(
|
|
||||||
db_path=None,
|
|
||||||
config=config,
|
|
||||||
context=AnalysisContext(),
|
|
||||||
rag=rag,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
result = await sandbox.execute(
|
|
||||||
"rows = await search('cats', limit=10)\n"
|
|
||||||
"print(sorted(r['source'] for r in rows))\n"
|
|
||||||
"docs = await list_documents()\n"
|
|
||||||
"print(sorted(d['source'] for d in docs))"
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await sandbox.close()
|
|
||||||
|
|
||||||
assert result.success, result.stderr
|
|
||||||
assert "['alpha', 'beta']" in result.stdout
|
|
||||||
assert result.stdout.count("['alpha', 'beta']") == 2
|
|
||||||
|
|
||||||
|
|
||||||
class TestActionableFailures:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_a_migration_error_survives_being_named(self, tmp_path, temp_db_path):
|
|
||||||
"""The remedy is the whole value of the message, and it names no location,
|
|
||||||
so it is not replaced by the database's name."""
|
|
||||||
from haiku.rag.store.exceptions import MigrationRequiredError
|
|
||||||
|
|
||||||
config = _config(tmp_path, ["alpha"])
|
|
||||||
await _seed(config, "alpha", ["alpha document about cats"])
|
|
||||||
|
|
||||||
async with HaikuRAG(config=config, sources=["alpha"]) as rag:
|
|
||||||
await rag.store.set_haiku_version("0.20.0")
|
|
||||||
|
|
||||||
with pytest.raises(MigrationRequiredError) as raised:
|
|
||||||
async with HaikuRAG(config=config, sources=["alpha"]):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Both halves: which database failed, and what to run about it.
|
|
||||||
assert "haiku-rag migrate" in str(raised.value)
|
|
||||||
assert "alpha" in str(raised.value)
|
|
||||||
assert str(tmp_path) not in str(raised.value)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPictureDeduplication:
|
|
||||||
"""One picture yields two chunks — a text-embedded one and an image-embedded
|
|
||||||
one — that collapse to the best. Two databases holding the same picture are
|
|
||||||
two results, not a duplicate."""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _picture(source, score):
|
|
||||||
return SearchResult(
|
|
||||||
content="a figure",
|
|
||||||
score=score,
|
|
||||||
source=source,
|
|
||||||
chunk_id=f"{source}-c",
|
|
||||||
document_id="doc-1",
|
|
||||||
doc_item_refs=["#/pictures/0"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_the_same_picture_in_two_databases_survives(self):
|
|
||||||
from haiku.rag.client.search import _dedup_picture_chunks
|
|
||||||
|
|
||||||
kept = _dedup_picture_chunks(
|
|
||||||
[self._picture("alpha", 0.9), self._picture("clone", 0.5)]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert [r.source for r in kept] == ["alpha", "clone"]
|
|
||||||
|
|
||||||
def test_duplicates_within_one_database_still_collapse(self):
|
|
||||||
from haiku.rag.client.search import _dedup_picture_chunks
|
|
||||||
|
|
||||||
lower = self._picture("alpha", 0.5)
|
|
||||||
higher = self._picture("alpha", 0.9)
|
|
||||||
|
|
||||||
kept = _dedup_picture_chunks([lower, higher])
|
|
||||||
|
|
||||||
assert kept == [higher]
|
|
||||||
|
|
||||||
|
|
||||||
class TestPictureRouting:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_a_picture_is_fetched_from_the_database_that_holds_it(self, tmp_path):
|
|
||||||
"""A `self_ref` repeats across databases, so the citation's source is
|
|
||||||
what decides where the bytes come from."""
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
|
||||||
await _seed(config, "alpha", ["alpha document about cats"])
|
|
||||||
await _seed(config, "beta", ["beta document about cats"])
|
|
||||||
|
|
||||||
async with HaikuRAG(config=config) as rag:
|
|
||||||
beta = (await rag.clients_for(["beta"]))[0]
|
|
||||||
[document] = await beta.document_repository.list_all(limit=1)
|
|
||||||
assert document.id is not None
|
|
||||||
await beta.document_item_repository.create_all(
|
|
||||||
[
|
|
||||||
DocumentItem(
|
|
||||||
document_id=document.id,
|
|
||||||
self_ref="#/pictures/0",
|
|
||||||
position=99,
|
|
||||||
label="picture",
|
|
||||||
text="",
|
|
||||||
picture_data=b"beta-picture",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert (
|
|
||||||
await rag.get_picture_bytes(document.id, "#/pictures/0", "beta")
|
|
||||||
== b"beta-picture"
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
await rag.get_picture_bytes(document.id, "#/pictures/0", "alpha")
|
|
||||||
is None
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_a_single_database_needs_no_source(self, temp_db_path):
|
|
||||||
"""One database is where the picture is, named or not."""
|
|
||||||
async with HaikuRAG(temp_db_path, create=True) as rag:
|
|
||||||
document = await rag.document_repository.create(
|
|
||||||
Document(content="body", uri="test://one")
|
|
||||||
)
|
|
||||||
assert document.id is not None
|
|
||||||
await rag.document_item_repository.create_all(
|
|
||||||
[
|
|
||||||
DocumentItem(
|
|
||||||
document_id=document.id,
|
|
||||||
self_ref="#/pictures/0",
|
|
||||||
position=0,
|
|
||||||
label="picture",
|
|
||||||
text="",
|
|
||||||
picture_data=b"the-picture",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert (
|
|
||||||
await rag.get_picture_bytes(document.id, "#/pictures/0")
|
|
||||||
== b"the-picture"
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_a_picture_lookup_without_a_source_is_refused(self, tmp_path):
|
|
||||||
"""Federating, nothing can say which database holds an unqualified
|
|
||||||
reference."""
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
|
||||||
await _seed(config, "alpha", ["alpha document about cats"])
|
|
||||||
|
|
||||||
async with HaikuRAG(config=config) as rag:
|
|
||||||
with pytest.raises(ValueError, match="source"):
|
|
||||||
await rag.get_picture_bytes("doc-1", "#/pictures/0")
|
|
||||||
|
|
||||||
|
|
||||||
class TestFederatedEdges:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_expansion_passes_through_results_without_a_source(self, tmp_path):
|
|
||||||
"""A caller can hand `expand_context` results it built itself. Those name
|
|
||||||
no database, so there is nowhere to expand them from."""
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
|
||||||
await _seed(config, "alpha", ["alpha document about cats"])
|
|
||||||
await _seed(config, "beta", ["beta document about cats"])
|
|
||||||
|
|
||||||
handmade = SearchResult(content="handmade", score=0.4, doc_item_refs=[])
|
|
||||||
|
|
||||||
async with HaikuRAG(config=config) as rag:
|
|
||||||
found = await rag.search("cats", search_type="fts", limit=10)
|
|
||||||
expanded = await rag.expand_context([*found, handmade])
|
|
||||||
|
|
||||||
assert "handmade" in [r.content for r in expanded]
|
|
||||||
scores = [r.score for r in expanded]
|
|
||||||
assert scores == sorted(scores, reverse=True), "merged in score order"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_a_chunk_without_a_document_is_not_cited(self, tmp_path):
|
|
||||||
"""`Chunk.document_id` is optional, and a citation without a document has
|
|
||||||
nothing to point at."""
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
from haiku.rag.capabilities.rag import RAGCapability
|
|
||||||
from haiku.rag.store.models import Chunk
|
|
||||||
from tests.capabilities.test_capabilities import (
|
|
||||||
Deps,
|
|
||||||
_single_database_client,
|
|
||||||
make_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
|
||||||
await _seed(config, "alpha", ["alpha document about cats"])
|
|
||||||
|
|
||||||
orphan = _single_database_client()
|
|
||||||
orphan.get_chunk_by_id.return_value = Chunk(
|
|
||||||
id="orphan", document_id=None, content="no document"
|
|
||||||
)
|
|
||||||
|
|
||||||
capability = create_capability(config=config, defer_loading=False)
|
|
||||||
run = await capability.for_run(make_context(Deps()))
|
|
||||||
with patch.object(RAGCapability, "_ensure_rag", AsyncMock(return_value=orphan)):
|
|
||||||
with pytest.raises(ModelRetry):
|
|
||||||
await run._cite(["orphan"])
|
|
||||||
Loading…
Reference in a new issue