Modelos de Embedding y Estrategias de Indexación
¿Qué Son los Embeddings?
Un embedding es una representación numérica de un concepto (texto, imagen, audio) en un espacio vectorial donde la distancia entre vectores refleja similitud semántica.
"gato" → [0.23, -0.15, 0.87, ...] ─┐
"felino" → [0.21, -0.13, 0.85, ...] ─┤ Cercanos (similares)
"perro" → [0.19, -0.10, 0.72, ...] ─┘
"economía" → [-0.45, 0.67, 0.12, ...] Lejos (diferente)
Modelos de Embedding para Texto
OpenAI Embeddings
from openai import OpenAI
client = OpenAI()
response = client.embeddings.create(
model="text-embedding-3-large",
input=["Cómo configurar autenticación JWT"],
dimensions=1536, # Matryoshka: se puede reducir a 256, 512, etc.
)
vector = response.data[0].embedding
print(f"Dimensiones: {len(vector)}") # 1536
Matryoshka Embeddings: Los modelos -3-large y -3-small soportan reducción de dimensiones sin re-entrenamiento. Puedes usar 256 dims para ahorrar ~6x en storage con ~5% pérdida de calidad.
Cohere Embed v4
import cohere
co = cohere.Client("your-api-key")
# Input types: "search_document" para indexar, "search_query" para buscar
response = co.embed(
texts=["Cómo configurar JWT"],
model="embed-v4.0",
input_type="search_query", # Importante: distinguir query vs document
embedding_types=["float"],
)
Modelos Open-Source: Sentence Transformers
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-m3")
# Embedding de textos
embeddings = model.encode(
["Autenticación JWT", "Token de acceso"],
normalize_embeddings=True, # Normalizar para cosine similarity
batch_size=32,
show_progress_bar=True,
)
# Similitud
from sentence_transformers.util import cos_sim
similarity = cos_sim(embeddings[0], embeddings[1])
print(f"Similitud: {similarity.item():.4f}")
Comparativa de Modelos de Embedding
| Modelo | Dims | Contexto | MTEB Score | Costo |
|---|---|---|---|---|
| text-embedding-3-large | 3072* | 8K | 64.6 | $0.13/1M tokens |
| text-embedding-3-small | 1536 | 8K | 62.3 | $0.02/1M tokens |
| Cohere embed-v4 | 1024 | 512 | 66.1 | $0.10/1M tokens |
| voyage-3-large | 1024 | 32K | 67.3 | $0.18/1M tokens |
| BGE-M3 | 1024 | 8K | 63.5 | Gratis (self-hosted) |
| nomic-embed-text | 768 | 8K | 62.4 | Gratis (self-hosted) |
*Reducible con Matryoshka
Estrategias Avanzadas de Chunking
Chunking Semántico
Divide basándose en coherencia semántica, no en caracteres/tokens.
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
# SemanticChunker: genera un embedding por cada oración del texto,
# luego calcula la similitud coseno entre oraciones consecutivas.
# Cuando la similitud cae significativamente, corta ahí — indicando un cambio de tema.
splitter = SemanticChunker(
OpenAIEmbeddings(model="text-embedding-3-small"),
# breakpoint_threshold_type: cómo se determina un "cambio significativo" de tema.
# "percentile" = calcula todos los drops de similitud y usa el percentil como umbral.
# Otras opciones: "standard_deviation", "interquartile", "gradient"
breakpoint_threshold_type="percentile",
# breakpoint_threshold_amount=80: corta cuando la caída de similitud está en el
# percentil 80 (es decir, solo el 20% de caídas más extremas generan un corte).
# Más alto = menos chunks, más grandes. Más bajo = más chunks, más pequeños.
breakpoint_threshold_amount=80,
)
chunks = splitter.split_documents(documents)
Chunking por Estructura (Markdown/HTML)
from langchain_text_splitters import MarkdownHeaderTextSplitter
headers_to_split = [
("#", "title"),
("##", "section"),
("###", "subsection"),
]
splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split)
chunks = splitter.split_text(markdown_content)
# Cada chunk conserva los headers como metadata
# chunk.metadata = {"title": "API Reference", "section": "Authentication"}
Parent-Child Chunking
Indexa chunks pequeños pero recupera el documento padre (más contexto).
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=400)
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=InMemoryStore(),
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
# Indexa chunks pequeños (400 chars) como embeddings
# Busca en chunks pequeños (más precisos)
# Retorna el parent chunk completo (2000 chars, más contexto)
retriever.add_documents(documents)
Agentic Chunking con Propositions
Extrae proposiciones individuales de cada documento y las indexa por separado.
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini")
PROPOSITION_PROMPT = """
Descompón el siguiente texto en proposiciones atómicas e independientes.
Cada proposición debe:
- Ser auto-contenida (entendible sin contexto)
- Contener exactamente un hecho o idea
- Ser corta (1-2 oraciones máximo)
Texto: {text}
Devuelve una lista JSON de proposiciones.
"""
# Convierte un chunk en múltiples proposiciones indexables
propositions = llm.invoke(PROPOSITION_PROMPT.format(text=chunk))
Re-ranking: Mejorando la Relevancia
El retriever inicial usa búsqueda aproximada. Un reranker refina los resultados con un modelo más preciso.
from langchain.retrievers import ContextualCompressionRetriever
from langchain_cohere import CohereRerank
reranker = CohereRerank(model="rerank-v3.5", top_n=5)
compression_retriever = ContextualCompressionRetriever(
base_compressor=reranker,
base_retriever=vectorstore.as_retriever(search_kwargs={"k": 20}),
)
# Busca 20 documentos → Reranker selecciona los 5 más relevantes
results = compression_retriever.invoke("¿Cómo autenticar con JWT?")
Cross-Encoder vs Bi-Encoder
Bi-Encoder (Embedding):
Query → Vector₁ ──┐
Doc → Vector₂ ──┼── Cosine similarity (rápido, ~1ms)
│
Cross-Encoder (Reranker):
[Query, Doc] → Modelo → Score (preciso, ~50ms por par)
Indexación Híbrida: Sparse + Dense
Combina búsqueda semántica (dense vectors) con keyword matching (sparse vectors).
# Dense embeddings: capturan semántica
dense_vector = embedding_model.encode("autenticación de usuarios")
# Sparse embeddings: capturan keywords exactas (BM25/SPLADE)
from transformers import AutoModelForMaskedLM, AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("naver/splade-cocondenser-ensembledistil")
model = AutoModelForMaskedLM.from_pretrained("naver/splade-cocondenser-ensembledistil")
# En Qdrant con named vectors
client.create_collection(
collection_name="hybrid",
vectors_config={
"dense": VectorParams(size=1536, distance=Distance.COSINE),
},
sparse_vectors_config={
"sparse": SparseVectorParams(),
},
)
Pipeline de Indexación en Producción
import hashlib
from datetime import datetime
class DocumentIndexer:
def __init__(self, vectorstore, embeddings, splitter):
self.vectorstore = vectorstore
self.embeddings = embeddings
self.splitter = splitter
def index_document(self, content: str, metadata: dict) -> int:
"""Indexa un documento con deduplicación."""
# 1. Generar hash para deduplicación
content_hash = hashlib.sha256(content.encode()).hexdigest()
# 2. Verificar si ya está indexado
existing = self.vectorstore.get(where={"hash": content_hash})
if existing["ids"]:
return 0 # Ya indexado
# 3. Chunking
chunks = self.splitter.split_text(content)
# 4. Enriquecer metadata
enriched_metadata = {
**metadata,
"hash": content_hash,
"indexed_at": datetime.utcnow().isoformat(),
"chunk_count": len(chunks),
}
# 5. Indexar
self.vectorstore.add_texts(
texts=chunks,
metadatas=[{**enriched_metadata, "chunk_index": i} for i in range(len(chunks))],
)
return len(chunks)
Resumen
La calidad de tu RAG depende fundamentalmente de:
- Modelo de embedding correcto para tu dominio y idioma
- Estrategia de chunking que preserve semántica
- Vector database adecuada para tu escala
- Re-ranking para refinar resultados
- Indexación híbrida cuando necesitas keyword matching + semántica
🧠 Preguntas de Repaso
1. ¿Cuál es la diferencia principal entre un Bi-Encoder y un Cross-Encoder en un pipeline de búsqueda?
- A) El Bi-Encoder es más preciso y el Cross-Encoder es más rápido
- B) El Bi-Encoder procesa query y documento por separado (~1ms), mientras el Cross-Encoder los procesa juntos (~50ms por par) siendo más preciso
- C) El Cross-Encoder solo funciona con texto en inglés
- D) No hay diferencia, son nombres diferentes para la misma técnica
Respuesta: B) — El Bi-Encoder genera embeddings independientes para query y documento (rápido, ~1ms), y compara por similitud coseno. El Cross-Encoder procesa [Query, Doc] juntos (más lento, ~50ms por par) pero es significativamente más preciso para reranking.
2. ¿Qué es el "Agentic Chunking con Propositions"?
- A) Dividir texto por número fijo de caracteres
- B) Usar un LLM para descomponer el texto en proposiciones atómicas auto-contenidas de 1-2 oraciones
- C) Agrupar chunks por similitud semántica
- D) Crear chunks basados en los títulos del documento
Respuesta: B) — El Agentic Chunking usa un LLM para descomponer el texto en proposiciones atómicas — afirmaciones auto-contenidas de 1-2 oraciones que contienen exactamente un hecho verificable, mejorando la precisión de búsqueda.
3. El modelo text-embedding-3-large con dimensiones reducidas a 256 (en vez de 3072) ofrece:
- A) 12x más almacenamiento pero 50% de pérdida de calidad
- B) ~6x ahorro en storage con ~5% de pérdida de calidad
- C) Ningún ahorro porque las dimensiones no se pueden reducir
- D) El mismo rendimiento exacto que con 3072 dimensiones
Respuesta: B) — Gracias a Matryoshka embeddings, reducir de 3072 a 256 dimensiones ahorra aproximadamente 6x en almacenamiento con solo ~5% de pérdida en calidad de búsqueda.
4. En la indexación híbrida (Sparse + Dense), ¿cuál es el rol de cada componente?
- A) Sparse maneja semántica y Dense maneja keywords
- B) Dense captura significado semántico y Sparse (BM25/SPLADE) captura coincidencia exacta de keywords
- C) Ambos hacen lo mismo pero uno es más rápido
- D) Dense se usa para indexar y Sparse solo para consultar
Respuesta: B) — Dense embeddings capturan el significado semántico (sinónimos, contexto), mientras que Sparse vectors (BM25/SPLADE) capturan la coincidencia exacta de términos. Combinarlos mejora la búsqueda cuando se necesita tanto relevancia semántica como keyword matching.