fix: harden task3 qdrant compatibility boundaries
This commit is contained in:
@@ -13,9 +13,12 @@ from tht.adapters.vector._shared import (
|
||||
validate_known_kinds,
|
||||
)
|
||||
from tht.ports.vector import (
|
||||
SemanticIndexIncompatibleError,
|
||||
VectorCapabilities,
|
||||
VectorHealth,
|
||||
VectorResponseError,
|
||||
VectorStoreError,
|
||||
VectorTransportError,
|
||||
VectorWriteRecord,
|
||||
require_positive_limit,
|
||||
)
|
||||
@@ -55,6 +58,7 @@ class QdrantVectorStore:
|
||||
workspace_id: str,
|
||||
workspace_revision: str | None = None,
|
||||
expected_dimension: int | None = None,
|
||||
expected_distance: str | None = "Cosine",
|
||||
collection_lifecycle: str = "create_if_missing",
|
||||
request: Callable[..., object] | None = None,
|
||||
connect_timeout: float = 2.0,
|
||||
@@ -65,8 +69,16 @@ class QdrantVectorStore:
|
||||
self._workspace_id = workspace_id
|
||||
self._workspace_revision = workspace_revision
|
||||
self._expected_dimension = expected_dimension
|
||||
self._expected_distance = expected_distance
|
||||
if collection_lifecycle not in ("create_if_missing", "require_existing"):
|
||||
raise ValueError("Unsupported Qdrant collection lifecycle")
|
||||
if collection_lifecycle == "require_existing" and (
|
||||
expected_dimension is None or expected_dimension <= 0 or expected_distance is None
|
||||
):
|
||||
raise ValueError(
|
||||
"require_existing requires an explicit positive expected dimension "
|
||||
"and expected distance"
|
||||
)
|
||||
self._collection_lifecycle = collection_lifecycle
|
||||
self._request = request or requests.request
|
||||
self._timeout = (connect_timeout, read_timeout)
|
||||
@@ -100,9 +112,17 @@ class QdrantVectorStore:
|
||||
|
||||
dimension = info["config"]["params"]["vectors"]["size"]
|
||||
dimensions = (dimension,)
|
||||
compatible = (
|
||||
dimension_compatible = (
|
||||
None if self._expected_dimension is None else dimensions == (self._expected_dimension,)
|
||||
)
|
||||
observed_distance = info["config"]["params"]["vectors"].get("distance")
|
||||
distance_compatible = (
|
||||
None if self._expected_distance is None else observed_distance == self._expected_distance
|
||||
)
|
||||
compatible = (
|
||||
None if dimension_compatible is None and distance_compatible is None
|
||||
else dimension_compatible is not False and distance_compatible is not False
|
||||
)
|
||||
return VectorHealth(
|
||||
ok=compatible is not False,
|
||||
read_configured=True,
|
||||
@@ -161,7 +181,7 @@ class QdrantVectorStore:
|
||||
)
|
||||
points = response.get("result", {}).get("points")
|
||||
if not isinstance(points, list):
|
||||
raise VectorStoreError("Qdrant returned malformed query response")
|
||||
raise VectorResponseError("Qdrant returned malformed query response")
|
||||
hits = [self._hit_from_point(point) for point in points]
|
||||
return sorted(hits, key=lambda hit: (-hit.similarity, hit.id))[:limit]
|
||||
|
||||
@@ -179,11 +199,11 @@ class QdrantVectorStore:
|
||||
for point in points:
|
||||
payload = point.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
raise VectorStoreError("Qdrant returned malformed scroll response")
|
||||
raise VectorResponseError("Qdrant returned malformed scroll response")
|
||||
record_key = payload.get("record_key")
|
||||
content_hash = payload.get("content_hash")
|
||||
if not isinstance(record_key, str) or not isinstance(content_hash, str):
|
||||
raise VectorStoreError("Qdrant returned malformed scroll response")
|
||||
raise VectorResponseError("Qdrant returned malformed scroll response")
|
||||
hashes[record_key] = content_hash
|
||||
return hashes
|
||||
|
||||
@@ -207,11 +227,18 @@ class QdrantVectorStore:
|
||||
),
|
||||
}
|
||||
)
|
||||
self._call(
|
||||
"PUT",
|
||||
f"/collections/{self._collection}/points?wait=true",
|
||||
{"points": points},
|
||||
)
|
||||
try:
|
||||
self._call(
|
||||
"PUT",
|
||||
f"/collections/{self._collection}/points?wait=true",
|
||||
{"points": points},
|
||||
)
|
||||
except VectorTransportError as exc:
|
||||
if self._collection_lifecycle == "require_existing" and exc.status_code == 404:
|
||||
raise SemanticIndexIncompatibleError(
|
||||
"Qdrant collection disappeared during semantic index write"
|
||||
) from exc
|
||||
raise
|
||||
return len(records)
|
||||
|
||||
def delete_kinds(self, collection: str, kinds: list[str]) -> int:
|
||||
@@ -308,10 +335,10 @@ class QdrantVectorStore:
|
||||
def _ensure_collection(self, *, strict: bool) -> dict | None:
|
||||
response = self._call("GET", f"/collections/{self._collection}", None, allow_missing=True)
|
||||
if response is None:
|
||||
if self._collection_lifecycle == "require_existing":
|
||||
raise SemanticIndexIncompatibleError("Qdrant collection is missing")
|
||||
if not strict:
|
||||
raise VectorStoreError("Qdrant collection is missing")
|
||||
if self._collection_lifecycle == "require_existing":
|
||||
raise VectorStoreError("Qdrant collection configuration mismatch (semantic_index_incompatible)")
|
||||
self._call(
|
||||
"PUT",
|
||||
f"/collections/{self._collection}",
|
||||
@@ -327,24 +354,31 @@ class QdrantVectorStore:
|
||||
result = response.get("result") if isinstance(response, dict) else None
|
||||
config = result.get("config", {}).get("params", {}).get("vectors") if isinstance(result, dict) else None
|
||||
if not isinstance(config, dict):
|
||||
raise VectorStoreError("Qdrant returned malformed collection response")
|
||||
raise VectorResponseError("Qdrant returned malformed collection response")
|
||||
size = config.get("size")
|
||||
distance = config.get("distance")
|
||||
if (
|
||||
self._expected_dimension is not None
|
||||
and (size != self._expected_dimension or distance != "Cosine")
|
||||
self._expected_dimension is not None and size != self._expected_dimension
|
||||
) or (
|
||||
self._expected_distance is not None and distance != self._expected_distance
|
||||
):
|
||||
raise VectorStoreError("Qdrant collection configuration mismatch (semantic_index_incompatible)")
|
||||
if self._collection_lifecycle == "require_existing":
|
||||
raise SemanticIndexIncompatibleError(
|
||||
"Qdrant collection dimension or distance is incompatible"
|
||||
)
|
||||
raise VectorStoreError("Qdrant collection configuration mismatch")
|
||||
payload_schema = result.get("payload_schema")
|
||||
if not isinstance(payload_schema, dict):
|
||||
raise VectorStoreError("Qdrant returned malformed collection response")
|
||||
raise VectorResponseError("Qdrant returned malformed collection response")
|
||||
for field_name in _KEYWORD_INDEXES:
|
||||
field = payload_schema.get(field_name)
|
||||
if not isinstance(field, dict) or field.get("data_type") != "keyword":
|
||||
if self._collection_lifecycle == "require_existing":
|
||||
raise SemanticIndexIncompatibleError(
|
||||
"Qdrant collection payload indexes are incompatible"
|
||||
)
|
||||
if not strict:
|
||||
raise VectorStoreError("Qdrant collection payload indexes mismatch")
|
||||
if self._collection_lifecycle == "require_existing":
|
||||
raise VectorStoreError("Qdrant collection configuration mismatch (semantic_index_incompatible)")
|
||||
self._call(
|
||||
"PUT",
|
||||
f"/collections/{self._collection}/index",
|
||||
@@ -370,13 +404,13 @@ class QdrantVectorStore:
|
||||
result = response.get("result", {})
|
||||
page = result.get("points")
|
||||
if not isinstance(page, list):
|
||||
raise VectorStoreError("Qdrant returned malformed scroll response")
|
||||
raise VectorResponseError("Qdrant returned malformed scroll response")
|
||||
points.extend(page)
|
||||
next_page_offset = result.get("next_page_offset")
|
||||
if next_page_offset is None:
|
||||
return points
|
||||
if next_page_offset in seen_offsets:
|
||||
raise VectorStoreError("Qdrant returned malformed scroll response")
|
||||
raise VectorResponseError("Qdrant returned malformed scroll response")
|
||||
seen_offsets.add(next_page_offset)
|
||||
offset = next_page_offset
|
||||
|
||||
@@ -384,7 +418,7 @@ class QdrantVectorStore:
|
||||
payload = point.get("payload")
|
||||
score = point.get("score")
|
||||
if not isinstance(payload, dict) or not isinstance(score, (int, float)):
|
||||
raise VectorStoreError("Qdrant returned malformed query response")
|
||||
raise VectorResponseError("Qdrant returned malformed query response")
|
||||
return hit_from_metadata(float(score), payload)
|
||||
|
||||
def _call(self, method: str, path: str, payload: dict | None, allow_missing: bool = False) -> dict | None:
|
||||
@@ -396,19 +430,19 @@ class QdrantVectorStore:
|
||||
timeout=self._timeout,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise VectorStoreError(_sanitize_exception(exc)) from exc
|
||||
raise VectorTransportError(_sanitize_exception(exc)) from exc
|
||||
if response.status_code == 404 and allow_missing:
|
||||
return None
|
||||
if not response.ok:
|
||||
raise VectorStoreError(f"Qdrant request failed: HTTP {response.status_code}")
|
||||
raise VectorTransportError(f"Qdrant request failed: HTTP {response.status_code}", status_code=response.status_code)
|
||||
if response.status_code == 204 or not getattr(response, "text", ""):
|
||||
return {}
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception as exc:
|
||||
raise VectorStoreError("Qdrant returned malformed JSON response") from exc
|
||||
raise VectorResponseError("Qdrant returned malformed JSON response") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise VectorStoreError("Qdrant returned malformed JSON response")
|
||||
raise VectorResponseError("Qdrant returned malformed JSON response")
|
||||
return data
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user