From 6176410f4290c187f80187bf7d4f61c80530b696 Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 24 Aug 2026 18:21:21 +0200 Subject: [PATCH] fix(evidence): fail closed when BM25 is unavailable --- .../tests/l0/test_qdrant_bm25_inference.py | 7 ++- harness/tests/test_qdrant_vector_store.py | 46 ++++++++++++++++++- harness/tht/adapters/vector/qdrant.py | 20 +++++++- harness/tht/evidence/corpus/pipeline.py | 1 + harness/tht/ports/vector.py | 1 + 5 files changed, 70 insertions(+), 5 deletions(-) diff --git a/harness/tests/l0/test_qdrant_bm25_inference.py b/harness/tests/l0/test_qdrant_bm25_inference.py index ab7436bc..d31397e2 100644 --- a/harness/tests/l0/test_qdrant_bm25_inference.py +++ b/harness/tests/l0/test_qdrant_bm25_inference.py @@ -3,7 +3,9 @@ from __future__ import annotations import time +from contextlib import ExitStack from pathlib import Path +from uuid import uuid4 import pytest import requests @@ -59,10 +61,10 @@ def dense_result_id(base_url: str, collection: str, query: list[float]) -> int: def test_pinned_qdrant_image_indexes_and_queries_italian_bm25_server_side(): - with DockerContainer(qdrant_image()).with_exposed_ports(6333) as qdrant: + with DockerContainer(qdrant_image()).with_exposed_ports(6333) as qdrant, ExitStack() as cleanup: base_url = f"http://{qdrant.get_container_host_ip()}:{qdrant.get_exposed_port(6333)}" wait_for_qdrant(base_url) - collection = "italian_bm25_contract" + collection = f"italian_bm25_contract_{uuid4().hex}" request_ok( "PUT", f"{base_url}/collections/{collection}", @@ -70,6 +72,7 @@ def test_pinned_qdrant_image_indexes_and_queries_italian_bm25_server_side(): "vectors": {"size": 4, "distance": "Cosine"}, }, ) + cleanup.callback(request_ok, "DELETE", f"{base_url}/collections/{collection}") legacy_points = [ {"id": 10, "vector": [1.0, 0.0, 0.0, 0.0], "payload": {"record_kind": "schema_table"}}, {"id": 11, "vector": [0.0, 1.0, 0.0, 0.0], "payload": {"record_kind": "schema_column"}}, diff --git a/harness/tests/test_qdrant_vector_store.py b/harness/tests/test_qdrant_vector_store.py index 0d61db10..ec17bf2e 100644 --- a/harness/tests/test_qdrant_vector_store.py +++ b/harness/tests/test_qdrant_vector_store.py @@ -32,6 +32,7 @@ class FakeQdrantHttp: self.dimension = dimension self.distance = distance self.collection = None + self.sparse_vectors: dict[str, dict] | None = None self.payload_indexes: set[str] = set() self.points: dict[str, dict] = {} self.calls: list[tuple[str, str, dict | None]] = [] @@ -56,7 +57,11 @@ class FakeQdrantHttp: "result": { "config": { "params": { - "vectors": {"size": self.dimension, "distance": self.distance} + "vectors": {"size": self.dimension, "distance": self.distance}, + **( + {"sparse_vectors": self.sparse_vectors} + if self.sparse_vectors is not None else {} + ), } }, "payload_schema": { @@ -69,6 +74,7 @@ class FakeQdrantHttp: self.collection = json self.dimension = json["vectors"]["size"] self.distance = json["vectors"]["distance"] + self.sparse_vectors = json.get("sparse_vectors") return FakeResponse(200, {"status": "ok"}) if method == "PUT" and path == "/collections/workspace-semantic/index": @@ -182,6 +188,15 @@ def _store( ) +def _ready_collection_with_bm25(fake: FakeQdrantHttp) -> None: + fake.collection = {"vectors": {"size": 1024, "distance": "Cosine"}} + fake.payload_indexes = { + "content_hash", "document_id", "kind", "record_key", "record_kind", + "vector_generation", "workspace_id", "workspace_revision", + } + fake.sparse_vectors = {"bm25": {"modifier": "idf"}} + + def test_point_id_is_deterministic_uuidv5(): assert point_id("demo", "memory", "memory:1") == str( uuid5(NAMESPACE_URL, "thothii:demo:memory:memory:1") @@ -324,6 +339,20 @@ def test_health_fails_when_required_payload_indexes_are_missing_without_creating assert fake.payload_indexes == set() +def test_health_reports_when_evidence_bm25_is_unavailable_without_disabling_dense_callers(): + fake = FakeQdrantHttp() + fake.collection = {"vectors": {"size": 1024, "distance": "Cosine"}} + fake.payload_indexes = { + "content_hash", "document_id", "kind", "record_key", "record_kind", + "vector_generation", "workspace_id", "workspace_revision", + } + + health = _store(fake).health() + + assert health.ok is True + assert health.bm25_compatible is False + + @pytest.mark.parametrize( ("record", "semantic_kind"), [ @@ -363,6 +392,7 @@ def test_upsert_serializes_qdrant_point_payloads(record, semantic_kind): def test_evidence_upsert_sends_dense_and_server_side_italian_bm25(): fake = FakeQdrantHttp() + _ready_collection_with_bm25(fake) store = _store(fake) record = _write_record( "demo:gen:11111111111111111111111111111111:chunk:1", @@ -391,6 +421,7 @@ def test_evidence_upsert_sends_dense_and_server_side_italian_bm25(): def test_evidence_search_uses_filtered_dense_and_bm25_prefetches_with_default_rrf(): fake = FakeQdrantHttp() + _ready_collection_with_bm25(fake) store = _store(fake) generation = "gen:" + "1" * 32 store.upsert("evidence", [ @@ -477,6 +508,19 @@ def test_evidence_search_refuses_dense_only_fallback(): store.search(["evidence"], [0.2] * 1024, limit=5, kinds=["evidence"]) +def test_hybrid_evidence_search_refuses_a_collection_without_bm25(): + fake = FakeQdrantHttp() + _ready_collection_with_bm25(fake) + fake.sparse_vectors = None + store = _store(fake) + + with pytest.raises(VectorStoreError, match="BM25 collection configuration"): + store.search( + ["evidence"], [0.2] * 1024, limit=5, kinds=["evidence"], + query_text="cardiomiopatia", query_language="italian", + ) + + def test_search_excludes_inconsistent_semantic_kind_in_bound_workspace(): fake = FakeQdrantHttp() store = _store(fake) diff --git a/harness/tht/adapters/vector/qdrant.py b/harness/tht/adapters/vector/qdrant.py index aded7b04..5c7fbd87 100644 --- a/harness/tht/adapters/vector/qdrant.py +++ b/harness/tht/adapters/vector/qdrant.py @@ -102,6 +102,7 @@ class QdrantVectorStore: write_reachable=False, write_detail=str(exc), expected_dimension=self._expected_dimension, + bm25_compatible=None, ) dimension = info["config"]["params"]["vectors"]["size"] @@ -118,6 +119,7 @@ class QdrantVectorStore: expected_dimension=self._expected_dimension, observed_dimensions=dimensions, dimension_compatible=compatible, + bm25_compatible=self._bm25_compatible(info), ) def search( @@ -176,6 +178,7 @@ class QdrantVectorStore: raise VectorStoreError("Hybrid BM25 is only available for Evidence") if query_text.strip() == "" or query_language not in _BM25_LANGUAGES: raise VectorStoreError("Evidence BM25 query is invalid") + self._ensure_collection(strict=False, require_bm25=True) shared_filter = {"must": filter_must} response = self._call( "POST", @@ -225,7 +228,10 @@ class QdrantVectorStore: def upsert(self, collection: str, records: list[VectorWriteRecord]) -> int: validate_collection(collection) - self._ensure_collection(strict=True) + self._ensure_collection( + strict=True, + require_bm25=any(record.sparse_text is not None for record in records), + ) points = [] for write_record in records: validate_collection_kinds(collection, [write_record.record.kind]) @@ -377,7 +383,15 @@ class QdrantVectorStore: else "Embedding dimension does not match configured dimension" ) - def _ensure_collection(self, *, strict: bool) -> dict | None: + @staticmethod + def _bm25_compatible(info: dict) -> bool: + sparse_vectors = info.get("config", {}).get("params", {}).get("sparse_vectors") + if not isinstance(sparse_vectors, dict): + return False + bm25 = sparse_vectors.get("bm25") + return isinstance(bm25, dict) and bm25.get("modifier") == "idf" + + def _ensure_collection(self, *, strict: bool, require_bm25: bool = False) -> dict | None: response = self._call("GET", f"/collections/{self._collection}", None, allow_missing=True) if response is None: if not strict: @@ -420,6 +434,8 @@ class QdrantVectorStore: f"/collections/{self._collection}/index", {"field_name": field_name, "field_schema": "keyword"}, ) + if require_bm25 and not self._bm25_compatible(result): + raise VectorStoreError("Evidence BM25 collection configuration mismatch") return result def _scroll(self, must: list[dict]) -> list[dict]: diff --git a/harness/tht/evidence/corpus/pipeline.py b/harness/tht/evidence/corpus/pipeline.py index 969858d5..0cb78a03 100644 --- a/harness/tht/evidence/corpus/pipeline.py +++ b/harness/tht/evidence/corpus/pipeline.py @@ -367,6 +367,7 @@ class CorpusPipeline: if ( not health.ok or health.dimension_compatible is not True + or health.bm25_compatible is False or health.expected_dimension != self.embedding_dimensions or health.observed_dimensions != (self.embedding_dimensions,) ): diff --git a/harness/tht/ports/vector.py b/harness/tht/ports/vector.py index f16c20b1..0c4bfa30 100644 --- a/harness/tht/ports/vector.py +++ b/harness/tht/ports/vector.py @@ -30,6 +30,7 @@ class VectorHealth: expected_dimension: int | None = None observed_dimensions: tuple[int, ...] = () dimension_compatible: bool | None = None + bm25_compatible: bool | None = None @dataclass(frozen=True)