autonomousagent / app /memory /vector_store.py
Ayush
feat: Planner-Executor AI agent with autonomous task execution, memory, and weather tool
7d3b88b
Raw
History Blame Contribute Delete
5.68 kB
"""Vector store β€” semantic similarity index for memory retrieval.
Provides fast nearest-neighbour search over embedding vectors.
Backend strategy:
1. FAISS (preferred) β€” fast, production-grade
2. NumPy cosine similarity (fallback) β€” zero extra deps
"""
from __future__ import annotations
import logging
import threading
import numpy as np
logger = logging.getLogger(__name__)
# ── Backend detection ──────────────────────────────────────────────────────
try:
import faiss # type: ignore[import-untyped]
_HAS_FAISS = True
except ImportError:
_HAS_FAISS = False
# ── VectorStore ────────────────────────────────────────────────────────────
class VectorStore:
"""Thread-safe vector index with add / search / remove support.
Uses FAISS when available; falls back to brute-force NumPy cosine
similarity for environments where FAISS cannot be installed.
"""
def __init__(self, dimension: int = 384) -> None:
self._dimension = dimension
self._lock = threading.Lock()
if _HAS_FAISS:
base = faiss.IndexFlatIP(dimension)
self._index: faiss.IndexIDMap = faiss.IndexIDMap(base)
self._backend = "faiss"
else:
self._vectors: dict[int, np.ndarray] = {}
self._backend = "numpy"
logger.info(
"[VECTOR] Initialised (dim=%d, backend=%s)",
dimension, self._backend,
)
# ── Properties ─────────────────────────────────────────────────────
@property
def backend(self) -> str:
"""Return the active backend name."""
return self._backend
@property
def size(self) -> int:
"""Current number of indexed vectors."""
with self._lock:
if self._backend == "faiss":
return self._index.ntotal
return len(self._vectors)
# ── Public API ─────────────────────────────────────────────────────
def add(self, embedding: list[float], entry_id: int) -> None:
"""Index an embedding under the given *entry_id*."""
vec = self._to_unit_vec(embedding)
with self._lock:
if self._backend == "faiss":
ids = np.array([entry_id], dtype=np.int64)
self._index.add_with_ids(vec, ids)
else:
self._vectors[entry_id] = vec.flatten()
logger.debug("[VECTOR] Added entry_id=%d (total=%d)", entry_id, self.size)
def remove(self, entry_id: int) -> None:
"""Remove an embedding by *entry_id*."""
with self._lock:
if self._backend == "faiss":
ids = np.array([entry_id], dtype=np.int64)
try:
self._index.remove_ids(ids)
except Exception as exc:
logger.warning(
"[VECTOR] FAISS remove failed for id=%d: %s",
entry_id, exc,
)
else:
self._vectors.pop(entry_id, None)
logger.debug("[VECTOR] Removed entry_id=%d", entry_id)
def search(
self,
query_embedding: list[float],
top_k: int = 5,
) -> list[tuple[int, float]]:
"""Return the *top_k* most similar entries.
Returns:
List of ``(entry_id, similarity_score)`` sorted descending.
"""
vec = self._to_unit_vec(query_embedding)
with self._lock:
if self._backend == "faiss":
return self._search_faiss(vec, top_k)
return self._search_numpy(vec, top_k)
def clear(self) -> None:
"""Drop all indexed vectors."""
with self._lock:
if self._backend == "faiss":
base = faiss.IndexFlatIP(self._dimension)
self._index = faiss.IndexIDMap(base)
else:
self._vectors.clear()
logger.info("[VECTOR] Index cleared")
# ── Internals ──────────────────────────────────────────────────────
def _to_unit_vec(self, embedding: list[float]) -> np.ndarray:
"""Convert to float32 row vector and L2-normalise."""
vec = np.array(embedding, dtype=np.float32).reshape(1, -1)
norm = np.linalg.norm(vec)
if norm > 0:
vec = vec / norm
return vec
def _search_faiss(
self, query_vec: np.ndarray, top_k: int,
) -> list[tuple[int, float]]:
if self._index.ntotal == 0:
return []
k = min(top_k, self._index.ntotal)
scores, ids = self._index.search(query_vec, k)
return [
(int(eid), float(score))
for score, eid in zip(scores[0], ids[0])
if eid >= 0
]
def _search_numpy(
self, query_vec: np.ndarray, top_k: int,
) -> list[tuple[int, float]]:
if not self._vectors:
return []
q = query_vec.flatten()
scored = [
(eid, float(np.dot(q, vec)))
for eid, vec in self._vectors.items()
]
scored.sort(key=lambda x: x[1], reverse=True)
return scored[:top_k]