fix(evidence): fail closed when BM25 is unavailable

This commit is contained in:
2026-08-24 18:21:21 +02:00
parent 29d41ac258
commit 6176410f42
5 changed files with 70 additions and 5 deletions
@@ -3,7 +3,9 @@
from __future__ import annotations from __future__ import annotations
import time import time
from contextlib import ExitStack
from pathlib import Path from pathlib import Path
from uuid import uuid4
import pytest import pytest
import requests 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(): 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)}" base_url = f"http://{qdrant.get_container_host_ip()}:{qdrant.get_exposed_port(6333)}"
wait_for_qdrant(base_url) wait_for_qdrant(base_url)
collection = "italian_bm25_contract" collection = f"italian_bm25_contract_{uuid4().hex}"
request_ok( request_ok(
"PUT", "PUT",
f"{base_url}/collections/{collection}", 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"}, "vectors": {"size": 4, "distance": "Cosine"},
}, },
) )
cleanup.callback(request_ok, "DELETE", f"{base_url}/collections/{collection}")
legacy_points = [ legacy_points = [
{"id": 10, "vector": [1.0, 0.0, 0.0, 0.0], "payload": {"record_kind": "schema_table"}}, {"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"}}, {"id": 11, "vector": [0.0, 1.0, 0.0, 0.0], "payload": {"record_kind": "schema_column"}},
+45 -1
View File
@@ -32,6 +32,7 @@ class FakeQdrantHttp:
self.dimension = dimension self.dimension = dimension
self.distance = distance self.distance = distance
self.collection = None self.collection = None
self.sparse_vectors: dict[str, dict] | None = None
self.payload_indexes: set[str] = set() self.payload_indexes: set[str] = set()
self.points: dict[str, dict] = {} self.points: dict[str, dict] = {}
self.calls: list[tuple[str, str, dict | None]] = [] self.calls: list[tuple[str, str, dict | None]] = []
@@ -56,7 +57,11 @@ class FakeQdrantHttp:
"result": { "result": {
"config": { "config": {
"params": { "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": { "payload_schema": {
@@ -69,6 +74,7 @@ class FakeQdrantHttp:
self.collection = json self.collection = json
self.dimension = json["vectors"]["size"] self.dimension = json["vectors"]["size"]
self.distance = json["vectors"]["distance"] self.distance = json["vectors"]["distance"]
self.sparse_vectors = json.get("sparse_vectors")
return FakeResponse(200, {"status": "ok"}) return FakeResponse(200, {"status": "ok"})
if method == "PUT" and path == "/collections/workspace-semantic/index": 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(): def test_point_id_is_deterministic_uuidv5():
assert point_id("demo", "memory", "memory:1") == str( assert point_id("demo", "memory", "memory:1") == str(
uuid5(NAMESPACE_URL, "thothii:demo:memory:memory:1") 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() 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( @pytest.mark.parametrize(
("record", "semantic_kind"), ("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(): def test_evidence_upsert_sends_dense_and_server_side_italian_bm25():
fake = FakeQdrantHttp() fake = FakeQdrantHttp()
_ready_collection_with_bm25(fake)
store = _store(fake) store = _store(fake)
record = _write_record( record = _write_record(
"demo:gen:11111111111111111111111111111111:chunk:1", "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(): def test_evidence_search_uses_filtered_dense_and_bm25_prefetches_with_default_rrf():
fake = FakeQdrantHttp() fake = FakeQdrantHttp()
_ready_collection_with_bm25(fake)
store = _store(fake) store = _store(fake)
generation = "gen:" + "1" * 32 generation = "gen:" + "1" * 32
store.upsert("evidence", [ 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"]) 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(): def test_search_excludes_inconsistent_semantic_kind_in_bound_workspace():
fake = FakeQdrantHttp() fake = FakeQdrantHttp()
store = _store(fake) store = _store(fake)
+18 -2
View File
@@ -102,6 +102,7 @@ class QdrantVectorStore:
write_reachable=False, write_reachable=False,
write_detail=str(exc), write_detail=str(exc),
expected_dimension=self._expected_dimension, expected_dimension=self._expected_dimension,
bm25_compatible=None,
) )
dimension = info["config"]["params"]["vectors"]["size"] dimension = info["config"]["params"]["vectors"]["size"]
@@ -118,6 +119,7 @@ class QdrantVectorStore:
expected_dimension=self._expected_dimension, expected_dimension=self._expected_dimension,
observed_dimensions=dimensions, observed_dimensions=dimensions,
dimension_compatible=compatible, dimension_compatible=compatible,
bm25_compatible=self._bm25_compatible(info),
) )
def search( def search(
@@ -176,6 +178,7 @@ class QdrantVectorStore:
raise VectorStoreError("Hybrid BM25 is only available for Evidence") raise VectorStoreError("Hybrid BM25 is only available for Evidence")
if query_text.strip() == "" or query_language not in _BM25_LANGUAGES: if query_text.strip() == "" or query_language not in _BM25_LANGUAGES:
raise VectorStoreError("Evidence BM25 query is invalid") raise VectorStoreError("Evidence BM25 query is invalid")
self._ensure_collection(strict=False, require_bm25=True)
shared_filter = {"must": filter_must} shared_filter = {"must": filter_must}
response = self._call( response = self._call(
"POST", "POST",
@@ -225,7 +228,10 @@ class QdrantVectorStore:
def upsert(self, collection: str, records: list[VectorWriteRecord]) -> int: def upsert(self, collection: str, records: list[VectorWriteRecord]) -> int:
validate_collection(collection) 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 = [] points = []
for write_record in records: for write_record in records:
validate_collection_kinds(collection, [write_record.record.kind]) validate_collection_kinds(collection, [write_record.record.kind])
@@ -377,7 +383,15 @@ class QdrantVectorStore:
else "Embedding dimension does not match configured dimension" 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) response = self._call("GET", f"/collections/{self._collection}", None, allow_missing=True)
if response is None: if response is None:
if not strict: if not strict:
@@ -420,6 +434,8 @@ class QdrantVectorStore:
f"/collections/{self._collection}/index", f"/collections/{self._collection}/index",
{"field_name": field_name, "field_schema": "keyword"}, {"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 return result
def _scroll(self, must: list[dict]) -> list[dict]: def _scroll(self, must: list[dict]) -> list[dict]:
+1
View File
@@ -367,6 +367,7 @@ class CorpusPipeline:
if ( if (
not health.ok not health.ok
or health.dimension_compatible is not True or health.dimension_compatible is not True
or health.bm25_compatible is False
or health.expected_dimension != self.embedding_dimensions or health.expected_dimension != self.embedding_dimensions
or health.observed_dimensions != (self.embedding_dimensions,) or health.observed_dimensions != (self.embedding_dimensions,)
): ):
+1
View File
@@ -30,6 +30,7 @@ class VectorHealth:
expected_dimension: int | None = None expected_dimension: int | None = None
observed_dimensions: tuple[int, ...] = () observed_dimensions: tuple[int, ...] = ()
dimension_compatible: bool | None = None dimension_compatible: bool | None = None
bm25_compatible: bool | None = None
@dataclass(frozen=True) @dataclass(frozen=True)