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
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"}},
+45 -1
View File
@@ -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)
+18 -2
View File
@@ -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]:
+1
View File
@@ -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,)
):
+1
View File
@@ -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)