Cinewatch-recommender / serving /faiss_index.py
IntimateUser6969
Deploy CineMatch backend: Two-Tower + DeepFM + MMR + Upstash Redis
1359487
Raw
History Blame Contribute Delete
4.78 kB
"""
FAISS-based Approximate Nearest Neighbour index for candidate retrieval.
Uses IndexFlatIP (exact inner product) for the ~27K movie catalog,
which fits comfortably in memory and gives exact results.
For catalogs >1M, swap to IndexIVFFlat with appropriate nlist.
After L2-normalisation in TwoTowerModel, inner product == cosine similarity.
"""
from __future__ import annotations
import logging
from pathlib import Path
import numpy as np
try:
import faiss
FAISS_AVAILABLE = True
except ImportError:
FAISS_AVAILABLE = False
logging.warning(
"faiss-cpu not installed. Using brute-force numpy search as fallback. "
"Install with: pip install faiss-cpu"
)
logger = logging.getLogger(__name__)
class FAISSIndex:
"""
Wraps a FAISS inner-product index with a consistent interface.
Falls back to exact numpy brute-force search when faiss isn't installed.
"""
def __init__(self):
self._index = None # faiss.Index or None
self._embeddings: np.ndarray | None = None # (num_items, dim) fallback
self.num_items: int = 0
self.embed_dim: int = 0
self._use_faiss = FAISS_AVAILABLE
def build(self, embeddings: np.ndarray) -> "FAISSIndex":
"""
Build the index from a (num_items, dim) float32 array.
Embeddings are assumed L2-normalised (unit norm).
"""
assert embeddings.ndim == 2, "embeddings must be 2-D"
self.num_items, self.embed_dim = embeddings.shape
vecs = embeddings.astype(np.float32, copy=False)
if self._use_faiss:
self._index = faiss.IndexFlatIP(self.embed_dim)
self._index.add(vecs)
logger.info(
f"FAISS IndexFlatIP built: {self._index.ntotal} vectors, dim={self.embed_dim}"
)
else:
# Fallback: store embeddings and use numpy matmul
self._embeddings = vecs
logger.info(
f"Numpy brute-force index built: {self.num_items} vectors, dim={self.embed_dim}"
)
return self
def search(
self, query: np.ndarray, k: int = 200
) -> tuple[np.ndarray, np.ndarray]:
"""
Search for top-k items closest to query.
Parameters
----------
query : (dim,) or (n_queries, dim) float32 array
k : number of results per query
Returns
-------
scores : (n_queries, k) float32
indices : (n_queries, k) int64
"""
query = np.atleast_2d(query).astype(np.float32)
k = min(k, self.num_items)
if self._use_faiss and self._index is not None:
scores, indices = self._index.search(query, k)
elif self._embeddings is not None:
# Brute-force: inner product = dot product (vecs are unit-normed)
sims = query @ self._embeddings.T # (n_q, num_items)
top_k_idx = np.argpartition(-sims, k - 1, axis=1)[:, :k]
top_k_scores = np.take_along_axis(sims, top_k_idx, axis=1)
# Sort within the top-k block
order = np.argsort(-top_k_scores, axis=1)
indices = np.take_along_axis(top_k_idx, order, axis=1)
scores = np.take_along_axis(top_k_scores, order, axis=1)
else:
raise RuntimeError("Index not built. Call build() first.")
return scores, indices
def save(self, path: str | Path) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
if self._use_faiss and self._index is not None:
faiss.write_index(self._index, str(path))
logger.info(f"FAISS index saved → {path}")
elif self._embeddings is not None:
np.save(str(path) + ".npy", self._embeddings)
logger.info(f"Numpy fallback index saved → {path}.npy")
def load(self, path: str | Path) -> "FAISSIndex":
path = Path(path)
if self._use_faiss and path.exists():
self._index = faiss.read_index(str(path))
self.num_items = self._index.ntotal
self.embed_dim = self._index.d
logger.info(
f"FAISS index loaded: {self.num_items} vectors, dim={self.embed_dim}"
)
else:
npy_path = Path(str(path) + ".npy")
if npy_path.exists():
self._embeddings = np.load(str(npy_path))
self.num_items, self.embed_dim = self._embeddings.shape
self._use_faiss = False
logger.info(
f"Numpy fallback index loaded: {self.num_items} vectors"
)
else:
raise FileNotFoundError(f"No index found at {path} or {npy_path}")
return self