194 lines
6.4 KiB
Python
194 lines
6.4 KiB
Python
"""
|
|
FAISS-based Vector Store for Image Embeddings
|
|
Enables fast similarity search over large image collections
|
|
"""
|
|
import os
|
|
import json
|
|
import numpy as np
|
|
import faiss
|
|
from typing import List, Dict, Optional, Tuple
|
|
|
|
|
|
class VectorStore:
|
|
"""
|
|
FAISS-based vector store for efficient similarity search
|
|
|
|
Supports:
|
|
- L2 (Euclidean) distance search
|
|
- Incremental index building
|
|
- Save/load to disk
|
|
- Metadata storage alongside embeddings
|
|
"""
|
|
|
|
def __init__(self, dimension: int = 768, metric: str = "L2"):
|
|
"""
|
|
Initialize vector store
|
|
|
|
Args:
|
|
dimension: Embedding dimension (768 for CLIP ViT-B/16)
|
|
metric: Distance metric ("L2" or "IP" for inner product)
|
|
"""
|
|
self.dimension = dimension
|
|
self.metric = metric
|
|
|
|
# Initialize FAISS index
|
|
if metric == "L2":
|
|
self.index = faiss.IndexFlatL2(dimension)
|
|
elif metric == "IP":
|
|
self.index = faiss.IndexFlatIP(dimension) # Inner product (cosine if normalized)
|
|
else:
|
|
raise ValueError(f"Unknown metric: {metric}. Use 'L2' or 'IP'")
|
|
|
|
# Metadata storage (parallel to FAISS index)
|
|
self.metadata = []
|
|
|
|
def add(self, embeddings: np.ndarray, metadata: Optional[List[Dict]] = None):
|
|
"""
|
|
Add embeddings to the index
|
|
|
|
Args:
|
|
embeddings: Array of shape (N, dimension)
|
|
metadata: List of N metadata dicts (one per embedding)
|
|
"""
|
|
if embeddings.dtype != np.float32:
|
|
embeddings = embeddings.astype(np.float32)
|
|
|
|
# Normalize for inner product (makes it cosine similarity)
|
|
if self.metric == "IP":
|
|
faiss.normalize_L2(embeddings)
|
|
|
|
# Add to FAISS index
|
|
self.index.add(embeddings)
|
|
|
|
# Add metadata
|
|
if metadata:
|
|
assert len(metadata) == len(embeddings), "Metadata length must match embeddings"
|
|
self.metadata.extend(metadata)
|
|
else:
|
|
# Add empty dicts if no metadata provided
|
|
self.metadata.extend([{} for _ in range(len(embeddings))])
|
|
|
|
def search(self, query_embeddings: np.ndarray, k: int = 5) -> Tuple[np.ndarray, np.ndarray, List[List[Dict]]]:
|
|
"""
|
|
Search for k nearest neighbors
|
|
|
|
Args:
|
|
query_embeddings: Array of shape (N_queries, dimension)
|
|
k: Number of neighbors to return
|
|
|
|
Returns:
|
|
distances: Array of shape (N_queries, k)
|
|
indices: Array of shape (N_queries, k)
|
|
metadata: List of lists, metadata for each query's k neighbors
|
|
"""
|
|
if query_embeddings.dtype != np.float32:
|
|
query_embeddings = query_embeddings.astype(np.float32)
|
|
|
|
# Normalize for inner product
|
|
if self.metric == "IP":
|
|
faiss.normalize_L2(query_embeddings)
|
|
|
|
# Search
|
|
distances, indices = self.index.search(query_embeddings, k)
|
|
|
|
# Retrieve metadata
|
|
retrieved_metadata = []
|
|
for query_indices in indices:
|
|
query_metadata = [self.metadata[i] if i < len(self.metadata) else {} for i in query_indices]
|
|
retrieved_metadata.append(query_metadata)
|
|
|
|
return distances, indices, retrieved_metadata
|
|
|
|
def save(self, index_path: str, metadata_path: Optional[str] = None):
|
|
"""
|
|
Save index and metadata to disk
|
|
|
|
Args:
|
|
index_path: Path to save FAISS index (.faiss or .index)
|
|
metadata_path: Path to save metadata (.json). If None, uses index_path.json
|
|
"""
|
|
# Save FAISS index
|
|
faiss.write_index(self.index, index_path)
|
|
|
|
# Save metadata
|
|
if metadata_path is None:
|
|
metadata_path = index_path.replace('.faiss', '.json').replace('.index', '.json')
|
|
if not metadata_path.endswith('.json'):
|
|
metadata_path += '.json'
|
|
|
|
with open(metadata_path, 'w') as f:
|
|
json.dump({
|
|
'metadata': self.metadata,
|
|
'dimension': self.dimension,
|
|
'metric': self.metric,
|
|
'count': len(self.metadata)
|
|
}, f, indent=2)
|
|
|
|
print(f"✅ Saved FAISS index to {index_path}")
|
|
print(f"✅ Saved metadata to {metadata_path}")
|
|
|
|
@classmethod
|
|
def load(cls, index_path: str, metadata_path: Optional[str] = None) -> 'VectorStore':
|
|
"""
|
|
Load index and metadata from disk
|
|
|
|
Args:
|
|
index_path: Path to FAISS index
|
|
metadata_path: Path to metadata. If None, uses index_path.json
|
|
|
|
Returns:
|
|
VectorStore instance with loaded data
|
|
"""
|
|
# Load FAISS index
|
|
index = faiss.read_index(index_path)
|
|
|
|
# Load metadata
|
|
if metadata_path is None:
|
|
metadata_path = index_path.replace('.faiss', '.json').replace('.index', '.json')
|
|
if not metadata_path.endswith('.json'):
|
|
metadata_path += '.json'
|
|
|
|
with open(metadata_path, 'r') as f:
|
|
data = json.load(f)
|
|
|
|
# Create instance
|
|
store = cls(dimension=data['dimension'], metric=data['metric'])
|
|
store.index = index
|
|
store.metadata = data['metadata']
|
|
|
|
print(f"✅ Loaded {data['count']} embeddings from {index_path}")
|
|
|
|
return store
|
|
|
|
def __len__(self):
|
|
"""Return number of vectors in index"""
|
|
return self.index.ntotal
|
|
|
|
|
|
# Example usage
|
|
if __name__ == "__main__":
|
|
# Create some random embeddings for demo
|
|
dimension = 768
|
|
n_vectors = 100
|
|
embeddings = np.random.randn(n_vectors, dimension).astype(np.float32)
|
|
|
|
# Create metadata
|
|
metadata = [{"id": f"img_{i}", "description": f"Image {i}"} for i in range(n_vectors)]
|
|
|
|
# Build index
|
|
store = VectorStore(dimension=dimension)
|
|
store.add(embeddings, metadata)
|
|
|
|
# Search
|
|
query = np.random.randn(1, dimension).astype(np.float32)
|
|
distances, indices, retrieved_metadata = store.search(query, k=5)
|
|
|
|
print(f"Top 5 matches:")
|
|
for i, (dist, idx, meta) in enumerate(zip(distances[0], indices[0], retrieved_metadata[0])):
|
|
print(f" {i+1}. Index {idx}, Distance {dist:.4f}, Metadata: {meta}")
|
|
|
|
# Save and load
|
|
store.save("demo_index.faiss")
|
|
loaded_store = VectorStore.load("demo_index.faiss")
|
|
print(f"Loaded store has {len(loaded_store)} vectors")
|