feat(evidence): use server-side Qdrant BM25 retrieval
This commit is contained in:
@@ -277,7 +277,7 @@ def test_active_searcher_without_active_fails_closed_for_evidence(tmp_path):
|
||||
from tht.evidence.search import active_searcher
|
||||
|
||||
class Delegate:
|
||||
def search(self, embedding, top_n=10, kinds=None, metadata_filter=None):
|
||||
def search(self, embedding, top_n=10, kinds=None, metadata_filter=None, **kwargs):
|
||||
return ["legacy"]
|
||||
|
||||
cfg = SimpleNamespace(paths=SimpleNamespace(artifacts=tmp_path / "artifacts"))
|
||||
@@ -332,14 +332,14 @@ def test_active_evidence_query_holds_lock_against_publish(tmp_path):
|
||||
published = threading.Event()
|
||||
|
||||
class Delegate:
|
||||
def search(self, embedding, top_n=10, kinds=None, metadata_filter=None):
|
||||
def search(self, embedding, top_n=10, kinds=None, metadata_filter=None, **kwargs):
|
||||
entered.set()
|
||||
assert release.wait(5)
|
||||
return [SimpleNamespace(id="active", similarity=1.0)]
|
||||
|
||||
search = threading.Thread(
|
||||
target=lambda: ActiveEvidenceSearcher(store, Delegate()).search(
|
||||
[1.0], kinds=["evidence"]
|
||||
[1.0], kinds=["evidence"], query_text="old"
|
||||
)
|
||||
)
|
||||
search.start()
|
||||
@@ -360,6 +360,17 @@ def test_active_evidence_query_holds_lock_against_publish(tmp_path):
|
||||
assert published.is_set()
|
||||
|
||||
|
||||
def test_active_evidence_search_refuses_a_dense_only_fallback(tmp_path):
|
||||
from tht.evidence.search import ActiveEvidenceSearcher
|
||||
from tht.ports.vector import VectorStoreError
|
||||
|
||||
current = pipeline(tmp_path, Source([(item("one", "a"), "cardiomiopatia")]), vectors=Vectors())
|
||||
current.run()
|
||||
|
||||
with pytest.raises(VectorStoreError, match="hybrid query text"):
|
||||
ActiveEvidenceSearcher(current.store, object()).search([1.0], kinds=["evidence"])
|
||||
|
||||
|
||||
def test_pipeline_result_dump_does_not_deepcopy_frozen_metadata():
|
||||
manifest = CorpusManifest(metadata={"nested": {"value": ["safe"]}})
|
||||
payload = PipelineResult(
|
||||
@@ -716,6 +727,16 @@ def test_dimension_mismatch_fails_before_vector_write_and_publish(tmp_path):
|
||||
assert candidate.store.active_generation() is None
|
||||
|
||||
|
||||
def test_pipeline_marks_each_evidence_fragment_for_server_side_italian_bm25(tmp_path):
|
||||
vectors = Vectors()
|
||||
|
||||
pipeline(tmp_path, Source([(item("one", "a"), "ricovero cardiologico")]), vectors=vectors).run()
|
||||
|
||||
assert [(record.sparse_text, record.sparse_language) for record in vectors.records] == [
|
||||
("ricovero cardiologico", "italian"),
|
||||
]
|
||||
|
||||
|
||||
def test_dry_run_and_failed_acquire_never_change_active(tmp_path):
|
||||
one = item("one", "a")
|
||||
active = pipeline(tmp_path, Source([(one, "old")])).run().generation
|
||||
|
||||
@@ -133,6 +133,7 @@ def test_preprocessing_factory_forwards_only_evidence_pipeline_dependencies(monk
|
||||
"pipeline_version": "evidence-v1",
|
||||
"retain_published_generations": 2,
|
||||
"workspace_id": None,
|
||||
"sparse_language": "italian",
|
||||
}
|
||||
|
||||
pipeline = build_preprocessing_pipeline(**dependencies)
|
||||
|
||||
@@ -236,11 +236,13 @@ def test_run_from_config_uses_runtime_identity_workspace_id(monkeypatch, tmp_pat
|
||||
chunk_policy,
|
||||
pipeline_version,
|
||||
retain_published_generations,
|
||||
sparse_language,
|
||||
):
|
||||
calls["init"] = {
|
||||
"embedding_model": embedding_model,
|
||||
"embedding_dimensions": embedding_dimensions,
|
||||
"pipeline_version": pipeline_version,
|
||||
"sparse_language": sparse_language,
|
||||
}
|
||||
|
||||
def run_as_job(self, **kwargs):
|
||||
@@ -257,6 +259,7 @@ def test_run_from_config_uses_runtime_identity_workspace_id(monkeypatch, tmp_pat
|
||||
|
||||
command.run_from_config(config)
|
||||
|
||||
assert calls["init"]["sparse_language"] == "english"
|
||||
assert calls["run_as_job"]["workspace_id"] == "psd-clinical"
|
||||
assert calls["run_as_job"]["input_fingerprint"] != calls["run_as_job"]["config_fingerprint"]
|
||||
|
||||
|
||||
@@ -86,7 +86,8 @@ class FakeQdrantHttp:
|
||||
if method == "POST" and path == "/collections/workspace-semantic/points/query":
|
||||
if self.malformed_query:
|
||||
return FakeResponse(200, {"result": {"points": "nope"}})
|
||||
wanted = _match_points(self.points.values(), json["filter"])
|
||||
filter_value = json["filter"] if "filter" in json else json["prefetch"][0]["filter"]
|
||||
wanted = _match_points(self.points.values(), filter_value)
|
||||
scored = sorted(
|
||||
(
|
||||
{
|
||||
@@ -150,7 +151,7 @@ def _match_clause(payload, clause):
|
||||
raise AssertionError(clause)
|
||||
|
||||
|
||||
def _write_record(record_id: str, kind: str, *, metadata=None):
|
||||
def _write_record(record_id: str, kind: str, *, metadata=None, sparse_text=None, sparse_language=None):
|
||||
return VectorWriteRecord(
|
||||
record=VectorRecord(
|
||||
id=record_id,
|
||||
@@ -162,6 +163,8 @@ def _write_record(record_id: str, kind: str, *, metadata=None):
|
||||
),
|
||||
embedding=[0.1] * 1024,
|
||||
content_hash="sha256:" + "a" * 64,
|
||||
sparse_text=sparse_text,
|
||||
sparse_language=sparse_language,
|
||||
)
|
||||
|
||||
|
||||
@@ -358,6 +361,89 @@ def test_upsert_serializes_qdrant_point_payloads(record, semantic_kind):
|
||||
assert point["payload"]["content_hash"] == record.content_hash
|
||||
|
||||
|
||||
def test_evidence_upsert_sends_dense_and_server_side_italian_bm25():
|
||||
fake = FakeQdrantHttp()
|
||||
store = _store(fake)
|
||||
record = _write_record(
|
||||
"demo:gen:11111111111111111111111111111111:chunk:1",
|
||||
"evidence",
|
||||
metadata={
|
||||
"workspace_id": "demo",
|
||||
"vector_generation": "gen:11111111111111111111111111111111",
|
||||
"document_id": "doc:abc",
|
||||
},
|
||||
sparse_text="ricovero per cardiomiopatia dilatativa",
|
||||
sparse_language="italian",
|
||||
)
|
||||
|
||||
store.upsert("evidence", [record])
|
||||
|
||||
point = next(iter(fake.points.values()))
|
||||
assert point["vector"] == {
|
||||
"": record.embedding,
|
||||
"bm25": {
|
||||
"text": "ricovero per cardiomiopatia dilatativa",
|
||||
"model": "qdrant/bm25",
|
||||
"options": {"language": "italian"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_evidence_search_uses_filtered_dense_and_bm25_prefetches_with_default_rrf():
|
||||
fake = FakeQdrantHttp()
|
||||
store = _store(fake)
|
||||
generation = "gen:" + "1" * 32
|
||||
store.upsert("evidence", [
|
||||
_write_record(
|
||||
f"demo:{generation}:chunk:1",
|
||||
"evidence",
|
||||
metadata={"workspace_id": "demo", "vector_generation": generation, "document_id": "doc:abc"},
|
||||
sparse_text="ricovero per cardiomiopatia dilatativa",
|
||||
sparse_language="italian",
|
||||
)
|
||||
])
|
||||
|
||||
store.search(
|
||||
["evidence"], [0.2] * 1024, limit=10, kinds=["evidence"],
|
||||
query_text="cardiomiopatia", query_language="italian",
|
||||
metadata_filter={"workspace_id": "demo", "vector_generation": generation, "document_ids": ["doc:abc"]},
|
||||
)
|
||||
|
||||
query = next(call[2] for call in reversed(fake.calls) if call[1].endswith("/points/query"))
|
||||
assert query["query"] == {"rrf": {}}
|
||||
assert query["limit"] == 10
|
||||
assert query["prefetch"] == [
|
||||
{
|
||||
"query": [0.2] * 1024,
|
||||
"limit": 20,
|
||||
"filter": {"must": [
|
||||
{"key": "workspace_id", "match": {"value": "demo"}},
|
||||
{"key": "workspace_revision", "match": {"value": "a" * 40}},
|
||||
{"key": "kind", "match": {"any": ["evidence"]}},
|
||||
{"key": "record_kind", "match": {"any": ["evidence"]}},
|
||||
{"key": "vector_generation", "match": {"value": generation}},
|
||||
{"key": "document_id", "match": {"any": ["doc:abc"]}},
|
||||
]},
|
||||
},
|
||||
{
|
||||
"query": {
|
||||
"text": "cardiomiopatia", "model": "qdrant/bm25",
|
||||
"options": {"language": "italian"},
|
||||
},
|
||||
"using": "bm25",
|
||||
"limit": 20,
|
||||
"filter": {"must": [
|
||||
{"key": "workspace_id", "match": {"value": "demo"}},
|
||||
{"key": "workspace_revision", "match": {"value": "a" * 40}},
|
||||
{"key": "kind", "match": {"any": ["evidence"]}},
|
||||
{"key": "record_kind", "match": {"any": ["evidence"]}},
|
||||
{"key": "vector_generation", "match": {"value": generation}},
|
||||
{"key": "document_id", "match": {"any": ["doc:abc"]}},
|
||||
]},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_search_filters_by_workspace_and_allowed_record_kinds():
|
||||
fake = FakeQdrantHttp()
|
||||
store = _store(fake)
|
||||
@@ -384,6 +470,13 @@ def test_search_filters_by_workspace_and_allowed_record_kinds():
|
||||
}
|
||||
|
||||
|
||||
def test_evidence_search_refuses_dense_only_fallback():
|
||||
store = _store(FakeQdrantHttp())
|
||||
|
||||
with pytest.raises(VectorStoreError, match="hybrid query text"):
|
||||
store.search(["evidence"], [0.2] * 1024, limit=5, kinds=["evidence"])
|
||||
|
||||
|
||||
def test_search_excludes_inconsistent_semantic_kind_in_bound_workspace():
|
||||
fake = FakeQdrantHttp()
|
||||
store = _store(fake)
|
||||
|
||||
Reference in New Issue
Block a user