diff --git a/CHANGELOG.md b/CHANGELOG.md
index 244b45fc..a77ec2f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 985b6baf..745c54e4 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
+
+
### Graph-Native Infrastructure for Context and Accountable AI Systems
#### *The Open Source Palantir for AI Agents*
diff --git a/explorer/package-lock.json b/explorer/package-lock.json
index 0bf6a67a..98e9bc17 100644
--- a/explorer/package-lock.json
+++ b/explorer/package-lock.json
@@ -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": {
diff --git a/semantica/vector_store/__init__.py b/semantica/vector_store/__init__.py
index 194ef4bd..45b617b4 100644
--- a/semantica/vector_store/__init__.py
+++ b/semantica/vector_store/__init__.py
@@ -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",
diff --git a/semantica/vector_store/faiss_store.py b/semantica/vector_store/faiss_store.py
index 3e3bec4d..2e39266f 100644
--- a/semantica/vector_store/faiss_store.py
+++ b/semantica/vector_store/faiss_store.py
@@ -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,
}
)
diff --git a/semantica/vector_store/milvus_store.py b/semantica/vector_store/milvus_store.py
index 9ac98faf..2a213859 100644
--- a/semantica/vector_store/milvus_store.py
+++ b/semantica/vector_store/milvus_store.py
@@ -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)
diff --git a/semantica/vector_store/pgvector_store.py b/semantica/vector_store/pgvector_store.py
index cfc112fd..b171ff48 100644
--- a/semantica/vector_store/pgvector_store.py
+++ b/semantica/vector_store/pgvector_store.py
@@ -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
diff --git a/semantica/vector_store/pinecone_store.py b/semantica/vector_store/pinecone_store.py
index 13c04d53..f58f61dc 100644
--- a/semantica/vector_store/pinecone_store.py
+++ b/semantica/vector_store/pinecone_store.py
@@ -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,
}
)
diff --git a/semantica/vector_store/qdrant_store.py b/semantica/vector_store/qdrant_store.py
index 318aa973..d9624842 100644
--- a/semantica/vector_store/qdrant_store.py
+++ b/semantica/vector_store/qdrant_store.py
@@ -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,
}
)
diff --git a/semantica/vector_store/sqlite_vec_store.py b/semantica/vector_store/sqlite_vec_store.py
index fc6dc0f0..646353ec 100644
--- a/semantica/vector_store/sqlite_vec_store.py
+++ b/semantica/vector_store/sqlite_vec_store.py
@@ -414,6 +414,8 @@ class SQLiteVecStore:
"id": vec_id,
"score": similarity,
"metadata": json.loads(meta_json) if meta_json else {},
+ "vector": None,
+ "distance": None,
}
)
diff --git a/semantica/vector_store/vector_store.py b/semantica/vector_store/vector_store.py
index 239f958f..a45d40ef 100644
--- a/semantica/vector_store/vector_store.py
+++ b/semantica/vector_store/vector_store.py
@@ -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,
}
)
diff --git a/semantica/vector_store/weaviate_store.py b/semantica/vector_store/weaviate_store.py
index b5551221..63f30475 100644
--- a/semantica/vector_store/weaviate_store.py
+++ b/semantica/vector_store/weaviate_store.py
@@ -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,
}
)
diff --git a/tests/vector_store/test_backward_compatibility.py b/tests/vector_store/test_backward_compatibility.py
index 29098ba0..a4d8766a 100644
--- a/tests/vector_store/test_backward_compatibility.py
+++ b/tests/vector_store/test_backward_compatibility.py
@@ -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."""
diff --git a/tests/vector_store/test_decision_embedding_pipeline.py b/tests/vector_store/test_decision_embedding_pipeline.py
index 17d47ddc..ed65236d 100644
--- a/tests/vector_store/test_decision_embedding_pipeline.py
+++ b/tests/vector_store/test_decision_embedding_pipeline.py
@@ -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}"
diff --git a/tests/vector_store/test_faiss_index.py b/tests/vector_store/test_faiss_index.py
new file mode 100644
index 00000000..b00052d2
--- /dev/null
+++ b/tests/vector_store/test_faiss_index.py
@@ -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)
diff --git a/tests/vector_store/test_search_result_schema.py b/tests/vector_store/test_search_result_schema.py
new file mode 100644
index 00000000..cdae2337
--- /dev/null
+++ b/tests/vector_store/test_search_result_schema.py
@@ -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()
diff --git a/tests/vector_store/test_vector_store_deepdive.py b/tests/vector_store/test_vector_store_deepdive.py
index 3d8d70be..ed31e5d3 100644
--- a/tests/vector_store/test_vector_store_deepdive.py
+++ b/tests/vector_store/test_vector_store_deepdive.py
@@ -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')