| """ |
| 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 |
| self._embeddings: np.ndarray | None = None |
| 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: |
| |
| 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: |
| |
| sims = query @ self._embeddings.T |
| top_k_idx = np.argpartition(-sims, k - 1, axis=1)[:, :k] |
| top_k_scores = np.take_along_axis(sims, top_k_idx, axis=1) |
| |
| 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 |
|
|