Inicio / Inteligencia Artificial / AI Engineering Pro / Embeddings e Indexación Avanzada

Embeddings e Indexación Avanzada

Modelos de embedding, semantic chunking, parent-child, reranking e hybrid search.

Avanzado
🔒 Solo lectura
📖

Estás en modo lectura

Puedes leer toda la lección, pero para marcar progreso, hacer ejercicios y ganar XP necesitas una cuenta Pro.

Desbloquear por $9/mes

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:

  1. Modelo de embedding correcto para tu dominio y idioma
  2. Estrategia de chunking que preserve semántica
  3. Vector database adecuada para tu escala
  4. Re-ranking para refinar resultados
  5. 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.

¿Te gustó esta lección?

Con Pro puedes marcar progreso, hacer ejercicios, tomar quizzes, ganar XP y obtener tu constancia.

Ver planes desde $9/mes