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 .qdrant_store import QdrantStore, QdrantClient, QdrantCollection, QdrantSearch
|
||||||
from .registry import MethodRegistry, method_registry
|
from .registry import MethodRegistry, method_registry
|
||||||
from .sqlite_vec_store import SQLiteVecStore
|
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 (
|
from .weaviate_store import (
|
||||||
WeaviateStore,
|
WeaviateStore,
|
||||||
WeaviateClient,
|
WeaviateClient,
|
||||||
@@ -196,6 +196,7 @@ __all__ = [
|
|||||||
"VectorIndexer",
|
"VectorIndexer",
|
||||||
"VectorRetriever",
|
"VectorRetriever",
|
||||||
"VectorManager",
|
"VectorManager",
|
||||||
|
"SearchResult",
|
||||||
# FAISS
|
# FAISS
|
||||||
"FAISSStore",
|
"FAISSStore",
|
||||||
"FAISSIndex",
|
"FAISSIndex",
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ class FAISSSearch:
|
|||||||
"score": similarity_score,
|
"score": similarity_score,
|
||||||
"distance": dist_val,
|
"distance": dist_val,
|
||||||
"metadata": self.index.metadata.get(vector_id, {}),
|
"metadata": self.index.metadata.get(vector_id, {}),
|
||||||
|
"vector": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,12 @@ class MilvusCollection:
|
|||||||
"score": 1.0 - hit.distance
|
"score": 1.0 - hit.distance
|
||||||
if hit.distance <= 1.0
|
if hit.distance <= 1.0
|
||||||
else 1.0 / (1.0 + hit.distance),
|
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)
|
results.append(batch_results)
|
||||||
|
|||||||
@@ -445,6 +445,7 @@ class PgVectorStore:
|
|||||||
"id": vec_id,
|
"id": vec_id,
|
||||||
"score": similarity,
|
"score": similarity,
|
||||||
"metadata": meta if isinstance(meta, dict) else json.loads(meta),
|
"metadata": meta if isinstance(meta, dict) else json.loads(meta),
|
||||||
|
"vector": None,
|
||||||
})
|
})
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ class PineconeIndex:
|
|||||||
"id": match.id,
|
"id": match.id,
|
||||||
"score": match.score,
|
"score": match.score,
|
||||||
"metadata": match.metadata or {},
|
"metadata": match.metadata or {},
|
||||||
|
"vector": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ class QdrantCollection:
|
|||||||
"id": result.id,
|
"id": result.id,
|
||||||
"score": result.score,
|
"score": result.score,
|
||||||
"metadata": result.payload or {},
|
"metadata": result.payload or {},
|
||||||
|
"vector": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -414,6 +414,7 @@ class SQLiteVecStore:
|
|||||||
"id": vec_id,
|
"id": vec_id,
|
||||||
"score": similarity,
|
"score": similarity,
|
||||||
"metadata": json.loads(meta_json) if meta_json else {},
|
"metadata": json.loads(meta_json) if meta_json else {},
|
||||||
|
"vector": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ Author: Semantica Contributors
|
|||||||
License: MIT
|
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 concurrent.futures
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
@@ -78,6 +78,26 @@ from ..embeddings import EmbeddingGenerator
|
|||||||
from .hybrid_similarity import HybridSimilarityCalculator
|
from .hybrid_similarity import HybridSimilarityCalculator
|
||||||
from .decision_embedding_pipeline import DecisionEmbeddingPipeline
|
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:
|
class VectorStore:
|
||||||
"""
|
"""
|
||||||
@@ -634,7 +654,7 @@ class VectorStore:
|
|||||||
|
|
||||||
def search_vectors(
|
def search_vectors(
|
||||||
self, query_vector: np.ndarray, k: int = 10, **options
|
self, query_vector: np.ndarray, k: int = 10, **options
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[SearchResult]:
|
||||||
"""
|
"""
|
||||||
Search for similar vectors.
|
Search for similar vectors.
|
||||||
|
|
||||||
@@ -687,11 +707,13 @@ class VectorStore:
|
|||||||
**options,
|
**options,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add metadata to results if available
|
# Add metadata to results; guarantee the key always exists.
|
||||||
for result in results:
|
for result in results:
|
||||||
vector_id = result.get("id")
|
vector_id = result.get("id")
|
||||||
if vector_id and vector_id in self.metadata:
|
if vector_id and vector_id in self.metadata:
|
||||||
result["metadata"] = self.metadata[vector_id]
|
result["metadata"] = self.metadata[vector_id]
|
||||||
|
elif "metadata" not in result:
|
||||||
|
result["metadata"] = {}
|
||||||
|
|
||||||
self.progress_tracker.stop_tracking(
|
self.progress_tracker.stop_tracking(
|
||||||
tracking_id,
|
tracking_id,
|
||||||
@@ -1276,6 +1298,7 @@ class VectorRetriever:
|
|||||||
"id": ids[idx],
|
"id": ids[idx],
|
||||||
"vector": vectors[idx],
|
"vector": vectors[idx],
|
||||||
"score": float(similarities[idx]),
|
"score": float(similarities[idx]),
|
||||||
|
"metadata": {},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ class WeaviateQuery:
|
|||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
"id": str(obj.uuid),
|
"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,
|
"distance": obj.metadata.distance if obj.metadata else None,
|
||||||
"score": 1.0
|
"score": 1.0
|
||||||
- (
|
- (
|
||||||
@@ -185,6 +185,7 @@ class WeaviateQuery:
|
|||||||
if obj.metadata and obj.metadata.distance
|
if obj.metadata and obj.metadata.distance
|
||||||
else 0.0
|
else 0.0
|
||||||
),
|
),
|
||||||
|
"vector": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ class TestVectorStoreBackwardCompatibility:
|
|||||||
assert all("id" in result for result in results)
|
assert all("id" in result for result in results)
|
||||||
assert all("score" 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("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):
|
def test_search_method_unchanged(self):
|
||||||
"""Test search method unchanged."""
|
"""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()
|
mock_collection_instance.search.assert_called()
|
||||||
self.assertEqual(len(results), 1)
|
self.assertEqual(len(results), 1)
|
||||||
self.assertEqual(results[0]["id"], 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.QdrantClientLib')
|
||||||
@patch('semantica.vector_store.qdrant_store.VectorParams')
|
@patch('semantica.vector_store.qdrant_store.VectorParams')
|
||||||
|
|||||||
Reference in New Issue
Block a user