mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
fix(vector-store): standardize search_vectors output schema
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -149,6 +149,7 @@ class FAISSSearch:
|
||||
"score": similarity_score,
|
||||
"distance": dist_val,
|
||||
"metadata": self.index.metadata.get(vector_id, {}),
|
||||
"vector": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -165,6 +165,12 @@ class MilvusCollection:
|
||||
"score": 1.0 - hit.distance
|
||||
if hit.distance <= 1.0
|
||||
else 1.0 / (1.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)
|
||||
|
||||
@@ -445,6 +445,7 @@ class PgVectorStore:
|
||||
"id": vec_id,
|
||||
"score": similarity,
|
||||
"metadata": meta if isinstance(meta, dict) else json.loads(meta),
|
||||
"vector": None,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
@@ -196,6 +196,7 @@ class PineconeIndex:
|
||||
"id": match.id,
|
||||
"score": match.score,
|
||||
"metadata": match.metadata or {},
|
||||
"vector": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -166,6 +166,7 @@ class QdrantCollection:
|
||||
"id": result.id,
|
||||
"score": result.score,
|
||||
"metadata": result.payload or {},
|
||||
"vector": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -414,6 +414,7 @@ class SQLiteVecStore:
|
||||
"id": vec_id,
|
||||
"score": similarity,
|
||||
"metadata": json.loads(meta_json) if meta_json else {},
|
||||
"vector": 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,26 @@ from ..embeddings import EmbeddingGenerator
|
||||
from .hybrid_similarity import HybridSimilarityCalculator
|
||||
from .decision_embedding_pipeline import DecisionEmbeddingPipeline
|
||||
|
||||
class SearchResult(TypedDict, total=False):
|
||||
"""Canonical schema returned by VectorStore.search_vectors().
|
||||
|
||||
Required fields (always present):
|
||||
id – string identifier of the stored vector.
|
||||
score – float similarity score, higher is better (normalised by each backend).
|
||||
metadata – dict of associated metadata; empty dict when none is stored.
|
||||
vector – np.ndarray when the backend returns the raw vector, otherwise None.
|
||||
|
||||
Optional/preserved fields (may be present depending on backend):
|
||||
distance – raw native distance value preserved for backends that expose it
|
||||
(FAISS L2, Weaviate cosine); None when not available.
|
||||
"""
|
||||
|
||||
id: str
|
||||
score: float
|
||||
metadata: Dict[str, Any]
|
||||
vector: Optional[Any] # np.ndarray | None
|
||||
distance: Optional[float] # preserved when backend exposes it; not required
|
||||
|
||||
|
||||
class VectorStore:
|
||||
"""
|
||||
@@ -634,7 +654,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.
|
||||
|
||||
@@ -687,11 +707,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,
|
||||
@@ -1276,6 +1298,7 @@ class VectorRetriever:
|
||||
"id": ids[idx],
|
||||
"vector": vectors[idx],
|
||||
"score": float(similarities[idx]),
|
||||
"metadata": {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ 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
|
||||
- (
|
||||
@@ -185,6 +185,7 @@ class WeaviateQuery:
|
||||
if obj.metadata and obj.metadata.distance
|
||||
else 0.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."""
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
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
|
||||
score : float
|
||||
metadata : dict (always a dict, {} when none stored)
|
||||
vector : any (np.ndarray | None; None when backend doesn't return vectors)
|
||||
|
||||
Optional preserved fields (may be present):
|
||||
distance : float | None (preserved from FAISS, Weaviate; absent 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.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.assertIsInstance(r["metadata"], dict, "'metadata' must be a dict")
|
||||
test_case.assertIsInstance(r["score"], float, "'score' must be a float")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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"], {})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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