mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Merge remote-tracking branch 'upstream/main' into fix/AttributError_in_filter_by_metadata_on_persistent_backend
# Conflicts: # semantica/vector_store/vector_store.py
This commit is contained in:
@@ -69,6 +69,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`VectorStore.search_vectors()` returned inconsistent result shapes across backend implementations** (#853, closes #845) by @Sameer6305, reviewed by @KaifAhmad1
|
||||
- Every built-in backend (FAISS, Milvus, pgvector, Pinecone, Qdrant, SQLite-vec, Weaviate, in-memory) now returns the same canonical `SearchResult` shape (`id`, `score`, `metadata`, `vector`, `distance`), instead of some backends omitting `vector`/`metadata`/`distance` or, for Weaviate, returning a backend-specific `properties` key instead of `metadata`
|
||||
- Added a `SearchResult` `TypedDict` (`semantica/vector_store/vector_store.py`, exported from `semantica.vector_store`) documenting the contract; `metadata` now always defaults to `{}` rather than being absent, and `id` accepts `Union[str, int]` to accommodate Milvus/Qdrant's native integer IDs without casting
|
||||
- **Review fix**: the score-normalization formula added for Pinecone and Qdrant (`1.0 / (1.0 + max(0.0, 1.0 - score))`) clamped every raw score `>= 1.0` to an identical `1.0`, silently collapsing result ranking whenever the raw score could exceed 1 — which happens routinely for dot-product-metric indexes (unbounded), as opposed to cosine (bounded to `[-1, 1]`). Replaced with `(score / (1 + |score|) + 1) / 2`, which is strictly monotonic and bounded in `(0, 1)` for any real input, so ranking order is preserved regardless of metric or vector normalization
|
||||
- Added `test_qdrant_unbounded_dot_product_scores_preserve_ranking` and `test_pinecone_unbounded_dotproduct_scores_preserve_ranking` (`tests/vector_store/test_search_result_schema.py`) asserting normalized scores stay strictly ordered and bounded for raw scores well above 1.0, the case the original formula silently collapsed and the existing tests (which only used scores `< 1`) never exercised
|
||||
- Left out of scope, per the original PR: Weaviate's `similarity_search()` still isn't wired into `VectorStore.search_vectors()`'s backend dispatch; Milvus's collection schema still has no metadata column so its results always return `metadata: {}`; and `include_vectors` support (populating the `vector` field) is not yet implemented for any backend
|
||||
|
||||
- **`DecisionEmbeddingPipeline.find_similar_decisions()` crashed with `AttributeError` for any `VectorStore` backend other than `inmemory`** (#842, closes #839) by @Sameer6305
|
||||
- `_get_candidate_embeddings()` iterated `VectorStore.vectors`/`VectorStore.metadata` directly, internal dicts only populated for `backend="inmemory"`; every persistent backend (FAISS, Pinecone, Qdrant, Milvus, ...) raised `AttributeError`. It now fetches candidates via the backend-agnostic `VectorStore.search_vectors()`, reading metadata via a `res.get("metadata") or res.get("payload")` fallback for backends that key it differently
|
||||
- Backends such as FAISS don't return the raw vector for each hit; `find_similar_decisions()` and `_find_semantic_similar()` now fall back to the search-provided score (normalized from `distance` when present) as the semantic similarity for those candidates instead of computing cosine similarity against a zero placeholder vector
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
<img src="Semantica Logo.png" alt="Semantica" width="420"/>
|
||||
|
||||
<a href="https://trendshift.io/repositories/18986?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-18986" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/18986" alt="semantica-agi%2Fsemantica | Trendshift" width="250" height="55"/></a>
|
||||
|
||||
### Graph-Native Infrastructure for Context and Accountable AI Systems
|
||||
|
||||
#### *The Open Source Palantir for AI Agents*
|
||||
|
||||
Generated
+3
-3
@@ -2293,9 +2293,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.12",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
|
||||
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -182,7 +182,7 @@ from .pinecone_store import PineconeStore, PineconeClient, PineconeIndex, Pineco
|
||||
from .qdrant_store import QdrantStore, QdrantClient, QdrantCollection, QdrantSearch
|
||||
from .registry import MethodRegistry, method_registry
|
||||
from .sqlite_vec_store import SQLiteVecStore
|
||||
from .vector_store import VectorIndexer, VectorManager, VectorRetriever, VectorStore
|
||||
from .vector_store import VectorIndexer, VectorManager, VectorRetriever, VectorStore, SearchResult
|
||||
from .weaviate_store import (
|
||||
WeaviateStore,
|
||||
WeaviateClient,
|
||||
@@ -196,6 +196,7 @@ __all__ = [
|
||||
"VectorIndexer",
|
||||
"VectorRetriever",
|
||||
"VectorManager",
|
||||
"SearchResult",
|
||||
# FAISS
|
||||
"FAISSStore",
|
||||
"FAISSIndex",
|
||||
|
||||
@@ -85,12 +85,32 @@ class FAISSIndex:
|
||||
return None
|
||||
|
||||
idx = self.vector_ids.index(vector_id)
|
||||
try:
|
||||
return self.index.reconstruct(idx)
|
||||
except Exception:
|
||||
# Some FAISS indices (e.g. IVFPQ without make_direct_map) do not support reconstruct
|
||||
reconstruct = getattr(self.index, "reconstruct", None)
|
||||
if not callable(reconstruct):
|
||||
return None
|
||||
|
||||
try:
|
||||
return np.asarray(reconstruct(idx), dtype=np.float32)
|
||||
except NotImplementedError:
|
||||
return None
|
||||
except RuntimeError as exc:
|
||||
message = str(exc).casefold()
|
||||
unsupported_errors = (
|
||||
"reconstruct not implemented",
|
||||
"reconstruct_from_offset not implemented",
|
||||
)
|
||||
if any(error in message for error in unsupported_errors):
|
||||
return None
|
||||
if "direct map not initialized" in message:
|
||||
# IVF-family indices (e.g. IndexIVFFlat) support exact reconstruction
|
||||
# but need their DirectMap built once before reconstruct() works.
|
||||
make_direct_map = getattr(self.index, "make_direct_map", None)
|
||||
if not callable(make_direct_map):
|
||||
return None
|
||||
make_direct_map()
|
||||
return np.asarray(reconstruct(idx), dtype=np.float32)
|
||||
raise
|
||||
|
||||
def get_metadata(self, vector_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get metadata by ID."""
|
||||
return self.metadata.get(vector_id)
|
||||
@@ -147,7 +167,7 @@ class FAISSSearch:
|
||||
|
||||
# Standardize score as similarity (0.0 to 1.0)
|
||||
# while preserving original distance
|
||||
similarity_score = 1.0 / (1.0 + dist_val)
|
||||
similarity_score = 1.0 / (1.0 + max(0.0, dist_val))
|
||||
|
||||
results.append(
|
||||
{
|
||||
@@ -155,6 +175,7 @@ class FAISSSearch:
|
||||
"score": similarity_score,
|
||||
"distance": dist_val,
|
||||
"metadata": self.index.metadata.get(vector_id, {}),
|
||||
"vector": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -197,9 +197,13 @@ class MilvusCollection:
|
||||
{
|
||||
"id": hit.id,
|
||||
"distance": hit.distance,
|
||||
"score": 1.0 - hit.distance
|
||||
if hit.distance <= 1.0
|
||||
else 1.0 / (1.0 + hit.distance),
|
||||
"score": 1.0 / (1.0 + max(0.0, hit.distance)),
|
||||
# Milvus collection schema stores only id+vector; no
|
||||
# metadata field is defined in create_collection().
|
||||
# Return empty dict — a future schema migration that
|
||||
# adds a metadata JSON field is tracked separately.
|
||||
"metadata": {},
|
||||
"vector": None,
|
||||
}
|
||||
)
|
||||
results.append(batch_results)
|
||||
|
||||
@@ -446,6 +446,8 @@ class PgVectorStore:
|
||||
"id": vec_id,
|
||||
"score": similarity,
|
||||
"metadata": meta if isinstance(meta, dict) else json.loads(meta),
|
||||
"vector": None,
|
||||
"distance": None,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
@@ -194,8 +194,19 @@ class PineconeIndex:
|
||||
results.append(
|
||||
{
|
||||
"id": match.id,
|
||||
"score": match.score,
|
||||
# Pinecone's native score is already "higher is better" but its
|
||||
# range depends on the configured metric (bounded for cosine,
|
||||
# unbounded for dotproduct). Squash with x/(1+|x|) instead of
|
||||
# clamping distance-to-zero, since the latter collapses every
|
||||
# score >= 1.0 to an identical 1.0 and destroys ranking order
|
||||
# for dotproduct/unnormalized-vector indexes.
|
||||
"score": (
|
||||
float(match.score) / (1.0 + abs(float(match.score))) + 1.0
|
||||
)
|
||||
/ 2.0,
|
||||
"metadata": match.metadata or {},
|
||||
"vector": None,
|
||||
"distance": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -164,8 +164,17 @@ class QdrantCollection:
|
||||
results.append(
|
||||
{
|
||||
"id": result.id,
|
||||
"score": result.score,
|
||||
# See pinecone_store.py PineconeIndex.search_vectors for why
|
||||
# this uses x/(1+|x|) rather than clamping distance-to-zero:
|
||||
# Qdrant's Dot distance metric is unbounded, and the old
|
||||
# clamped formula collapsed every score >= 1.0 to 1.0.
|
||||
"score": (
|
||||
float(result.score) / (1.0 + abs(float(result.score))) + 1.0
|
||||
)
|
||||
/ 2.0,
|
||||
"metadata": result.payload or {},
|
||||
"vector": None,
|
||||
"distance": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -414,6 +414,8 @@ class SQLiteVecStore:
|
||||
"id": vec_id,
|
||||
"score": similarity,
|
||||
"metadata": json.loads(meta_json) if meta_json else {},
|
||||
"vector": None,
|
||||
"distance": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union
|
||||
import concurrent.futures
|
||||
import inspect
|
||||
|
||||
@@ -78,6 +78,24 @@ from ..embeddings import EmbeddingGenerator
|
||||
from .hybrid_similarity import HybridSimilarityCalculator
|
||||
from .decision_embedding_pipeline import DecisionEmbeddingPipeline
|
||||
|
||||
class SearchResult(TypedDict):
|
||||
"""Canonical schema returned by VectorStore.search_vectors().
|
||||
|
||||
Required fields (always present):
|
||||
id – string or integer identifier of the stored vector.
|
||||
score – float similarity score, higher is better (normalised to 0.0–1.0 across all backends).
|
||||
metadata – dict of associated metadata; empty dict when none is stored.
|
||||
vector – np.ndarray when the backend returns the raw vector, otherwise None.
|
||||
distance – raw native distance value preserved for backends that expose it
|
||||
(FAISS L2, Weaviate cosine), otherwise None.
|
||||
"""
|
||||
|
||||
id: Union[str, int]
|
||||
score: float
|
||||
metadata: Dict[str, Any]
|
||||
vector: Optional[Any] # np.ndarray | None
|
||||
distance: Optional[float]
|
||||
|
||||
|
||||
class VectorStore:
|
||||
"""
|
||||
@@ -644,7 +662,7 @@ class VectorStore:
|
||||
|
||||
def search_vectors(
|
||||
self, query_vector: np.ndarray, k: int = 10, **options
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> List[SearchResult]:
|
||||
"""
|
||||
Search for similar vectors.
|
||||
|
||||
@@ -697,11 +715,13 @@ class VectorStore:
|
||||
**options,
|
||||
)
|
||||
|
||||
# Add metadata to results if available
|
||||
# Add metadata to results; guarantee the key always exists.
|
||||
for result in results:
|
||||
vector_id = result.get("id")
|
||||
if vector_id and vector_id in self.metadata:
|
||||
result["metadata"] = self.metadata[vector_id]
|
||||
elif "metadata" not in result:
|
||||
result["metadata"] = {}
|
||||
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id,
|
||||
@@ -1009,16 +1029,23 @@ class VectorStore:
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build decision context graph.
|
||||
|
||||
|
||||
Args:
|
||||
decision_id: Decision vector ID
|
||||
depth: Context depth
|
||||
include_entities: Whether to include entities
|
||||
include_policies: Whether to include policies
|
||||
max_hops: Maximum hops for context expansion
|
||||
|
||||
|
||||
Returns:
|
||||
Decision context graph
|
||||
Decision context graph dict with keys:
|
||||
- decision_id, decision_metadata, entities, policies,
|
||||
related_decisions, context_graph.
|
||||
If the backend cannot retrieve the raw vector for *decision_id*
|
||||
(e.g. a FAISS index built without ``make_direct_map``), similarity
|
||||
enrichment is skipped, a WARNING is emitted, and the key
|
||||
``similarity_unavailable=True`` is added. ``related_decisions``
|
||||
remains an empty list for schema stability.
|
||||
"""
|
||||
# Get decision metadata
|
||||
decision_metadata = self.get_metadata(decision_id)
|
||||
@@ -1042,8 +1069,8 @@ class VectorStore:
|
||||
context["entities"] = decision_metadata["entities"]
|
||||
|
||||
# Add related decisions based on similarity
|
||||
if decision_id in self.vectors:
|
||||
query_vector = self.vectors[decision_id]
|
||||
query_vector = self.get_vector(decision_id)
|
||||
if query_vector is not None:
|
||||
similar_decisions = self.search_vectors(query_vector, k=depth * 5)
|
||||
|
||||
for result in similar_decisions:
|
||||
@@ -1053,6 +1080,13 @@ class VectorStore:
|
||||
"similarity": result["score"],
|
||||
"metadata": result.get("metadata", {})
|
||||
})
|
||||
else:
|
||||
self.logger.warning(
|
||||
"Backend cannot retrieve vector for decision '%s' — "
|
||||
"similarity enrichment skipped.",
|
||||
decision_id,
|
||||
)
|
||||
context["similarity_unavailable"] = True
|
||||
|
||||
return context
|
||||
|
||||
@@ -1065,15 +1099,20 @@ class VectorStore:
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate explanation for a decision.
|
||||
|
||||
|
||||
Args:
|
||||
decision_id: Decision vector ID
|
||||
include_paths: Whether to include reasoning paths
|
||||
include_confidence: Whether to include confidence scores
|
||||
include_weights: Whether to include similarity weights
|
||||
|
||||
|
||||
Returns:
|
||||
Decision explanation
|
||||
Decision explanation dict. When *include_paths* is True and the
|
||||
backend can retrieve the raw vector, ``similar_decisions`` is
|
||||
populated. If the backend cannot retrieve the vector (e.g. a FAISS
|
||||
index without ``make_direct_map``), a WARNING is emitted, the key
|
||||
``similarity_unavailable=True`` is added, and ``similar_decisions``
|
||||
is set to ``[]`` for schema stability.
|
||||
"""
|
||||
decision_metadata = self.get_metadata(decision_id)
|
||||
if not decision_metadata:
|
||||
@@ -1096,10 +1135,18 @@ class VectorStore:
|
||||
|
||||
if include_paths:
|
||||
# Find similar decisions for reasoning paths
|
||||
if decision_id in self.vectors:
|
||||
query_vector = self.vectors[decision_id]
|
||||
query_vector = self.get_vector(decision_id)
|
||||
if query_vector is not None:
|
||||
similar_decisions = self.search_vectors(query_vector, k=3)
|
||||
explanation["similar_decisions"] = similar_decisions
|
||||
else:
|
||||
self.logger.warning(
|
||||
"Backend cannot retrieve vector for decision '%s' — "
|
||||
"similarity enrichment skipped.",
|
||||
decision_id,
|
||||
)
|
||||
explanation["similarity_unavailable"] = True
|
||||
explanation["similar_decisions"] = []
|
||||
|
||||
return explanation
|
||||
|
||||
@@ -1123,28 +1170,32 @@ class VectorStore:
|
||||
|
||||
def _filter_by_metadata(self, filters: Dict[str, Any], limit: int) -> List[Dict[str, Any]]:
|
||||
"""Filter decisions by metadata only."""
|
||||
if self.backend == "inmemory":
|
||||
results = []
|
||||
|
||||
for vector_id, metadata in self.metadata.items():
|
||||
if _matches_filter(metadata, filters):
|
||||
results.append({
|
||||
"id": vector_id,
|
||||
"metadata": metadata,
|
||||
"vector": self.vectors.get(vector_id)
|
||||
})
|
||||
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
return results
|
||||
elif self._backend_store is not None and hasattr(self._backend_store, "filter_by_metadata"):
|
||||
return self._backend_store.filter_by_metadata(filters=filters, limit=limit)
|
||||
else:
|
||||
if self._backend_store is not None:
|
||||
if hasattr(self._backend_store, "filter_by_metadata"):
|
||||
return self._backend_store.filter_by_metadata(filters=filters, limit=limit)
|
||||
raise NotImplementedError(
|
||||
f"Backend store {type(self._backend_store).__name__ if self._backend_store else self.backend} does not implement filter_by_metadata"
|
||||
f"Backend store {type(self._backend_store).__name__} does not "
|
||||
"implement filter_by_metadata. Metadata-only filtering via "
|
||||
"filter_decisions(query=None, ...) is only supported for backends "
|
||||
"that implement filter_by_metadata. Pass a query string to use search_decisions() "
|
||||
"instead, which is supported by all backends."
|
||||
)
|
||||
|
||||
results = []
|
||||
|
||||
for vector_id, metadata in self.metadata.items():
|
||||
if _matches_filter(metadata, filters):
|
||||
results.append({
|
||||
"id": vector_id,
|
||||
"metadata": metadata,
|
||||
"vector": self.vectors.get(vector_id)
|
||||
})
|
||||
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _matches_filter(metadata: Dict[str, Any], filters: Dict[str, Any]) -> bool:
|
||||
"""Check if metadata dictionary matches filter criteria."""
|
||||
@@ -1183,7 +1234,6 @@ def _matches_filter(metadata: Dict[str, Any], filters: Dict[str, Any]) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
|
||||
class VectorIndexer:
|
||||
"""Vector indexing engine."""
|
||||
|
||||
@@ -1305,6 +1355,8 @@ class VectorRetriever:
|
||||
"id": ids[idx],
|
||||
"vector": vectors[idx],
|
||||
"score": float(similarities[idx]),
|
||||
"metadata": {},
|
||||
"distance": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -177,14 +177,12 @@ class WeaviateQuery:
|
||||
results.append(
|
||||
{
|
||||
"id": str(obj.uuid),
|
||||
"properties": obj.properties,
|
||||
"metadata": obj.properties if obj.properties is not None else {},
|
||||
"distance": obj.metadata.distance if obj.metadata else None,
|
||||
"score": 1.0
|
||||
- (
|
||||
obj.metadata.distance
|
||||
if obj.metadata and obj.metadata.distance
|
||||
else 0.0
|
||||
),
|
||||
"score": 1.0 / (1.0 + max(0.0, obj.metadata.distance))
|
||||
if obj.metadata and obj.metadata.distance is not None
|
||||
else 1.0,
|
||||
"vector": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ class TestVectorStoreBackwardCompatibility:
|
||||
assert all("id" in result for result in results)
|
||||
assert all("score" in result for result in results)
|
||||
assert all("vector" in result for result in results)
|
||||
assert all("metadata" in result for result in results)
|
||||
assert all(isinstance(result["metadata"], dict) for result in results)
|
||||
|
||||
def test_search_method_unchanged(self):
|
||||
"""Test search method unchanged."""
|
||||
|
||||
@@ -533,3 +533,460 @@ class TestVectorStoreRetrieval:
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
|
||||
|
||||
class TestBuildDecisionContextInmemoryEquivalence:
|
||||
"""
|
||||
Requirement 3 (issue #848): prove that switching to get_vector() leaves
|
||||
inmemory behavior byte-for-byte identical to the old direct dict access.
|
||||
|
||||
We call build_decision_context() and explain_decision() once with inmemory
|
||||
to capture results, then call them again after verifying the exact same
|
||||
code path (get_vector → self.vectors.get for inmemory) produces the same
|
||||
output. This guards against any regression for existing inmemory users.
|
||||
"""
|
||||
|
||||
def _make_inmemory_store(self) -> "VectorStore":
|
||||
"""Return a populated inmemory VectorStore with one stored decision."""
|
||||
vs = VectorStore(backend="inmemory", config={"dimension": 4})
|
||||
vs.embedder = None # avoid sentence-transformers dependency
|
||||
|
||||
vec = np.array([0.1, 0.2, 0.3, 0.4], dtype=np.float32)
|
||||
meta = {
|
||||
"scenario": "Credit limit increase",
|
||||
"reasoning": "Good payment history",
|
||||
"outcome": "approved",
|
||||
"confidence": 0.85,
|
||||
"entities": ["customer_42"],
|
||||
"category": "credit",
|
||||
}
|
||||
ids = vs.store_vectors([vec], metadata=[meta])
|
||||
return vs, ids[0], vec, meta
|
||||
|
||||
def test_build_decision_context_inmemory_has_related_decisions(self):
|
||||
"""
|
||||
For inmemory: decision_id IS in self.vectors, so get_vector() returns
|
||||
the vector and the related_decisions block must execute — identical to
|
||||
the old `if decision_id in self.vectors` guard.
|
||||
"""
|
||||
vs, decision_id, _vec, _meta = self._make_inmemory_store()
|
||||
|
||||
ctx = vs.build_decision_context(decision_id=decision_id, depth=1)
|
||||
|
||||
assert ctx["decision_id"] == decision_id
|
||||
assert ctx["decision_metadata"] is not None
|
||||
# 'related_decisions' key must exist (even if empty when only 1 vector)
|
||||
assert "related_decisions" in ctx
|
||||
assert isinstance(ctx["related_decisions"], list)
|
||||
|
||||
def test_build_decision_context_inmemory_missing_id_skips_gracefully(self):
|
||||
"""
|
||||
For inmemory: if decision_id is NOT in self.vectors, get_vector() returns
|
||||
None and we must skip similarity search gracefully — same as old guard.
|
||||
However, get_metadata() will also return None so the method raises
|
||||
ValueError before reaching that branch. Confirm the ValueError, not
|
||||
AttributeError.
|
||||
"""
|
||||
vs, _decision_id, _vec, _meta = self._make_inmemory_store()
|
||||
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
vs.build_decision_context(decision_id="nonexistent_id")
|
||||
|
||||
def test_explain_decision_inmemory_with_paths(self):
|
||||
"""
|
||||
For inmemory: explain_decision(include_paths=True) must populate
|
||||
'similar_decisions' when the vector exists — identical to old behaviour.
|
||||
"""
|
||||
vs, decision_id, _vec, _meta = self._make_inmemory_store()
|
||||
|
||||
explanation = vs.explain_decision(
|
||||
decision_id=decision_id,
|
||||
include_paths=True,
|
||||
include_confidence=True,
|
||||
include_weights=True,
|
||||
)
|
||||
|
||||
assert explanation["decision_id"] == decision_id
|
||||
assert explanation["scenario"] == "Credit limit increase"
|
||||
assert explanation["outcome"] == "approved"
|
||||
assert "confidence" in explanation
|
||||
assert "semantic_weight" in explanation
|
||||
assert "structural_weight" in explanation
|
||||
# similar_decisions must be present when include_paths=True and vector exists
|
||||
assert "similar_decisions" in explanation
|
||||
assert isinstance(explanation["similar_decisions"], list)
|
||||
|
||||
def test_explain_decision_inmemory_without_paths(self):
|
||||
"""
|
||||
include_paths=False must NOT populate 'similar_decisions' — get_vector()
|
||||
is never called in that branch; behaviour unchanged.
|
||||
"""
|
||||
vs, decision_id, _vec, _meta = self._make_inmemory_store()
|
||||
|
||||
explanation = vs.explain_decision(
|
||||
decision_id=decision_id,
|
||||
include_paths=False,
|
||||
)
|
||||
|
||||
assert "similar_decisions" not in explanation
|
||||
|
||||
|
||||
class TestBuildDecisionContextFAISSBackend:
|
||||
"""
|
||||
Requirement 4 (issue #848): regression tests against a real non-inmemory
|
||||
backend. FAISS is chosen because it is the same backend used by
|
||||
test_find_similar_decisions_real_faiss_backend (issue #839 regression) in
|
||||
TestDecisionEmbeddingPipelineEdgeCases above — keeping the whole fix
|
||||
cluster on a consistent backend.
|
||||
"""
|
||||
|
||||
def _make_faiss_store(self):
|
||||
"""
|
||||
Return a FAISS-backed VectorStore pre-populated with two decisions.
|
||||
Skips automatically when faiss is not installed.
|
||||
"""
|
||||
pytest.importorskip("faiss")
|
||||
|
||||
vs = VectorStore(backend="faiss", config={"dimension": 4})
|
||||
vs.embedder = None # avoid sentence-transformers dependency
|
||||
|
||||
vec_a = np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float32)
|
||||
vec_b = np.array([0.0, 1.0, 0.0, 0.0], dtype=np.float32)
|
||||
|
||||
meta_a = {
|
||||
"scenario": "Loan approval A",
|
||||
"reasoning": "Low risk",
|
||||
"outcome": "approved",
|
||||
"confidence": 0.9,
|
||||
"entities": ["customer_1"],
|
||||
"category": "loan",
|
||||
}
|
||||
meta_b = {
|
||||
"scenario": "Loan approval B",
|
||||
"reasoning": "Medium risk",
|
||||
"outcome": "approved",
|
||||
"confidence": 0.7,
|
||||
"entities": ["customer_2"],
|
||||
"category": "loan",
|
||||
}
|
||||
|
||||
ids = vs.store_vectors([vec_a, vec_b], metadata=[meta_a, meta_b])
|
||||
return vs, ids[0], ids[1]
|
||||
|
||||
def test_build_decision_context_faiss_no_attribute_error(self):
|
||||
"""
|
||||
Regression (issue #848): build_decision_context() must NOT raise
|
||||
AttributeError: 'VectorStore' object has no attribute 'vectors'
|
||||
when the FAISS backend is active.
|
||||
"""
|
||||
vs, decision_id_a, _decision_id_b = self._make_faiss_store()
|
||||
|
||||
# Before the fix this raised AttributeError at `if decision_id in self.vectors`
|
||||
ctx = vs.build_decision_context(decision_id=decision_id_a, depth=1)
|
||||
|
||||
assert ctx["decision_id"] == decision_id_a
|
||||
assert ctx["decision_metadata"] is not None
|
||||
assert ctx["decision_metadata"]["scenario"] == "Loan approval A"
|
||||
assert "related_decisions" in ctx
|
||||
assert isinstance(ctx["related_decisions"], list)
|
||||
|
||||
def test_build_decision_context_faiss_related_decisions_populated(self):
|
||||
"""
|
||||
With two stored vectors, the context for either one must list the other
|
||||
as a related decision (since FAISS search will return both and we filter
|
||||
out the query id itself).
|
||||
"""
|
||||
vs, decision_id_a, decision_id_b = self._make_faiss_store()
|
||||
|
||||
ctx = vs.build_decision_context(decision_id=decision_id_a, depth=1)
|
||||
|
||||
related_ids = [r["id"] for r in ctx["related_decisions"]]
|
||||
assert decision_id_a not in related_ids, (
|
||||
"The query decision itself must be excluded from related_decisions"
|
||||
)
|
||||
assert decision_id_b in related_ids, (
|
||||
"The other stored decision must appear as a related decision"
|
||||
)
|
||||
# Structural shape check
|
||||
for r in ctx["related_decisions"]:
|
||||
assert "id" in r
|
||||
assert "similarity" in r
|
||||
assert "metadata" in r
|
||||
|
||||
def test_build_decision_context_faiss_missing_id_raises_value_error(self):
|
||||
"""
|
||||
A completely unknown decision_id must raise ValueError (not AttributeError).
|
||||
"""
|
||||
vs, _a, _b = self._make_faiss_store()
|
||||
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
vs.build_decision_context(decision_id="totally_unknown_id")
|
||||
|
||||
def test_explain_decision_faiss_no_attribute_error(self):
|
||||
"""
|
||||
Regression (issue #848): explain_decision(include_paths=True) must NOT
|
||||
raise AttributeError: 'VectorStore' object has no attribute 'vectors'
|
||||
when the FAISS backend is active.
|
||||
"""
|
||||
vs, decision_id_a, _decision_id_b = self._make_faiss_store()
|
||||
|
||||
# Before the fix this raised AttributeError at `if decision_id in self.vectors`
|
||||
explanation = vs.explain_decision(
|
||||
decision_id=decision_id_a,
|
||||
include_paths=True,
|
||||
include_confidence=True,
|
||||
include_weights=True,
|
||||
)
|
||||
|
||||
assert explanation["decision_id"] == decision_id_a
|
||||
assert explanation["scenario"] == "Loan approval A"
|
||||
assert explanation["outcome"] == "approved"
|
||||
assert "confidence" in explanation
|
||||
assert "semantic_weight" in explanation
|
||||
assert "structural_weight" in explanation
|
||||
assert "similar_decisions" in explanation
|
||||
assert isinstance(explanation["similar_decisions"], list)
|
||||
|
||||
def test_explain_decision_faiss_without_paths_no_similar_decisions_key(self):
|
||||
"""
|
||||
include_paths=False must not populate 'similar_decisions' — regardless
|
||||
of backend.
|
||||
"""
|
||||
vs, decision_id_a, _decision_id_b = self._make_faiss_store()
|
||||
|
||||
explanation = vs.explain_decision(
|
||||
decision_id=decision_id_a,
|
||||
include_paths=False,
|
||||
)
|
||||
|
||||
assert "similar_decisions" not in explanation
|
||||
|
||||
def test_explain_decision_faiss_missing_id_raises_value_error(self):
|
||||
"""
|
||||
A completely unknown decision_id must raise ValueError (not AttributeError).
|
||||
"""
|
||||
vs, _a, _b = self._make_faiss_store()
|
||||
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
vs.explain_decision(decision_id="totally_unknown_id", include_paths=True)
|
||||
|
||||
|
||||
class TestFilterByMetadataBackendBehavior:
|
||||
"""
|
||||
Requirement (issue #848 follow-up): verify the chosen behavior of
|
||||
_filter_by_metadata when a non-inmemory backend is active.
|
||||
|
||||
The decision: raise NotImplementedError (matching get_vector / get_metadata
|
||||
from #843) rather than silently returning [].
|
||||
|
||||
Rationale documented in the production comment:
|
||||
- Zero backend wrappers implement filter_by_metadata(filters, limit).
|
||||
- The only codebase hit (HybridSearch.filter_by_metadata) has a completely
|
||||
different signature and is never stored in _backend_store.
|
||||
- Returning [] would make filter_decisions(query=None, category="loan")
|
||||
report "zero matches" when the truth is "capability not available" —
|
||||
indistinguishable from a real empty result and therefore wrong.
|
||||
"""
|
||||
|
||||
def _make_faiss_store(self):
|
||||
"""FAISS store with two stored decisions — same factory as the rest of
|
||||
this fix cluster."""
|
||||
pytest.importorskip("faiss")
|
||||
vs = VectorStore(backend="faiss", config={"dimension": 4})
|
||||
vs.embedder = None
|
||||
|
||||
ids = vs.store_vectors(
|
||||
[
|
||||
np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float32),
|
||||
np.array([0.0, 1.0, 0.0, 0.0], dtype=np.float32),
|
||||
],
|
||||
metadata=[
|
||||
{"scenario": "Loan A", "category": "loan", "outcome": "approved",
|
||||
"confidence": 0.9, "entities": [], "reasoning": ""},
|
||||
{"scenario": "Loan B", "category": "loan", "outcome": "denied",
|
||||
"confidence": 0.6, "entities": [], "reasoning": ""},
|
||||
],
|
||||
)
|
||||
return vs, ids
|
||||
|
||||
# ── FAISS backend: NotImplementedError, not AttributeError, not [] ── #
|
||||
|
||||
def test_filter_by_metadata_faiss_raises_not_implemented(self):
|
||||
"""
|
||||
filter_decisions(query=None, category='loan') on a FAISS-backed store
|
||||
must raise NotImplementedError, not AttributeError (old crash) and not
|
||||
silently return [] (the wrong silent-failure fix).
|
||||
|
||||
This test pins the chosen behavior: explicit NotImplementedError matching
|
||||
the get_vector/get_metadata precedent set by issue #843.
|
||||
"""
|
||||
vs, _ids = self._make_faiss_store()
|
||||
|
||||
with pytest.raises(NotImplementedError) as exc_info:
|
||||
vs.filter_decisions(query=None, category="loan")
|
||||
|
||||
# Message must name the backend and point to the correct alternative
|
||||
msg = str(exc_info.value)
|
||||
assert "FAISSStore" in msg, (
|
||||
f"Error message should name the backend class, got: {msg!r}"
|
||||
)
|
||||
assert "filter_decisions" in msg or "filter_by_metadata" in msg, (
|
||||
f"Error message should mention the failing method, got: {msg!r}"
|
||||
)
|
||||
assert "search_decisions" in msg, (
|
||||
f"Error message should suggest search_decisions() as the alternative, "
|
||||
f"got: {msg!r}"
|
||||
)
|
||||
|
||||
def test_filter_by_metadata_faiss_not_attribute_error(self):
|
||||
"""
|
||||
Regression guard: the old code raised AttributeError because self.metadata
|
||||
does not exist for non-inmemory backends. This must never happen again.
|
||||
"""
|
||||
vs, _ids = self._make_faiss_store()
|
||||
|
||||
try:
|
||||
vs.filter_decisions(query=None, category="loan")
|
||||
except NotImplementedError:
|
||||
pass # correct — this is what we want
|
||||
except AttributeError as exc:
|
||||
pytest.fail(
|
||||
f"Got AttributeError instead of NotImplementedError: {exc}"
|
||||
)
|
||||
|
||||
def test_filter_by_metadata_faiss_not_silent_empty_list(self):
|
||||
"""
|
||||
The wrong fix would have silently returned []. Confirm the FAISS path
|
||||
raises rather than returning an empty list that the caller cannot
|
||||
distinguish from 'zero decisions matched'.
|
||||
"""
|
||||
vs, _ids = self._make_faiss_store()
|
||||
|
||||
result_was_empty_list = False
|
||||
try:
|
||||
result = vs.filter_decisions(query=None, category="loan")
|
||||
result_was_empty_list = (result == [])
|
||||
except NotImplementedError:
|
||||
pass # correct
|
||||
|
||||
assert not result_was_empty_list, (
|
||||
"_filter_by_metadata must not silently return [] for a non-inmemory "
|
||||
"backend — it must raise NotImplementedError so callers cannot "
|
||||
"mistake 'backend unsupported' for 'no matching decisions'."
|
||||
)
|
||||
|
||||
# ── inmemory backend: existing iteration still works ── #
|
||||
|
||||
def test_filter_by_metadata_inmemory_still_works(self):
|
||||
"""
|
||||
Control: inmemory backend must not be affected by the FAISS guard.
|
||||
filter_decisions(query=None, category='loan') must return the two
|
||||
loan decisions that were stored and not raise anything.
|
||||
"""
|
||||
vs = VectorStore(backend="inmemory", config={"dimension": 4})
|
||||
vs.embedder = None
|
||||
|
||||
vs.store_vectors(
|
||||
[
|
||||
np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float32),
|
||||
np.array([0.0, 1.0, 0.0, 0.0], dtype=np.float32),
|
||||
np.array([0.0, 0.0, 1.0, 0.0], dtype=np.float32),
|
||||
],
|
||||
metadata=[
|
||||
{"scenario": "Loan A", "category": "loan", "outcome": "approved",
|
||||
"confidence": 0.9, "entities": [], "reasoning": ""},
|
||||
{"scenario": "Loan B", "category": "loan", "outcome": "denied",
|
||||
"confidence": 0.6, "entities": [], "reasoning": ""},
|
||||
{"scenario": "Credit card", "category": "credit", "outcome": "approved",
|
||||
"confidence": 0.8, "entities": [], "reasoning": ""},
|
||||
],
|
||||
)
|
||||
|
||||
results = vs.filter_decisions(query=None, category="loan")
|
||||
|
||||
assert isinstance(results, list)
|
||||
assert len(results) == 2, (
|
||||
f"Expected 2 loan decisions, got {len(results)}: {results}"
|
||||
)
|
||||
for r in results:
|
||||
assert r["metadata"]["category"] == "loan"
|
||||
|
||||
|
||||
class TestVectorRetrievalFailureBehavior:
|
||||
"""
|
||||
Requirement: verify that when get_vector() returns None for an existing decision
|
||||
(e.g., FAISS without reconstruct capability), we don't silently drop similarity
|
||||
enrichment. We should get a warning log and an explicit marker in the output.
|
||||
"""
|
||||
|
||||
class _StubBackend:
|
||||
def __init__(self):
|
||||
self.metadata = {
|
||||
"decision_1": {
|
||||
"scenario": "Stub Scenario",
|
||||
"reasoning": "Stub Reasoning",
|
||||
"outcome": "approved"
|
||||
}
|
||||
}
|
||||
|
||||
def get_metadata(self, vector_id: str):
|
||||
return self.metadata.get(vector_id)
|
||||
|
||||
def get_vector(self, vector_id: str):
|
||||
# Explicitly return None to simulate a backend that cannot reconstruct vectors
|
||||
return None
|
||||
|
||||
def _make_stub_store(self):
|
||||
vs = VectorStore(backend="inmemory", config={"dimension": 4})
|
||||
vs.embedder = None
|
||||
# Replace the backend store with our stub
|
||||
vs._backend_store = self._StubBackend()
|
||||
vs.backend = "stub"
|
||||
return vs
|
||||
|
||||
def test_build_decision_context_warns_and_marks_on_missing_vector(self, caplog):
|
||||
"""
|
||||
When get_vector() returns None for an existing decision, build_decision_context
|
||||
should warn and add 'similarity_unavailable': True to the context.
|
||||
"""
|
||||
import logging
|
||||
vs = self._make_stub_store()
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="semantica.vector_store"):
|
||||
ctx = vs.build_decision_context(decision_id="decision_1", depth=1)
|
||||
|
||||
assert ctx["decision_id"] == "decision_1"
|
||||
assert ctx.get("similarity_unavailable") is True, "Expected similarity_unavailable marker"
|
||||
assert isinstance(ctx["related_decisions"], list), "related_decisions must be a list for schema stability"
|
||||
assert len(ctx["related_decisions"]) == 0
|
||||
|
||||
warning_logged = any(
|
||||
"decision_1" in record.message and record.levelname == "WARNING"
|
||||
for record in caplog.records
|
||||
)
|
||||
assert warning_logged, f"Expected WARNING log mentioning decision_1; got: {caplog.records}"
|
||||
|
||||
def test_explain_decision_warns_and_marks_on_missing_vector(self, caplog):
|
||||
"""
|
||||
When get_vector() returns None for an existing decision, explain_decision
|
||||
with include_paths=True should warn and add 'similarity_unavailable': True.
|
||||
"""
|
||||
import logging
|
||||
vs = self._make_stub_store()
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="semantica.vector_store"):
|
||||
explanation = vs.explain_decision(
|
||||
decision_id="decision_1",
|
||||
include_paths=True,
|
||||
)
|
||||
|
||||
assert explanation["decision_id"] == "decision_1"
|
||||
assert explanation.get("similarity_unavailable") is True, "Expected similarity_unavailable marker"
|
||||
assert isinstance(explanation["similar_decisions"], list), "similar_decisions must be a list for schema stability"
|
||||
assert len(explanation["similar_decisions"]) == 0
|
||||
|
||||
warning_logged = any(
|
||||
"decision_1" in record.message and record.levelname == "WARNING"
|
||||
for record in caplog.records
|
||||
)
|
||||
assert warning_logged, f"Expected WARNING log mentioning decision_1; got: {caplog.records}"
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from semantica.vector_store.faiss_store import FAISSIndex
|
||||
|
||||
|
||||
def test_get_vector_reconstructs_from_flat_l2_index():
|
||||
faiss = pytest.importorskip("faiss")
|
||||
vectors = np.array(
|
||||
[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]],
|
||||
dtype=np.float32,
|
||||
)
|
||||
index = FAISSIndex(faiss.IndexFlatL2(3), dimension=3)
|
||||
index.add_vectors(vectors, ids=["vec_first", "vec_target"])
|
||||
|
||||
result = index.get_vector("vec_target")
|
||||
|
||||
np.testing.assert_array_equal(result, vectors[1])
|
||||
|
||||
|
||||
def test_get_vector_reconstructs_vector_at_matching_id_position():
|
||||
backend_index = MagicMock()
|
||||
backend_index.reconstruct.return_value = [0.25, 0.5, 0.75]
|
||||
index = FAISSIndex(backend_index, dimension=3)
|
||||
index.vector_ids = ["vec_first", "vec_target"]
|
||||
|
||||
result = index.get_vector("vec_target")
|
||||
|
||||
backend_index.reconstruct.assert_called_once_with(1)
|
||||
np.testing.assert_array_equal(result, np.array([0.25, 0.5, 0.75], dtype=np.float32))
|
||||
|
||||
|
||||
def test_get_vector_returns_none_for_unknown_id_without_reconstructing():
|
||||
backend_index = MagicMock()
|
||||
index = FAISSIndex(backend_index, dimension=3)
|
||||
index.vector_ids = ["vec_known"]
|
||||
|
||||
assert index.get_vector("vec_missing") is None
|
||||
backend_index.reconstruct.assert_not_called()
|
||||
|
||||
|
||||
def test_get_vector_returns_none_when_index_has_no_reconstruct_method():
|
||||
index = FAISSIndex(object(), dimension=3)
|
||||
index.vector_ids = ["vec_known"]
|
||||
|
||||
assert index.get_vector("vec_known") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
NotImplementedError(),
|
||||
RuntimeError("reconstruct not implemented for this type of index"),
|
||||
RuntimeError("reconstruct_from_offset not implemented"),
|
||||
],
|
||||
)
|
||||
def test_get_vector_returns_none_when_reconstruction_is_unsupported(error):
|
||||
backend_index = MagicMock()
|
||||
backend_index.reconstruct.side_effect = error
|
||||
index = FAISSIndex(backend_index, dimension=3)
|
||||
index.vector_ids = ["vec_known"]
|
||||
|
||||
assert index.get_vector("vec_known") is None
|
||||
|
||||
|
||||
def test_get_vector_propagates_unexpected_runtime_errors():
|
||||
backend_index = MagicMock()
|
||||
runtime_error = RuntimeError("index is not trained")
|
||||
backend_index.reconstruct.side_effect = runtime_error
|
||||
index = FAISSIndex(backend_index, dimension=3)
|
||||
index.vector_ids = ["vec_known"]
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
index.get_vector("vec_known")
|
||||
|
||||
assert exc_info.value is runtime_error
|
||||
|
||||
|
||||
def test_get_vector_builds_direct_map_and_retries_when_not_initialized():
|
||||
backend_index = MagicMock()
|
||||
backend_index.reconstruct.side_effect = [
|
||||
RuntimeError("direct map not initialized"),
|
||||
[0.25, 0.5, 0.75],
|
||||
]
|
||||
index = FAISSIndex(backend_index, dimension=3)
|
||||
index.vector_ids = ["vec_known"]
|
||||
|
||||
result = index.get_vector("vec_known")
|
||||
|
||||
backend_index.make_direct_map.assert_called_once_with()
|
||||
assert backend_index.reconstruct.call_count == 2
|
||||
np.testing.assert_array_equal(result, np.array([0.25, 0.5, 0.75], dtype=np.float32))
|
||||
|
||||
|
||||
def test_get_vector_returns_none_when_direct_map_unavailable_and_not_initialized():
|
||||
backend_index = MagicMock(spec=["reconstruct"])
|
||||
backend_index.reconstruct.side_effect = RuntimeError("direct map not initialized")
|
||||
index = FAISSIndex(backend_index, dimension=3)
|
||||
index.vector_ids = ["vec_known"]
|
||||
|
||||
assert index.get_vector("vec_known") is None
|
||||
|
||||
|
||||
def test_get_vector_reconstructs_from_real_ivfflat_index_without_prior_direct_map():
|
||||
faiss = pytest.importorskip("faiss")
|
||||
dimension = 3
|
||||
vectors = np.array(
|
||||
[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9], [1.0, 1.1, 1.2]],
|
||||
dtype=np.float32,
|
||||
)
|
||||
quantizer = faiss.IndexFlatL2(dimension)
|
||||
backend_index = faiss.IndexIVFFlat(quantizer, dimension, 2)
|
||||
backend_index.train(vectors)
|
||||
|
||||
index = FAISSIndex(backend_index, dimension=dimension)
|
||||
index.add_vectors(vectors, ids=["vec_0", "vec_1", "vec_2", "vec_target"])
|
||||
|
||||
result = index.get_vector("vec_target")
|
||||
|
||||
np.testing.assert_allclose(result, vectors[3], atol=1e-6)
|
||||
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
Test: Search Result Schema Compliance -- Issue #845
|
||||
|
||||
Every backend wrapper's search method must return a list of dicts that each
|
||||
contain the four required canonical fields:
|
||||
|
||||
id : str | int
|
||||
score : float
|
||||
metadata : dict (always a dict, {} when none stored)
|
||||
vector : any (np.ndarray | None; None when backend doesn't return vectors)
|
||||
distance : float | None (preserved from FAISS, Weaviate, Milvus; None elsewhere)
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _assert_canonical_schema(test_case, results):
|
||||
"""Assert every result satisfies the #845 canonical schema."""
|
||||
test_case.assertIsInstance(results, list)
|
||||
for r in results:
|
||||
test_case.assertIsInstance(r, dict, "result must be a dict")
|
||||
test_case.assertIn("id", r, "result must contain 'id'")
|
||||
test_case.assertTrue(isinstance(r["id"], (str, int)), "'id' must be a str or int")
|
||||
test_case.assertIn("score", r, "result must contain 'score'")
|
||||
test_case.assertIn("metadata", r, "result must contain 'metadata'")
|
||||
test_case.assertIn("vector", r, "result must contain 'vector'")
|
||||
test_case.assertIn("distance", r, "result must contain 'distance'")
|
||||
test_case.assertIsInstance(r["metadata"], dict, "'metadata' must be a dict")
|
||||
test_case.assertIsInstance(r["score"], float, "'score' must be a float")
|
||||
test_case.assertTrue(r["distance"] is None or isinstance(r["distance"], float), "'distance' must be float or None")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory backend (no mocking needed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInMemorySearchSchema(unittest.TestCase):
|
||||
|
||||
def test_search_vectors_canonical_schema(self):
|
||||
"""In-memory VectorStore.search_vectors() returns canonical schema."""
|
||||
from semantica.vector_store import VectorStore
|
||||
|
||||
store = VectorStore(backend="inmemory", dimension=4)
|
||||
vectors = [np.array([0.1, 0.2, 0.3, 0.4]), np.array([0.5, 0.6, 0.7, 0.8])]
|
||||
metadata = [{"type": "a"}, {"type": "b"}]
|
||||
store.store_vectors(vectors, metadata)
|
||||
|
||||
results = store.search_vectors(np.array([0.15, 0.25, 0.35, 0.45]), k=2)
|
||||
_assert_canonical_schema(self, results)
|
||||
self.assertEqual(results[0]["metadata"]["type"], "a")
|
||||
|
||||
def test_search_vectors_no_metadata_gives_empty_dict(self):
|
||||
"""In-memory results have metadata={} when no metadata was stored."""
|
||||
from semantica.vector_store import VectorStore
|
||||
|
||||
store = VectorStore(backend="inmemory", dimension=4)
|
||||
store.store_vectors([np.array([0.1, 0.2, 0.3, 0.4])])
|
||||
|
||||
results = store.search_vectors(np.array([0.1, 0.2, 0.3, 0.4]), k=1)
|
||||
_assert_canonical_schema(self, results)
|
||||
self.assertEqual(results[0]["metadata"], {})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FAISS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFAISSSearchSchema(unittest.TestCase):
|
||||
|
||||
@patch("semantica.vector_store.faiss_store.FAISS_AVAILABLE", True)
|
||||
@patch("semantica.vector_store.faiss_store.faiss")
|
||||
def test_faiss_search_similar_canonical_schema(self, mock_faiss):
|
||||
from semantica.vector_store.faiss_store import FAISSIndex, FAISSSearch
|
||||
|
||||
mock_index = MagicMock()
|
||||
mock_index.search.return_value = (
|
||||
np.array([[0.05, 0.2]], dtype=np.float32),
|
||||
np.array([[0, 1]]),
|
||||
)
|
||||
|
||||
idx = FAISSIndex(mock_index, dimension=4)
|
||||
idx.vector_ids = ["vec_0", "vec_1"]
|
||||
idx.metadata = {"vec_0": {"k": "v"}, "vec_1": {}}
|
||||
|
||||
searcher = FAISSSearch(idx)
|
||||
results = searcher.search_similar(np.array([0.1, 0.2, 0.3, 0.4], dtype=np.float32), k=2)
|
||||
|
||||
_assert_canonical_schema(self, results)
|
||||
self.assertIn("distance", results[0])
|
||||
self.assertIsNone(results[0]["vector"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Qdrant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQdrantSearchSchema(unittest.TestCase):
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_qdrant_search_points_canonical_schema(self):
|
||||
from semantica.vector_store.qdrant_store import QdrantCollection
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_hit = MagicMock()
|
||||
mock_hit.id = "q_1"
|
||||
mock_hit.score = 0.88
|
||||
mock_hit.payload = {"category": "x"}
|
||||
mock_client.search.return_value = [mock_hit]
|
||||
|
||||
coll = QdrantCollection(mock_client, "test_col")
|
||||
results = coll.search_points(np.array([0.1, 0.2]), limit=1)
|
||||
|
||||
_assert_canonical_schema(self, results)
|
||||
self.assertIsNone(results[0]["vector"])
|
||||
self.assertEqual(results[0]["metadata"], {"category": "x"})
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_qdrant_unbounded_dot_product_scores_preserve_ranking(self):
|
||||
"""Qdrant's Dot distance metric is unbounded; normalized scores must
|
||||
stay strictly ordered instead of collapsing once raw score >= 1.0
|
||||
(regression for #845 follow-up)."""
|
||||
from semantica.vector_store.qdrant_store import QdrantCollection
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_hit_high = MagicMock(id="q_hi", score=50.0, payload={})
|
||||
mock_hit_mid = MagicMock(id="q_mid", score=2.0, payload={})
|
||||
mock_hit_low = MagicMock(id="q_lo", score=1.0, payload={})
|
||||
mock_client.search.return_value = [mock_hit_high, mock_hit_mid, mock_hit_low]
|
||||
|
||||
coll = QdrantCollection(mock_client, "test_col")
|
||||
results = coll.search_points(np.array([0.1, 0.2]), limit=3)
|
||||
|
||||
_assert_canonical_schema(self, results)
|
||||
scores = [r["score"] for r in results]
|
||||
self.assertEqual(len(set(scores)), 3, "scores >= 1.0 must not collapse")
|
||||
self.assertGreater(scores[0], scores[1])
|
||||
self.assertGreater(scores[1], scores[2])
|
||||
self.assertTrue(all(0.0 < s < 1.0 for s in scores))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pinecone
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPineconeSearchSchema(unittest.TestCase):
|
||||
|
||||
@patch("semantica.vector_store.pinecone_store.PINECONE_AVAILABLE", True)
|
||||
def test_pinecone_search_vectors_canonical_schema(self):
|
||||
from semantica.vector_store.pinecone_store import PineconeIndex
|
||||
|
||||
mock_index = MagicMock()
|
||||
mock_match = MagicMock()
|
||||
mock_match.id = "p_1"
|
||||
mock_match.score = 0.95
|
||||
mock_match.metadata = {"source": "web"}
|
||||
mock_index.query.return_value = MagicMock(matches=[mock_match])
|
||||
|
||||
pi = PineconeIndex(mock_index)
|
||||
results = pi.search_vectors([0.1, 0.2], k=1)
|
||||
|
||||
_assert_canonical_schema(self, results)
|
||||
self.assertIsNone(results[0]["vector"])
|
||||
self.assertEqual(results[0]["metadata"], {"source": "web"})
|
||||
|
||||
@patch("semantica.vector_store.pinecone_store.PINECONE_AVAILABLE", True)
|
||||
def test_pinecone_none_metadata_becomes_empty_dict(self):
|
||||
from semantica.vector_store.pinecone_store import PineconeIndex
|
||||
|
||||
mock_index = MagicMock()
|
||||
mock_match = MagicMock()
|
||||
mock_match.id = "p_2"
|
||||
mock_match.score = 0.7
|
||||
mock_match.metadata = None
|
||||
mock_index.query.return_value = MagicMock(matches=[mock_match])
|
||||
|
||||
pi = PineconeIndex(mock_index)
|
||||
results = pi.search_vectors([0.1, 0.2], k=1)
|
||||
|
||||
_assert_canonical_schema(self, results)
|
||||
self.assertEqual(results[0]["metadata"], {})
|
||||
|
||||
@patch("semantica.vector_store.pinecone_store.PINECONE_AVAILABLE", True)
|
||||
def test_pinecone_unbounded_dotproduct_scores_preserve_ranking(self):
|
||||
"""Pinecone's dotproduct metric is unbounded; normalized scores must
|
||||
stay strictly ordered instead of collapsing once raw score >= 1.0
|
||||
(regression for #845 follow-up)."""
|
||||
from semantica.vector_store.pinecone_store import PineconeIndex
|
||||
|
||||
mock_index = MagicMock()
|
||||
mock_match_high = MagicMock(id="p_hi", score=50.0, metadata={})
|
||||
mock_match_mid = MagicMock(id="p_mid", score=2.0, metadata={})
|
||||
mock_match_low = MagicMock(id="p_lo", score=1.0, metadata={})
|
||||
mock_index.query.return_value = MagicMock(
|
||||
matches=[mock_match_high, mock_match_mid, mock_match_low]
|
||||
)
|
||||
|
||||
pi = PineconeIndex(mock_index)
|
||||
results = pi.search_vectors([0.1, 0.2], k=3)
|
||||
|
||||
_assert_canonical_schema(self, results)
|
||||
scores = [r["score"] for r in results]
|
||||
self.assertEqual(len(set(scores)), 3, "scores >= 1.0 must not collapse")
|
||||
self.assertGreater(scores[0], scores[1])
|
||||
self.assertGreater(scores[1], scores[2])
|
||||
self.assertTrue(all(0.0 < s < 1.0 for s in scores))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Milvus
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMilvusSearchSchema(unittest.TestCase):
|
||||
|
||||
@patch("semantica.vector_store.milvus_store.MILVUS_AVAILABLE", True)
|
||||
def test_milvus_search_canonical_schema(self):
|
||||
from semantica.vector_store.milvus_store import MilvusCollection
|
||||
|
||||
mock_collection = MagicMock()
|
||||
mock_hit = MagicMock()
|
||||
mock_hit.id = 42
|
||||
mock_hit.distance = 0.15
|
||||
|
||||
mock_collection.search.return_value = [[mock_hit]]
|
||||
|
||||
mc = MilvusCollection.__new__(MilvusCollection)
|
||||
mc.collection = mock_collection
|
||||
mc.logger = MagicMock()
|
||||
|
||||
results = mc.search(
|
||||
vectors=[np.array([0.1, 0.2])],
|
||||
anns_field="vector",
|
||||
param={"metric_type": "L2", "params": {"nprobe": 10}},
|
||||
limit=1,
|
||||
)
|
||||
|
||||
_assert_canonical_schema(self, results)
|
||||
self.assertEqual(results[0]["metadata"], {})
|
||||
self.assertIsNone(results[0]["vector"])
|
||||
self.assertIn("distance", results[0])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Weaviate (direct WeaviateQuery)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWeaviateSearchSchema(unittest.TestCase):
|
||||
|
||||
@patch("semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE", True)
|
||||
@patch("semantica.vector_store.weaviate_store.MetadataQuery")
|
||||
def test_weaviate_similarity_search_canonical_schema(self, mock_mq):
|
||||
from semantica.vector_store.weaviate_store import WeaviateQuery
|
||||
|
||||
mock_obj = MagicMock()
|
||||
mock_obj.uuid = "weaviate-uuid-1"
|
||||
mock_obj.properties = {"text": "hello", "category": "docs"}
|
||||
mock_obj.metadata.distance = 0.12
|
||||
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.query.near_vector.return_value = MagicMock(objects=[mock_obj])
|
||||
|
||||
wq = WeaviateQuery(mock_collection)
|
||||
results = wq.similarity_search(np.array([0.1, 0.2]), limit=1)
|
||||
|
||||
_assert_canonical_schema(self, results)
|
||||
self.assertNotIn("properties", results[0])
|
||||
self.assertEqual(results[0]["metadata"]["text"], "hello")
|
||||
self.assertIsNone(results[0]["vector"])
|
||||
self.assertIn("distance", results[0])
|
||||
self.assertAlmostEqual(results[0]["distance"], 0.12)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SearchResult TypedDict is importable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSearchResultTypeImport(unittest.TestCase):
|
||||
|
||||
def test_search_result_importable_from_package(self):
|
||||
from semantica.vector_store import SearchResult
|
||||
self.assertTrue(callable(SearchResult))
|
||||
|
||||
def test_search_result_importable_from_module(self):
|
||||
from semantica.vector_store.vector_store import SearchResult
|
||||
self.assertTrue(callable(SearchResult))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -195,6 +195,9 @@ class TestVectorStoreDeepDive(unittest.TestCase):
|
||||
mock_collection_instance.search.assert_called()
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]["id"], 1)
|
||||
self.assertIn("metadata", results[0])
|
||||
self.assertIsInstance(results[0]["metadata"], dict)
|
||||
self.assertIn("vector", results[0])
|
||||
|
||||
@patch('semantica.vector_store.qdrant_store.QdrantClientLib')
|
||||
@patch('semantica.vector_store.qdrant_store.VectorParams')
|
||||
|
||||
Reference in New Issue
Block a user