Módulo 8: Proyecto Final Integrador - RAG System Completo
Vector Search con FAISS
Descripción
En esta cápsula implementarás el componente de Vector Search usando FAISS (Facebook AI Similarity Search), específicamente el algoritmo HNSW (Hierarchical Navigable Small World) para búsqueda eficiente en alta dimensionalidad.
FAISS permite búsqueda de vectores 100-1000x más rápida que brute-force cosine similarity, manteniendo ~95% de precisión. Es crítico para RAG production-ready donde tienes millones de chunks.
Al final tendrás un FAISSIndex production-ready con save/load, batch indexing, y optimización para dot product (equivalente a cosine con normalización).
Duración estimada: 40-50 minutos
Objetivos
Al completar esta cápsula, serás capaz de:
- ✅ Instalar y configurar FAISS
- ✅ Implementar FAISS HNSW index
- ✅ Normalizar embeddings para dot product ≈ cosine
- ✅ Hacer batch indexing eficiente
- ✅ Buscar top-K vecinos más cercanos
- ✅ Persistir índice (save/load)
- ✅ Optimizar parámetros HNSW
¿Por qué FAISS?
Problema: Brute-force es lento
# Brute-force cosine similarity
def search_brute_force(query_emb, all_embeddings, k=5):
"""O(n) - Calcula similaridad con TODOS los vectores"""
similarities = []
for emb in all_embeddings: # 1M vectores = 1M operaciones
sim = cosine_similarity(query_emb, emb)
similarities.append(sim)
# Ordenar y retornar top-K
top_k_indices = np.argsort(similarities)[-k:]
return top_k_indices
# Para 1M vectores: ~2-3 segundos por query ❌
Solución: FAISS HNSW
# FAISS HNSW: Approximate Nearest Neighbors
index.search(query_emb, k=5)
# Para 1M vectores: ~5-10ms por query ✅ (200-400x más rápido)
Trade-off:
- Brute-force: 100% precisión, lento
- FAISS HNSW: ~95-98% precisión, 100-1000x más rápido
Paso 1: Instalar FAISS
1.1: Instalación
# CPU version (más común)
pip install faiss-cpu
# GPU version (si tienes CUDA)
pip install faiss-gpu
Verificar instalación:
import faiss
print(f"FAISS version: {faiss.__version__}")
# Output: FAISS version: 1.7.4
Paso 2: Implementar FAISSIndex
2.1: Clase base con HNSW
Crear src/search/faiss_index.py:
"""
FAISS Index - RAG System
Vector search con FAISS HNSW para búsqueda eficiente
"""
import faiss
import numpy as np
import pickle
from pathlib import Path
from typing import List, Dict, Tuple
import logging
class FAISSIndex:
"""
FAISS index con HNSW algorithm
Features:
- HNSW (Hierarchical Navigable Small World)
- L2 normalization (dot product ≈ cosine)
- Batch indexing
- Persistent storage (save/load)
- Metadata tracking
Example:
index = FAISSIndex(dim=1536)
index.add(embeddings, chunks)
results = index.search(query_embedding, k=5)
"""
def __init__(
self,
dim: int = 1536,
m: int = 32,
ef_construction: int = 200,
ef_search: int = 64
):
"""
Inicializar FAISS HNSW index
Args:
dim: Dimensionalidad de embeddings (1536 para OpenAI)
m: Número de conexiones por layer (default: 32)
- Más alto = mejor recall, más memoria
ef_construction: Size de dynamic candidate list durante construcción
- Más alto = mejor calidad, construcción más lenta
ef_search: Size de dynamic candidate list durante búsqueda
- Más alto = mejor recall, búsqueda más lenta
"""
self.dim = dim
self.m = m
self.ef_construction = ef_construction
self.ef_search = ef_search
# Crear índice HNSW
# IndexHNSWFlat: HNSW con flat (brute-force) en último layer
self.index = faiss.IndexHNSWFlat(dim, m)
# Configurar parámetros
self.index.hnsw.efConstruction = ef_construction
self.index.hnsw.efSearch = ef_search
# Storage para chunks (metadata)
self.chunks = []
self.logger = logging.getLogger(__name__)
def add(
self,
embeddings: np.ndarray,
chunks: List,
normalize: bool = True
) -> None:
"""
Agregar embeddings al índice
Args:
embeddings: Array de embeddings (shape: [n, dim])
chunks: Lista de Chunk objects o dicts
normalize: Si True, normaliza embeddings (L2 norm)
"""
# Validar dimensiones
if embeddings.shape[1] != self.dim:
raise ValueError(
f"Embedding dim {embeddings.shape[1]} != index dim {self.dim}"
)
# Convertir a float32 (requerido por FAISS)
embeddings = np.array(embeddings).astype('float32')
# Normalizar para dot product ≈ cosine similarity
if normalize:
faiss.normalize_L2(embeddings)
# Agregar al índice
self.index.add(embeddings)
# Guardar chunks (metadata)
self.chunks.extend(chunks)
self.logger.info(
f"✅ Indexed {len(chunks)} chunks (total: {self.index.ntotal})"
)
def add_batch(
self,
embeddings: np.ndarray,
chunks: List,
batch_size: int = 1000
) -> None:
"""
Agregar embeddings en batches (para datasets grandes)
Args:
embeddings: Array de embeddings
chunks: Lista de chunks
batch_size: Tamaño de batch
"""
total = len(embeddings)
for i in range(0, total, batch_size):
end = min(i + batch_size, total)
batch_embeddings = embeddings[i:end]
batch_chunks = chunks[i:end]
self.add(batch_embeddings, batch_chunks)
self.logger.info(f"Batch {i//batch_size + 1}: {end}/{total} chunks")
def search(
self,
query_embedding: np.ndarray,
k: int = 5,
normalize: bool = True
) -> List[Dict]:
"""
Buscar top-K vecinos más cercanos
Args:
query_embedding: Embedding del query (shape: [dim])
k: Cantidad de resultados
normalize: Si True, normaliza query
Returns:
Lista de dicts con chunk y score
"""
# Validar que hay vectores indexados
if self.index.ntotal == 0:
raise ValueError("Index is empty. Add vectors first.")
# Preparar query
query = np.array([query_embedding]).astype('float32')
if normalize:
faiss.normalize_L2(query)
# Buscar
distances, indices = self.index.search(query, k)
# Formatear resultados
results = []
for i, idx in enumerate(indices[0]):
if idx == -1: # FAISS retorna -1 si no encuentra suficientes vecinos
break
# Convertir distancia a score (dot product)
# Con L2 normalization, dot product ∈ [0, 1]
score = float(distances[0][i])
chunk = self.chunks[idx]
results.append({
'chunk': chunk,
'score': score,
'index': int(idx)
})
return results
def save(self, path: str) -> None:
"""
Guardar índice a disco
Args:
path: Directorio donde guardar
"""
path_obj = Path(path)
path_obj.mkdir(parents=True, exist_ok=True)
# Guardar índice FAISS
index_path = path_obj / "faiss.index"
faiss.write_index(self.index, str(index_path))
# Guardar chunks (metadata)
chunks_path = path_obj / "chunks.pkl"
with open(chunks_path, 'wb') as f:
pickle.dump(self.chunks, f)
# Guardar config
config = {
'dim': self.dim,
'm': self.m,
'ef_construction': self.ef_construction,
'ef_search': self.ef_search,
'total_vectors': self.index.ntotal
}
config_path = path_obj / "config.pkl"
with open(config_path, 'wb') as f:
pickle.dump(config, f)
self.logger.info(f"✅ Saved index to {path}")
def load(self, path: str) -> None:
"""
Cargar índice desde disco
Args:
path: Directorio donde está guardado
"""
path_obj = Path(path)
if not path_obj.exists():
raise FileNotFoundError(f"Index path not found: {path}")
# Cargar índice FAISS
index_path = path_obj / "faiss.index"
self.index = faiss.read_index(str(index_path))
# Cargar chunks
chunks_path = path_obj / "chunks.pkl"
with open(chunks_path, 'rb') as f:
self.chunks = pickle.load(f)
# Cargar config
config_path = path_obj / "config.pkl"
if config_path.exists():
with open(config_path, 'rb') as f:
config = pickle.load(f)
self.dim = config['dim']
self.m = config['m']
self.ef_construction = config['ef_construction']
self.ef_search = config['ef_search']
self.logger.info(
f"✅ Loaded index from {path} ({self.index.ntotal} vectors)"
)
def get_stats(self) -> Dict:
"""
Obtener estadísticas del índice
Returns:
Dict con estadísticas
"""
return {
'total_vectors': self.index.ntotal,
'dim': self.dim,
'm': self.m,
'ef_construction': self.ef_construction,
'ef_search': self.ef_search,
'index_size_mb': self.estimate_size_mb()
}
def estimate_size_mb(self) -> float:
"""
Estimar tamaño del índice en MB
Returns:
Tamaño estimado en MB
"""
# Estimación aproximada:
# - Cada vector: dim * 4 bytes (float32)
# - HNSW overhead: ~m * 8 bytes por vector
vector_size = self.dim * 4
hnsw_overhead = self.m * 8
total_bytes = self.index.ntotal * (vector_size + hnsw_overhead)
return total_bytes / (1024 ** 2)
# Demo
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
# Crear índice
index = FAISSIndex(dim=1536, m=32)
# Datos de ejemplo
embeddings = np.random.rand(1000, 1536).astype('float32')
chunks = [{'id': i, 'text': f"Chunk {i}"} for i in range(1000)]
# Indexar
index.add(embeddings, chunks)
# Buscar
query_emb = np.random.rand(1536).astype('float32')
results = index.search(query_emb, k=5)
print(f"\n✅ Found {len(results)} results")
for r in results:
print(f" Score: {r['score']:.4f} - {r['chunk']['text']}")
# Guardar
index.save("./faiss_index")
# Cargar
new_index = FAISSIndex(dim=1536)
new_index.load("./faiss_index")
print(f"\n✅ Loaded index with {new_index.index.ntotal} vectors")
Paso 3: Optimización de parámetros HNSW
3.1: Parámetros clave
# M (connections per layer)
# - Valor típico: 16-64
# - M=16: Menos memoria, recall ~90%
# - M=32: Balance (recomendado)
# - M=64: Más memoria, recall ~98%
# ef_construction (construction time)
# - Valor típico: 40-500
# - ef=40: Construcción rápida, recall ~90%
# - ef=200: Balance (recomendado)
# - ef=500: Construcción lenta, recall ~99%
# ef_search (search time)
# - Valor típico: 16-512
# - ef=16: Búsqueda muy rápida, recall ~85%
# - ef=64: Balance (recomendado)
# - ef=128: Búsqueda más lenta, recall ~95%
3.2: Benchmark de parámetros
def benchmark_parameters():
"""Benchmark diferentes configuraciones"""
configs = [
{'m': 16, 'ef_construction': 40, 'ef_search': 16}, # Fast
{'m': 32, 'ef_construction': 200, 'ef_search': 64}, # Balanced
{'m': 64, 'ef_construction': 500, 'ef_search': 128}, # Accurate
]
for config in configs:
index = FAISSIndex(**config)
# Indexar
start = time.time()
index.add(embeddings, chunks)
index_time = time.time() - start
# Buscar
start = time.time()
for query in test_queries:
results = index.search(query, k=10)
search_time = (time.time() - start) / len(test_queries)
print(f"\nConfig: M={config['m']}, ef_c={config['ef_construction']}, ef_s={config['ef_search']}")
print(f" Index time: {index_time:.2f}s")
print(f" Search time: {search_time*1000:.2f}ms/query")
print(f" Index size: {index.estimate_size_mb():.1f}MB")
Paso 4: Integración con pipeline
4.1: Pipeline completo con FAISS
from src.pipeline.rag_pipeline import RAGPipeline
from src.search.faiss_index import FAISSIndex
# Crear pipeline
pipeline = RAGPipeline(chunk_size=500, overlap=50)
# Procesar documentos
chunks, embeddings = pipeline.process_directory("./data/documents")
# Crear índice FAISS
index = FAISSIndex(dim=embeddings.shape[1], m=32)
index.add(embeddings, chunks)
# Guardar índice
index.save("./rag_index")
print(f"✅ Indexed {len(chunks)} chunks")
print(f"Index size: {index.estimate_size_mb():.1f}MB")
# Buscar
query = "How to install Python?"
query_emb = pipeline.embedder.embed(query)
results = index.search(query_emb, k=5)
for i, r in enumerate(results, 1):
print(f"\n{i}. Score: {r['score']:.4f}")
print(f" {r['chunk'].text[:200]}...")
Troubleshooting
Problema 1: RuntimeError: Error in faiss::write_index
Causa: Path no existe o sin permisos
Solución:
from pathlib import Path
# Crear directorio si no existe
path = Path("./faiss_index")
path.mkdir(parents=True, exist_ok=True)
index.save(str(path))
Problema 2: Búsqueda retorna scores negativos
Causa: No normalizaste embeddings
Solución:
# SIEMPRE normalizar para dot product ≈ cosine
faiss.normalize_L2(embeddings) # Durante indexing
faiss.normalize_L2(query) # Durante búsqueda
Problema 3: Recall muy bajo (~70%)
Causa: Parámetros HNSW muy agresivos
Solución:
# Aumentar ef_search
index.index.hnsw.efSearch = 128 # Default: 64
# O recrear con M más alto
index = FAISSIndex(dim=1536, m=64) # Default: 32
Problema 4: Index muy grande (>1GB)
Causa: HNSW usa mucha memoria para M alto
Solución:
# Opción 1: Reducir M
index = FAISSIndex(dim=1536, m=16) # Menos memoria
# Opción 2: Usar IVF (Inverted File Index) para datasets >1M
index = faiss.IndexIVFFlat(quantizer, dim, nlist)
Resumen
En esta cápsula implementaste:
- ✅
FAISSIndexcon algoritmo HNSW - ✅ L2 normalization para dot product ≈ cosine
- ✅ Batch indexing para datasets grandes
- ✅ Save/load persistente
- ✅ Optimización de parámetros (M, ef_construction, ef_search)
- ✅ Integración con pipeline RAG completo
Próxima cápsula: Evaluation Framework - Implementar métricas (nDCG, MRR, Recall@K) y A/B testing.
Recursos Adicionales
- FAISS Documentation - Official wiki
- FAISS Tutorial - Pinecone guide
- HNSW Paper - Original algorithm paper
- FAISS Benchmarks - Performance comparisons
- ANN Benchmarks - Comprehensive ANN comparison
- FAISS Best Practices - Index selection guide
- Vector Search at Scale - DeepLearning.AI course
Módulo 8 - Cápsula 04