Elección y ajuste de modelos de embedding para sistemas RAG en producción.
Aprende cómo los modelos de embedding convierten el texto en vectores buscables, por qué el vocabulario del dominio daña la búsqueda semántica, y cómo seleccionar, comprimir y ajustar los modelos para sistemas RAG en producción.
Este es el quinto capítulo de una serie sobre la creación de sistemas de generación mejorada por recuperación de información de nivel profesional, que sigue el camino desde documentos en bruto hasta un sistema capaz de responder a preguntas reales. Las etapas anteriores de esta serie trataron sobre la limpieza y normalización del contenido extraído, y luego su división en unidades de recuperación mediante el procesamiento por bloques. Una vez obtenidos los bloques, el siguiente paso es transformarlos en algo que un índice de búsqueda pueda comparar realmente.
Imagínese al mismo gestor de relaciones mencionado anteriormente, aún atascado con una línea de crédito de 12 millones de euros para un cliente corporativo de alto riesgo. Para atender la solicitud es necesario localizar un requisito de diligencia debida mejorada oculto en una cláusula de la política, un umbral de aprobación indicado en una fila de tabla y una secuencia de control descrita en un diagrama de procesos. La etapa de procesamiento por bloques ya ha separado estos elementos en piezas de contenido independientes y rastreables.
Aun así, nada de eso puede ser buscado aún por un índice vectorial.
Un modelo de embedding es lo que convierte cada fragmento en un vector numérico de longitud fija. Luego, el mismo modelo transforma una consulta entrante en un vector, y el índice recupera aquellos fragmentos que se encuentren más cerca de él en ese espacio vectorial. No está garantizado que frases como “Aprobación del Comité de Crédito del Grupo” en un documento de política y “Umbral de aprobación del GCC” en la pregunta de un usuario terminen realmente cerca una de la otra; todo depende del modelo que se haya elegido y de lo que haya aprendido durante el entrenamiento.
Esa dependencia es precisamente de lo que trata esta parte de la serie.
Elegir un modelo de embedding equivale a apostar por cuánto vocabulario y formulación comparten sus documentos con las consultas de los usuarios. Si elige el incorrecto para su área de aplicación, perderá semanas intentando solucionar lo que parece un error de recuperación, pero que en realidad es un problema de representación: los vectores mismos están colocados incorrectamente, por lo que ninguna cantidad de ajuste del índice podrá solucionarlo.
Qué calcula realmente un modelo de embedding
En esencia, un modelo de embedding ingiere una secuencia de tokens y genera un vector denso, generalmente con entre 384 y 3072 dimensiones según el modelo específico. Ese vector tiene como objetivo ser una codificación comprimida de lo que significa la entrada.
La suposición básica detrás de la recuperación es que las entradas con significado similar terminan teniendo vectores cercanos entre sí en ese espacio. La proximidad se mide comúnmente con la similitud coseno, que analiza el ángulo que separa a dos vectores en lugar de su distancia bruta; esta propiedad hace que sea insensible a las diferencias en la longitud del texto.
Considere una regla de cumplimiento que establece que los clientes corporativos clasificados como de alto riesgo deben someterse a verificaciones de debida diligencia reforzada antes de que se pueda presentar una propuesta de crédito para su revisión. Un modelo versátil y capaz probablemente colocaría su vector cerca de textos similares sobre cumplimiento de otros bancos, cerca de material legal relacionado con las obligaciones de debida diligencia y cerca de directrices regulatorias para gestionar clientes de alto riesgo.
No obstante, un gestor de relaciones podría expresar la misma necesidad subyacente de manera muy diferente, preguntando algo como qué pasos se deben seguir antes de poder presentar una solicitud de crédito. Si esa forma de expresión cotidiana se acerca al enfoque formal de las políticas es, en realidad, una cuestión relacionada con la cantidad de datos de entrenamiento que ha vinculado preguntas operativas informales con lenguaje formal de cumplimiento. Los modelos entrenados principalmente en texto web general y de uso amplio a menudo nunca absorben esa combinación específica cuando el campo es especializado.
Esta brecha entre la redacción de las consultas cotidianas y la redacción de los documentos especializados se conoce como desajuste de vocabulario, y es la causa principal de los problemas de calidad en la recuperación de información en los sistemas RAG empresariales.
Tokenización y la ventana de contexto
Antes de que un modelo de embedding calcule algo, primero divide la entrada en tokens utilizando su propio vocabulario interno. El recuento de tokens no se corresponde directamente con el número de palabras o caracteres. Un bloque de 500 tokens en inglés podría equivaler a aproximadamente 350–400 palabras, mientras que el mismo límite de tokens en alemán — donde las palabras suelen ser compuestas — podría representar menos ideas distintas.
Cada modelo de embedding establece una longitud máxima de contexto; todo lo que sea más largo o bien se trunca o requiere un tratamiento especial. Los modelos Sentence-Transformers suelen tener un límite entre 256 y 512 tokens. Text-Embedding-3-Large de OpenAI puede manejar hasta 8,191 tokens. BGE-M3 alcanza hasta 8,192 tokens. Las embeddings v3 de Jina también soportan hasta 8,192 tokens.
La conclusión práctica para el RAG en entornos de producción es que los límites entre los fragmentos de texto definidos anteriormente en el proceso deben mantenerse dentro del límite de contexto impuesto por el modelo de embedding elegido. Cualquier fragmento que supere ese límite se corta silenciosamente, y el vector resultante solo refleja una parte del texto original; este es un modo de fallo que no aparecerá en los registros de tu pipeline.
El espacio semántico y cuándo falla
La mayoría de los modelos de embedding actuales son codificadores tipo transformer entrenados con un objetivo contrastivo: las parejas de textos que significan cosas similares se acercan en el espacio vectorial, mientras que las parejas dispares se alejan. Tras suficiente entrenamiento, el modelo adquiere una estructura geométrica en la que la proximidad sirve como indicador de relación semántica.
Ese diseño funciona bien siempre y cuando las consultas y los documentos compartan la terminología, el estilo de redacción y el enfoque conceptual con lo que se utilizó para entrenar al modelo. En el caso del RAG empresarial, tiende a fallar de varias maneras predecibles:
- La terminología específica del dominio genera fallos que son fáciles de pasar por alto. Un analista que pregunte sobre el “umbral de presentación de SAR para la estructuración” podría no encontrar coincidencias con un fragmento de política redactado como “Criterios de presentación del Informe de Actividades Sospechosas para la estructuración de transacciones”, si el modelo nunca aprendió que estas dos formulaciones significan lo mismo.
Reconocer exactamente dónde tienden a fallar los modelos de incrustación de uso general es tan importante como saber qué modelo lidera las listas de mejores rendimientos en pruebas públicas.
Elegir un modelo de incrustación en 2025
El campo de los modelos de incrustación se ha reducido considerablemente. La comparación que sigue abarca los modelos más relevantes para los sistemas RAG empresariales en banca y servicios financieros a mediados de 2025.
No existe un ganador universal para todos los escenarios empresariales. Su elección depende de la combinación de idiomas que necesite soportar, del presupuesto de latencia y de la infraestructura a su disposición, de si la inferencia local es una opción viable para usted, y del tamaño de la brecha terminológica entre su dominio y los modelos de uso general en los que se entrenaron; dicha brecha determina si vale la pena realizar un ajuste fino.
Recuperación asimétrica y indicar al modelo qué tipo de entrada está recibiendo
Una distinción que muchos equipos pasan por alto es la recuperación asimétrica. Cuando se recuperan fragmentos de texto, la consulta y el fragmento con el que se compara son cosas estructuralmente muy diferentes. Las consultas suelen ser cortas, están formuladas como preguntas y a menudo carecen de gran parte del vocabulario que aparece en una respuesta correcta. En cambio, los fragmentos de texto son más largos, se presentan como hechos y están repletos de términos específicos del dominio.
Ciertos modelos están diseñados para reconocer esta asimetría directamente. Los modelos E5 añaden un prefijo “query:” o “passage:” al texto de entrada para que el modelo sepa qué rol debe asignarle. Cohere’s Embed v3 expone esto a través de un parámetro input_type, cuyos valores aceptados incluyen “search_query”, “search_document”, “classification” y “clustering”.
Proporcionar el tipo de entrada incorrecto en el momento del indexado o la consulta afecta silenciosamente las puntuaciones de similitud de manera difícil de rastrear hasta su causa raíz. Si incrustas un documento como si fuera una consulta, obtendrás un vector diseñado para la geometría de las consultas y no para la de los pasajes. La recuperación de información no falla por completo, pero pierde precisión de forma que es fácil pasarla por alto durante pruebas informales.
En entornos de producción, no dependa de que los desarrolladores recuerden configurarlo correctamente: hágalo obligatorio a través de la configuración. Tanto la llamada de incrustación en el momento del indexado como la llamada de incrustación en el momento de la consulta deben declarar explícitamente su tipo de entrada siempre que el modelo que utilice soporte esa opción.
import cohere
from typing import List
co = cohere.Client(api_key="your_api_key")
def embed_documents(chunks: List[str]) -> List[List[float]]:
"""Embed document chunks for indexing with explicit document input type."""
response = co.embed(
texts=chunks,
model="embed-english-v3.0",
input_type="search_document",
embedding_types=["float"]
)
return response.embeddings.float
def embed_query(query: str) -> List[float]:
"""Embed a search query with explicit query input type."""
response = co.embed(
texts=[query],
model="embed-english-v3.0",
input_type="search_query",
embedding_types=["float"]
)
return response.embeddings.float[0]
Vectores dispersos: donde la coincidencia de palabras clave supera a la búsqueda semántica
Las incrustaciones densas capturan el significado. En cambio, las representaciones dispersas indican qué términos están presentes y cuánto peso deben tener. Para una parte significativa de los tipos de consultas en sistemas RAG empresariales, la recuperación con vectores dispersos supera por completo a la recuperación con vectores densos, y en la mayoría de los sistemas de producción, combinar ambos es mejor que utilizar solo uno.
BM25 como línea de base fiable
BM25 sigue siendo el enfoque estándar para la recuperación basada en palabras clave. Evalúa la relevancia según la frecuencia con que aparece un término en un documento, cuán raro es ese término en todo el corpus y un factor de normalización que tiene en cuenta la longitud del documento. No hay modelo que entrenar, no se requiere GPU y no se necesita ninguna llamada a una API de embeddings.
Considere una consulta como “CRD-EU-047 approval authority threshold”. BM25 dará una alta clasificación a cualquier fragmento que contenga esos términos literales. Por otro lado, un modelo denso podría no mostrar ese fragmento a menos que su corpus de entrenamiento haya establecido una fuerte asociación entre ese código de política específico y el concepto de autoridad de aprobación.
from rank_bm25 import BM25Okapi
import re
from typing import List, Tuple
def tokenise(text: str) -> List[str]:
"""Simple whitespace and punctuation tokeniser for BM25."""
return re.findall(r'\b\w+\b', text.lower())
class BM25Index:
def __init__(self, documents: List[str]):
self.documents = documents
tokenised = [tokenise(doc) for doc in documents]
self.bm25 = BM25Okapi(tokenised)
def search(self, query: str, top_k: int = 10) -> List[Tuple[int, float]]:
"""Return (doc_index, score) pairs for the top_k results."""
tokens = tokenise(query)
scores = self.bm25.get_scores(tokens)
ranked = sorted(enumerate(scores), key=lambda x: x[1], reverse=True)
return ranked[:top_k]
La recuperación densa es eficaz para capturar similitudes conceptuales, mientras que la recuperación dispersa es útil para encontrar coincidencias exactas en términos e identificadores. Combinar ambas —recuperación híbrida— suele dar excelentes resultados, especialmente en corpus bancarios donde el lenguaje regulatorio es preciso y está repleto de identificadores.
En corpus basados en un lenguaje normativo estable y definido con precisión, BM25 por sí solo a menudo logra un rendimiento similar al de la recuperación densa en búsquedas factuales específicas, con una fracción mínima de la carga adicional en infraestructura. Su principal limitación es la sinonimia: una consulta que utilice “requisitos EDD” no recuperará un pasaje que solo diga “requisitos de debida diligencia mejorada”, a menos que la frase exacta aparezca en algún lugar.
SPLADE: Vectores dispersos que aprenden la expansión del vocabulario
SPLADE (Sparse Lexical and Expansion Model) ocupa un punto intermedio entre la simple coincidencia de palabras clave y la recuperación densa completa. Durante el proceso de indexación, se utiliza un modelo de lenguaje enmascarado para enriquecer tanto los documentos como las consultas con un vocabulario semánticamente relacionado que no necesariamente está presente en la redacción original. El resultado es un vector disperso cuyas dimensiones corresponden a cada token del vocabulario, ponderadas según la importancia que ese token tiene para la entrada.
Por lo tanto, un pasaje codificado con SPLADE que aborda los requisitos de EDD podría tener mayor peso en términos como “debida diligencia del cliente”, “evaluación de riesgos” e “verificación de identidad”, incluso cuando ninguna de esas frases exactas aparezca en el texto original. Esta expansión mejora la capacidad de recuperación en consultas que utilizan sinónimos, al tiempo que mantiene la eficiencia e interpretabilidad propias de las representaciones dispersas adecuadas para índices invertidos.
El compromiso consiste en un costo adicional de inferencia al momento del índice y un mayor espacio de almacenamiento en comparación con BM25 tradicional. Pero en corpus de servicios financieros donde el mismo concepto normativo se describe de manera diferente según las jurisdicciones y revisiones de documentos, dicha expansión puede ampliar significativamente el alcance de la recuperación.
Matryoshka Embeddings: Tamaño vectorial ajustable para el control de costos
El aprendizaje por representación Matryoshka (MRL) crea embeddings en los que las N dimensiones iniciales ya forman una representación completa y autónoma de la entrada; las dimensiones adicionales añaden detalles más finos en lugar de sobrescribir lo anterior.
La técnica toma su nombre de las muñecas rusas: un vector Matryoshka de 1536 dimensiones contiene una representación completamente funcional de 256 dimensiones en sus primeros 256 espacios, una representación funcional de 512 dimensiones en sus primeros 512 espacios, y así sucesivamente.
La familia de modelos text-embedding-3 de OpenAI admite esto directamente mediante un parámetro de dimensiones.
from openai import OpenAI
from typing import List
client = OpenAI()
def embed_with_matryoshka(
texts: List[str],
dimensions: int = 256,
model: str = "text-embedding-3-large"
) -> List[List[float]]:
"""
Embed texts at a specified sub-dimension.
Lower dimensions reduce storage and index cost.
Measure retrieval quality drop before committing to a dimension.
"""
response = client.embeddings.create(
input=texts,
model=model,
dimensions=dimensions
)
return [item.embedding for item in response.data]
Los embeddings Matryoshka anidan representaciones cada vez más detalladas dentro de un único vector: las primeras 256 dimensiones ya proporcionan una representación útil para la recuperación de información, y cada nivel adicional añade precisión semántica a un costo proporcional en almacenamiento.
La verdadera ventaja en la producción es poder ajustar el equilibrio entre almacenamiento y calidad según sea necesario, sin tener que volver a entrenar un modelo ni reconstruir el índice desde cero. En el caso de un corpus relacionado con políticas bancarias, se pueden realizar pruebas de rendimiento con 256, 512, 1024 y 3072 dimensiones, y descubrir que 512 dimensiones permiten obtener el 97% de la recuperación total utilizando solo el 17% del almacenamiento que requeriría un vector completo.
Ese equilibrio rara vez es tan claro en la práctica como parece en un gráfico de pruebas. Las sutiles diferencias entre dominios, especialmente entre conceptos regulatorios estrechamente relacionados, suelen encontrarse específicamente en la parte de mayor dimensión del vector. Pruebe con su propio corpus y patrones reales de consultas antes de decidirse por una dimensionalidad reducida para su uso en producción.
Compresión de embeddings sin sacrificar demasiada precisión
Los embeddings estándar almacenan cada dimensión como un número flotante de 32 bits. Si se eleva esa cantidad a un millón de fragmentos de documentos, cada uno con 1536 dimensiones, se obtienen aproximadamente 6 GB de vectores en bruto antes de que se añada cualquier carga adicional por indexación. A escala empresarial, ese espacio de almacenamiento y su costo en memoria dejan de ser un error de redondeo.
La cuantización aborda este problema reduciendo la cantidad de bits utilizados para representar cada dimensión. En la práctica, tres técnicas son las más utilizadas: cuantización escalar (convierte float32 a int8), cuantización binaria (convierte float32 a un solo bit) y cuantización por producto (compresión de cada vector en un código más corto).
Cuantización escalar: int8
La cuantización escalar mapea el rango continuo de float32 a 256 valores enteros discretos. Cada dimensión pasa de ocupar 4 bytes a 1, lo que reduce el almacenamiento en un 75%. Dado que los modelos de embedding de alta dimensión distribuyen la información de manera dispersa en muchas dimensiones, ninguna dimensión por sí sola tiene mucho peso, por lo que la precisión perdida debido a este redondeo suele ser menor.
import numpy as np
from typing import Tuple
def quantise_to_int8(
embeddings: np.ndarray
) -> Tuple[np.ndarray, float, float]:
"""
Scalar quantisation to int8.
Returns quantised array plus the scale and zero_point needed for dequantisation.
"""
min_val = embeddings.min()
max_val = embeddings.max()
scale = (max_val - min_val) / 255.0
zero_point = -round(min_val / scale)
quantised = np.clip(
np.round(embeddings / scale) + zero_point,
0, 255
).astype(np.uint8)
return quantised, scale, zero_point
def dequantise_from_int8(
quantised: np.ndarray,
scale: float,
zero_point: float
) -> np.ndarray:
"""Reconstruct approximate float32 embeddings from int8."""
return ((quantised.astype(np.float32) - zero_point) * scale)
Cuantización binaria
La cuantización binaria va aún más allá, reduciendo cada dimensión a un único bit que simplemente registra si el valor float original era positivo o negativo. Esto reduce el almacenamiento en aproximadamente un 97% en comparación con float32. Como la representación ya no es continua, la similitud se mide mediante la distancia de Hamming en lugar de la similitud coseno.
Esta técnica funciona mejor en modelos cuyas distribuciones de salida están naturalmente bien centradas, de modo que para cualquier entrada dada aproximadamente la mitad de las dimensiones se encuentran en cada lado de cero. Si las dimensiones de un modelo están sesgadas en lugar de equilibradas, la cuantización binaria provoca una pérdida de calidad notablemente mayor. Cohere desarrolló Embed v3 teniendo en cuenta esta restricción, y la evaluación publicada por Anthropic de dicho modelo indica una degradación en la recuperación inferior al 1%, junto con una reducción del 97% en el almacenamiento en sus conjuntos de prueba. Considere esa cifra como un punto de partida y no como una garantía, y véalas validadas con su propio corpus antes de confiar en ellas.
import numpy as np
def quantise_to_binary(embeddings: np.ndarray) -> np.ndarray:
"""
Binary quantisation: positive dimensions become 1, negative become 0.
Packs 8 dimensions per byte using numpy packbits.
"""
binary_matrix = (embeddings > 0).astype(np.uint8)
return np.packbits(binary_matrix, axis=1)
def hamming_similarity(
query_binary: np.ndarray,
corpus_binary: np.ndarray
) -> np.ndarray:
"""Compute normalised Hamming similarity for binary embeddings."""
n_bits = corpus_binary.shape[1] * 8
xor = np.bitwise_xor(
query_binary,
corpus_binary
)
hamming_distances = np.unpackbits(xor, axis=1).sum(axis=1)
return 1.0 - (hamming_distances / n_bits)
En entornos de producción, la cuantización binaria se suele utilizar como primera etapa de un proceso de recuperación en dos pasos: un índice binario permite una recuperación rápida y amplia de candidatos, mientras que un proceso de precisión total vuelve a calificar los resultados más relevantes. Este enfoque permite ahorrar la mayor parte del espacio de almacenamiento al mismo tiempo que se mantiene la precisión en el paso final de clasificación, donde es crucial.
Ajuste fino para RAG específico del dominio
El ajuste fino es la solución adecuada una vez que se ha confirmado que los embeddings de uso general no son suficientes para los datos específicos del dominio. El objetivo es enseñar al modelo que el vocabulario, las abreviaturas y los vínculos conceptuales propios de su área están cercanos entre sí en el espacio semántico.
El ajuste fino no siempre es necesario, ni siempre constituye la solución adecuada. Si los problemas de recuperación se deben a una mala división en fragmentos, como se explicó anteriormente en esta serie, ajustar el modelo de embeddings no servirá de nada. Si la causa raíz radica en cómo está configurado el reordenamiento o en cómo se elaboran las instrucciones, el ajuste fino apunta erróneamente a una capa completamente distinta del sistema. Antes de invertir recursos en ello, analice sus fallos de recuperación por tipo de consulta para determinar dónde se encuentra realmente el problema.
Cuando fallan los embeddings generales
En el contexto específico de RAG en banca, algunos patrones recurrentes de fallo justifican la inversión en ajuste fino:
Las abreviaturas específicas de un dominio suelen interpretarse erróneamente. Un modelo de uso general podría asociar “NPA” con la National Parks Association en lugar de con Activos que No Generan Ingresos, y podría vincular solo de forma débil “KYC” con los conceptos de cumplimiento y onboarding que en realidad predominan en las consultas bancarias.
Faltan conexiones entre conceptos relacionados en diferentes documentos. Una búsqueda de “disposiciones para la reestructuración de facilidades” debería mostrar textos normativos sobre “marcos para la modificación de préstamos”, pero un modelo entrenado principalmente con contenido web general podría no haber visto nunca estas expresiones juntas lo suficiente como para establecer esa relación.
Los códigos y identificadores regulatorios no reciben la importancia que merecen. Los números de versión de las políticas, los códigos regulatorios y los marcadores de jurisdicción deberían influir significativamente en la clasificación, pero los modelos de embedding generales tienden a tratarlos como tokens de bajo valor que transmiten poca información semántica.
Los umbrales numéricos pierden su contexto regulatorio. Una cifra como “10 millones de euros” presentada por separado no debería relacionarse automáticamente con una consulta sobre “la autoridad competente para exposiciones grandes”; dicha conexión solo se establece si el modelo ha sido entrenado con datos del dominio que vinculan esa cifra a su significado regulatorio.
Creación de pares de entrenamiento a partir de datos específicos del dominio
El ajuste fino de los modelos sentence-transformers bajo un objetivo contrastivo depende de pares positivos: ejemplos que emparejan una consulta con un pasaje que el modelo debe aprender a tratar como relacionado. Los ejemplos negativos pueden seleccionarse manualmente o extraerse automáticamente del corpus circundante.
En un contexto bancario de RAG, estos pares positivos pueden obtenerse de varias fuentes prácticas:
Conjuntos existentes de preguntas y respuestas ya generados por los equipos de cumplimiento y crédito, donde cada pregunta está vinculada a su pasaje de origen.
La estructura natural de los documentos normativos, donde un encabezado junto con el párrafo debajo forma un ejemplo positivo listo para usar.
Consultas de analistas registradas emparejadas con los pasajes que realmente se recuperaron cuando la respuesta era correcta.
Las consultas generadas por máquina por un LLM para cada fragmento de texto, utilizando dicho fragmento como el pasaje positivo de referencia.
De estas, la generación de consultas sintéticas suele ser el enfoque más viable cuando ya existe poca información etiquetada.
from openai import OpenAI
import json
from typing import List, Dict
client = OpenAI()
def generate_training_queries(
chunk: str,
chunk_metadata: Dict,
n_queries: int = 3
) -> List[Dict]:
"""
Generate synthetic query-passage pairs for fine-tuning.
The chunk itself is the positive passage for each generated query.
"""
prompt = f"""You are generating training data for a banking RAG system.
Given the following policy passage, generate {n_queries} realistic questions
that a credit analyst, compliance officer, or relationship manager might ask
that this passage directly answers. Each question should use natural language
and may use different terminology than the passage itself.
Passage:
{chunk}
Return a JSON array of objects with keys "query" and "difficulty".
Difficulty should be "narrow" (single fact) or "synthesis" (multiple facts).
Return only the JSON array, no other text."""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"}
)
try:
result = json.loads(response.choices[0].message.content)
queries = result.get("queries", result) if isinstance(result, dict) else result
return [
{
"query": q["query"],
"passage": chunk,
"document_id": chunk_metadata.get("document_id"),
"chunk_id": chunk_metadata.get("chunk_id"),
"difficulty": q.get("difficulty", "narrow")
}
for q in queries
]
except (json.JSONDecodeError, KeyError):
return []
Entrenamiento contrastivo con pérdida de estilo tripleta
El objetivo de entrenamiento más efectivo para los modelos de embedding orientados a la recuperación es el aprendizaje contrastivo, que utiliza negativos dentro del mismo lote o negativos difíciles seleccionados intencionadamente. Sentence-transformers admite este patrón a través de MultipleNegativesRankingLoss, el cual utiliza cada otro ejemplo en un lote de entrenamiento como negativo implícito para una pareja ancla-positivo dada.
from sentence_transformers import SentenceTransformer, InputExample
from sentence_transformers.losses import MultipleNegativesRankingLoss
from torch.utils.data import DataLoader
from typing import List, Dict
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def build_training_examples(
pairs: List[Dict]
) -> List[InputExample]:
"""
Convert query-passage pairs into InputExample objects.
MultipleNegativesRankingLoss expects (anchor, positive) pairs.
Negatives are sampled automatically from other items in the batch.
"""
return [
InputExample(texts=[pair["query"], pair["passage"]])
for pair in pairs
if pair.get("query") and pair.get("passage")
]
def fine_tune_embedding_model(
base_model_name: str,
training_pairs: List[Dict],
output_path: str,
epochs: int = 3,
batch_size: int = 16,
warmup_steps: int = 100
) -> SentenceTransformer:
"""
Fine-tune a sentence-transformers model on domain-specific query-passage pairs.
base_model_name: HuggingFace model identifier or local path.
training_pairs: List of dicts with "query" and "passage" keys.
output_path: Directory to save the fine-tuned model.
"""
model = SentenceTransformer(base_model_name)
logger.info(f"Loaded base model: {base_model_name}")
logger.info(f"Training on {len(training_pairs)} query-passage pairs")
examples = build_training_examples(training_pairs)
loader = DataLoader(examples, shuffle=True, batch_size=batch_size)
loss = MultipleNegativesRankingLoss(model)
total_steps = len(loader) * epochs
logger.info(f"Training for {epochs} epochs, {total_steps} total steps")
model.fit(
train_objectives=[(loader, loss)],
epochs=epochs,
warmup_steps=warmup_steps,
output_path=output_path,
show_progress_bar=True,
checkpoint_path=output_path,
checkpoint_save_steps=len(loader)
)
logger.info(f"Fine-tuned model saved to: {output_path}")
return model
Resultados de las pruebas: un corpus bancario antes y después del ajuste fino
Considere una prueba de rendimiento de recuperación realizada contra un corpus de políticas de crédito corporativo compuesto por 847 fragmentos extraídos de documentos normativos, matrices de aprobación, procedimientos mejorados de debida diligencia y directrices contra el lavado de dinero. El conjunto de evaluación está formado por 120 consultas que abarcan cuatro categorías: búsquedas factuales específicas, preguntas con umbrales, preguntas que requieren múltiples pruebas y preguntas de síntesis.
La ventaja del ajuste fino del dominio es más evidente en consultas de tipo umbral, como aquellas que solicitan el umbral de aprobación aplicable a clientes corporativos de alto riesgo en la UE. Es aquí donde las abreviaturas bancarias y el vocabulario regulatorio difieren más marcadamente de lo que un modelo de uso general ha visto durante el preentrenamiento; por lo tanto, cerrar esa brecha léxica genera el mayor aumento en la capacidad de recuperación. Las consultas basadas en múltiples fuentes y las de síntesis mejoran menos con el ajuste fino por sí solo, pero ganan considerablemente una vez que se aplica la recuperación híbrida sobre ellas.
Considere estos números como una ilustración de lo que puede lograrse con un proyecto de ajuste fino bien ejecutado en un conjunto de datos bancarios adecuadamente etiquetado, y no como una promesa. La composición de su propio corpus, la mezcla de consultas y la precisión en el etiquetado modificarán los resultados. Lo más importante es desglosar la calidad de la recuperación por tipo de consulta en lugar de presentar una sola puntuación combinada, ya que cada tipo de consulta tiende a fallar por razones diferentes.
Consideraciones de rendimiento para el embejecimiento por lotes a gran escala
Enviar 100,000 fragmentos a través de una API de embejecimiento uno por solicitud es lento y costoso. En cambio, las pipelines de embejecimiento de nivel profesional procesan sus entradas por lotes para aumentar el rendimiento, mantenerse dentro de los límites de velocidad, recuperarse eficazmente de las fallas y garantizar que la generación de resultados sea determinista.
Procesamiento por lotes a través de APIs de proveedores
El endpoint de embeddings de OpenAI permite hasta 2,048 entradas en una sola llamada. El endpoint Embed de Cohere tiene un límite de 96 textos por solicitud, a menos que se utilice su API por lotes dedicada para tareas más grandes. Ejecutar la inferencia localmente con sentence-transformers permite configurar tamaños de lote, limitados únicamente por la memoria de la GPU disponible.
import time
import logging
from typing import List, Optional
from openai import OpenAI, RateLimitError, APIError
logger = logging.getLogger(__name__)
client = OpenAI()
def embed_in_batches(
texts: List[str],
model: str = "text-embedding-3-large",
batch_size: int = 512,
max_retries: int = 3,
retry_delay: float = 2.0,
dimensions: Optional[int] = None
) -> List[List[float]]:
"""
Embed a large list of texts using batched API calls with retry logic.
texts: Pre-chunked text strings. Caller is responsible for ensuring
no text exceeds the model's token limit.
batch_size: Number of texts per API call. Stay well below the API limit
to avoid hitting per-request token limits.
dimensions: Optional Matryoshka dimension reduction for supported models.
"""
all_embeddings: List[List[float]] = []
total_batches = (len(texts) + batch_size - 1) // batch_size
for batch_idx in range(0, len(texts), batch_size):
batch = texts[batch_idx: batch_idx + batch_size]
current_batch = batch_idx // batch_size + 1
logger.info(f"Embedding batch {current_batch}/{total_batches} "
f"({len(batch)} texts)")
kwargs = {
"input": batch,
"model": model
}
if dimensions is not None:
kwargs["dimensions"] = dimensions
attempt = 0
while attempt < max_retries:
try:
response = client.embeddings.create(**kwargs)
# Preserve input order: API returns items sorted by index
sorted_items = sorted(response.data, key=lambda x: x.index)
all_embeddings.extend([item.embedding for item in sorted_items])
break
except RateLimitError:
attempt += 1
wait = retry_delay * (2 ** attempt)
logger.warning(f"Rate limit hit on batch {current_batch}. "
f"Waiting {wait:.1f}s before retry {attempt}/{max_retries}")
time.sleep(wait)
except APIError as e:
attempt += 1
logger.error(f"API error on batch {current_batch}: {e}. "
f"Retry {attempt}/{max_retries}")
if attempt >= max_retries:
raise
time.sleep(retry_delay)
logger.info(f"Embedding complete. Total vectors: {len(all_embeddings)}")
return all_embeddings
Ejecutar inferencia localmente con sentence-transformers
Algunas organizaciones enfrentan restricciones de residencia de datos que impiden enviar documentos de política a una API externa. En esos casos, la inferencia local con sentence-transformers es la solución más adecuada.
from sentence_transformers import SentenceTransformer
import numpy as np
from typing import List, Optional
import logging
logger = logging.getLogger(__name__)
class LocalEmbeddingPipeline:
"""
Production-ready local embedding pipeline using sentence-transformers.
Suitable for data-residency-constrained banking environments.
"""
def __init__(
self,
model_name_or_path: str,
device: str = "cpu",
batch_size: int = 64,
normalise: bool = True
):
self.model = SentenceTransformer(model_name_or_path, device=device)
self.batch_size = batch_size
self.normalise = normalise
self.device = device
logger.info(f"Loaded model: {model_name_or_path} on {device}")
def embed(
self,
texts: List[str],
show_progress: bool = True
) -> np.ndarray:
"""
Embed a list of texts. Returns an (N, D) numpy array.
Normalises to unit length if normalise=True (required for cosine similarity).
"""
embeddings = self.model.encode(
texts,
batch_size=self.batch_size,
show_progress_bar=show_progress,
normalize_embeddings=self.normalise,
convert_to_numpy=True
)
logger.info(f"Embedded {len(texts)} texts. "
f"Output shape: {embeddings.shape}")
return embeddings
def embed_query(self, query: str) -> np.ndarray:
"""Embed a single query. Returns a 1D array."""
return self.embed([query], show_progress=False)[0]
Verificar la longitud de los tokens antes de embeberlos
Si un fragmento supera el límite de tokens del modelo, se trunca sin previo aviso. En un corpus de políticas, esa truncación silenciosa puede eliminar exactamente la cláusula o el umbral numérico que hizo que ese fragmento valiera la pena recuperar en primer lugar. Validar el conteo de tokens antes del proceso de incrustación permite detectar este problema antes de que llegue a su índice.
from transformers import AutoTokenizer
from typing import List, Tuple
import logging
logger = logging.getLogger(__name__)
def validate_chunk_lengths(
chunks: List[str],
model_name: str,
max_tokens: int,
truncation_strategy: str = "warn"
) -> Tuple[List[str], List[int]]:
"""
Validate that all chunks are within the model's token limit.
truncation_strategy:
"warn" - Log a warning for oversized chunks and include them (will be truncated by model).
"skip" - Remove oversized chunks and return only valid ones.
"raise" - Raise ValueError on the first oversized chunk.
Returns (validated_chunks, oversized_indices).
"""
tokeniser = AutoTokenizer.from_pretrained(model_name)
oversized = []
for idx, chunk in enumerate(chunks):
token_count = len(tokeniser.encode(chunk, add_special_tokens=True))
if token_count > max_tokens:
oversized.append(idx)
msg = (f"Chunk {idx} has {token_count} tokens, "
f"exceeds model limit of {max_tokens}. "
f"First 80 chars: {chunk[:80]!r}")
if truncation_strategy == "raise":
raise ValueError(msg)
else:
logger.warning(msg)
if truncation_strategy == "skip" and oversized:
valid = [c for i, c in enumerate(chunks) if i not in set(oversized)]
logger.info(f"Removed {len(oversized)} oversized chunks. "
f"{len(valid)} chunks remain.")
return valid, oversized
return chunks, oversized
Asociación de metadatos y procedencia a las incrustaciones
Un vector de incrustación en bruto no es suficiente por sí solo para que un sistema RAG regulado funcione correctamente. Cada vector necesita metadatos estructurados asociados a él, de modo que las etapas de recuperación, reclasificación y generación posteriores puedan determinar la fuente del contenido, aplicar permisos de acceso, restringir los resultados según la jurisdicción y remitir al documento fuente autorizado.
Volviendo al escenario de la propuesta de crédito de 12 millones de euros, cada fragmento incrustado debe incluir, como mínimo, los campos que se muestran aquí:
from dataclasses import dataclass, field
from typing import Optional, List
import uuid
@dataclass
class EmbeddedChunk:
"""
Production embedding record for a banking policy RAG system.
The vector enables retrieval. The metadata enables everything else.
"""
# Vector
vector: List[float]
vector_dimensions: int
embedding_model: str
embedding_model_version: str
# Content
text: str
content_type: str # "narrative", "table_row", "proposition", "image_description"
# Provenance
document_id: str
document_version: str # e.g. "7.2"
policy_id: Optional[str] # e.g. "CRD-EU-047"
jurisdiction: Optional[str] # e.g. "EU"
effective_date: Optional[str]
# Chunk structure
chunk_id: str = field(default_factory=lambda: str(uuid.uuid4()))
parent_id: Optional[str] = None
section: Optional[str] = None
page_number: Optional[int] = None
source_artifact_path: Optional[str] = None # path to original image/table
# Access control
classification: str = "INTERNAL" # "PUBLIC", "INTERNAL", "CONFIDENTIAL"
permitted_roles: List[str] = field(default_factory=list)
# Indexing
indexed_at: Optional[str] = None
indexing_pipeline_version: Optional[str] = None
Aplicar estos metadatos de manera consistente en todas las etapas del proceso, desde la división en fragmentos hasta su incrustación y el indexado vectorial, no solo es una buena práctica. En un contexto bancario regulado, obtener una respuesta técnicamente correcta basada en una versión de política obsoleta se considera un incumplimiento normativo. La función del vector es encontrar el fragmento; la función de los metadatos es confirmar que dicho fragmento proviene de la versión correcta y actual del contenido original.
Evaluación de la calidad de la incrustación
Las listas de clasificación públicas como MTEB muestran las puntuaciones generales de recuperación en una amplia gama de conjuntos de datos académicos. Esos números son útiles para descartar modelos cuyo rendimiento es claramente deficiente. Sin embargo, resultan insuficientes cuando la tarea consiste en elegir el mejor modelo para un corpus especializado, como la biblioteca de políticas internas de un banco.
La única métrica que realmente importa es el rendimiento de un modelo con respecto a sus propios documentos, utilizando sus propias consultas y evaluándolo según los criterios de relevancia que usted mismo haya definido.
Creación de un conjunto de evaluación para recuperación
Un conjunto de pruebas de recuperación diseñado para un pipeline de incrustación RAG en banca debe abarcar varios tipos de consultas:
Búsquedas factuales específicas que se corresponden con un único fragmento fiable, como preguntar con qué frecuencia, como mínimo, deben someterse a revisión anual los clientes corporativos de alto riesgo.
Preguntas basadas en umbrales que combinan una condición numérica específica con la regla de gobernanza asociada, como preguntar qué autoridad de aprobación se requiere cuando una instalación empresarial de la UE supera los 10 millones de euros.
Preguntas con múltiples fuentes de información, donde la respuesta completa depende de reunir más de un elemento, como preguntar qué verificaciones deben realizarse antes de poder presentar siquiera una propuesta de crédito empresarial de alto riesgo.
Preguntas de síntesis que extraen contenido de varias secciones al mismo tiempo, como solicitar una descripción completa del marco de control contra el lavado de dinero que rige los préstamos empresariales de alto riesgo.
Preguntas entre documentos, relevantes siempre que las políticas se refieren mutuamente en documentos separados.
import numpy as np
from typing import List, Dict, Set
def recall_at_k(
retrieved_ids: List[str],
relevant_ids: Set[str],
k: int
) -> float:
"""
Compute Recall@k for a single query.
relevant_ids is the ground truth set of chunk identifiers.
retrieved_ids is the ordered list of retrieved chunk identifiers.
"""
if not relevant_ids:
return 0.0
top_k_retrieved = set(retrieved_ids[:k])
return len(top_k_retrieved & relevant_ids) / len(relevant_ids)
def mean_reciprocal_rank(
retrieved_ids: List[str],
relevant_ids: Set[str]
) -> float:
"""Compute MRR for a single query."""
for rank, chunk_id in enumerate(retrieved_ids, start=1):
if chunk_id in relevant_ids:
return 1.0 / rank
return 0.0
def evaluate_embedding_model(
model_name: str,
evaluation_queries: List[Dict],
corpus_chunks: List[Dict],
k_values: List[int] = [1, 5, 10, 20]
) -> Dict:
"""
Evaluate an embedding model on a labelled retrieval dataset.
evaluation_queries: List of dicts with "query" and "relevant_chunk_ids" keys.
corpus_chunks: List of dicts with "chunk_id" and "text" keys.
Returns per-query-type and aggregate retrieval metrics.
"""
from sentence_transformers import SentenceTransformer
model = SentenceTransformer(model_name)
corpus_texts = [c["text"] for c in corpus_chunks]
corpus_ids = [c["chunk_id"] for c in corpus_chunks]
corpus_embeddings = model.encode(corpus_texts, normalize_embeddings=True)
results_by_type: Dict[str, List] = {}
all_recall: Dict[int, List[float]] = {k: [] for k in k_values}
all_mrr: List[float] = []
for query_item in evaluation_queries:
query = query_item["query"]
relevant = set(query_item["relevant_chunk_ids"])
query_type = query_item.get("query_type", "unspecified")
query_embedding = model.encode(query, normalize_embeddings=True)
scores = corpus_embeddings @ query_embedding
ranked_indices = np.argsort(scores)[::-1]
retrieved = [corpus_ids[i] for i in ranked_indices]
mrr = mean_reciprocal_rank(retrieved, relevant)
all_mrr.append(mrr)
for k in k_values:
r = recall_at_k(retrieved, relevant, k)
all_recall[k].append(r)
if query_type not in results_by_type:
results_by_type[query_type] = {"mrr": [], "recall": {k: [] for k in k_values}}
results_by_type[query_type]["mrr"].append(mrr)
for k in k_values:
results_by_type[query_type]["recall"][k].append(
recall_at_k(retrieved, relevant, k)
)
aggregate = {
"model": model_name,
"n_queries": len(evaluation_queries),
"mrr": float(np.mean(all_mrr)),
"recall": {k: float(np.mean(all_recall[k])) for k in k_values}
}
per_type = {
qt: {
"mrr": float(np.mean(data["mrr"])),
"recall": {k: float(np.mean(data["recall"][k])) for k in k_values},
"n_queries": len(data["mrr"])
}
for qt, data in results_by_type.items()
}
return {"aggregate": aggregate, "by_query_type": per_type}
Las métricas de recuperación no deben evaluarse de forma aislada, sin considerar cómo afectan la calidad de la respuesta final. Supongamos que Recall@5 mejora en tres puntos, pero la tasa de fragmentos casi idénticos recuperados aumenta un 30 por ciento: ese equilibrio podría no beneficiar realmente al LLM, ya que proporcionarle tres pasajes casi idénticos en lugar de uno útil no aporta evidencia real.
El enfoque correcto es evaluar toda la cadena, desde la consulta inicial hasta la respuesta final que se presenta al usuario. El papel de un modelo de embedding se limita a introducir la evidencia adecuada en la ventana de contexto. Si dicha evidencia conduce a una respuesta que sea al mismo tiempo precisa y conforme, eso lo determinan todas las etapas posteriores.
La pipeline de embedding como infraestructura
Una vez que un fragmento termina de pasar por el proceso de incrustación, debe salir del otro lado con un vector asociado, metadatos completamente completados y un identificador estable que permita recuperarlo, actualizarlo o eliminarlo posteriormente sin dañar los registros adyacentes en el índice.
Se trata de una parte de la infraestructura, no de un script único. Los procesos de incrustación de nivel profesional para entornos bancarios regulados requieren lo siguiente:
- Idempotencia. Si un fragmento se vuelve a incrustar debido al cambio de versiones del modelo, eso debería sobrescribir el registro existente, no generar un duplicado.
Nada de esto es opcional una vez que se está en producción. Estas son las características que diferencian un pipeline que solo funciona en una demostración de uno que se puede operar, auditar y mantener a lo largo del tiempo dentro de un entorno regulado.
Volver a la propuesta de crédito de 12 millones de euros
La pregunta original del gestor de relaciones no ha cambiado: ¿qué autoridad de aprobación necesita este acuerdo, y qué controles deben cumplirse antes de que pueda ser presentado?
- La Parte 4 explicó cómo diseñar unidades de recuperación que mantengan esas respuestas intactas en su forma original: la cláusula de política EDD, la fila de la matriz de aprobación que especifica la autoridad del GCC y el diagrama de flujo de trabajo que detalla los controles previos a la presentación.
- En esta parte, hemos convertido esas unidades de recuperación en vectores buscables, utilizando un modelo que fue probado con terminología específica del sector bancario, ajustado para comprender cómo las abreviaturas regulatorias se relacionan con su contexto de cumplimiento, e integrado junto con metadatos completos de procedencia que permiten confirmar posteriormente la versión de la política y la jurisdicción aplicable.
Cuando llega la consulta, el índice vectorial localiza la fila de la matriz de aprobación que abarca exposiciones de alto riesgo superiores a 10 millones de euros, la cláusula EDD y el diagrama que muestra la secuencia de control; luego las devuelve junto con metadatos que confirman que los tres se relacionan con la póliza CRD-EU-047, versión 7.2, jurisdicción de la UE, con vigencia a partir del 15 de enero de 2026.
Lo que llega a la capa de generación son pruebas precisas, completas y rastreables.
Esa es la diferencia entre un pipeline de incrustación que simplemente carga fragmentos en una base de datos vectorial y uno que conserva todo lo necesario para obtener respuestas fiables y auditables.
Antes de pasar a la indexación vectorial: una lista de verificación
Antes de introducir los fragmentos incrustados en el índice vectorial, confirme lo siguiente:
- ¿Está configurado correctamente el parámetro de tipo de entrada en el modelo de incrustación tanto para las llamadas de indexación como para las de consulta? Esta falta de coincidencia erosiona silenciosamente la precisión en la recuperación de información.
- ¿Se ha verificado la longitud de los tokens de cada fragmento antes de incrustarlo? La truncación silenciosa modifica lo que realmente representa el texto incrustado, sin que se genere ningún error en ninguna etapa del proceso.
- ¿Cada registro vectorial incluye toda la información de origen: versión del documento, ID de la política, jurisdicción y fecha de vigencia?
- ¿Se probó realmente el modelo de incrustación con su propio corpus de dominio y patrones de consulta, en lugar de elegirlo únicamente basándose en las clasificaciones de pruebas públicas?
- Si ajustó el modelo, ¿dispone de cifras de recuperación antes y después medidas en un conjunto de consultas separado?
Si falta alguno de estos requisitos, el índice vectorial no lo detectará por usted; almacenará sin problemas todo lo que le proporcione. Nada en la base de datos indicará si un vector fue creado a partir de un fragmento truncado, una consulta con el tipo de entrada incorrecto o un fragmento incluido utilizando una versión del modelo desincronizada con el resto del índice. Esos defectos no se manifiestan de inmediato; reaparecen más tarde como problemas en la calidad de la recuperación que, desde el exterior, parecen ser problemas del LLM.
En una etapa anterior de esta serie, la Parte 4 mostró cómo convertir documentos limpios en unidades de recuperación manteniendo intacta su estructura. Esta parte ha explicado cómo transformar esas unidades de recuperación en vectores buscables sin alterar su origen.
A continuación se aborda cómo esos vectores se integran realmente en el índice: búsqueda vectorial densa, recuperación dispersa, enfoques híbridos, algoritmos de vecino más cercano aproximado, y el filtrado de metadatos que determina qué vectores pueden ser considerados para la recuperación desde un principio.
Lecturas relacionadas
- Arquitectura de referencia para sistemas de IA agente a nivel empresarial — Conozca las capas fundamentales, las estrategias de memoria, los mecanismos de recuperación y las medidas de seguridad necesarias para pasar de prototipos a sistemas empresariales fiables basados en IA agente.
- Bases de datos vectoriales explicadas: el motor que está detrás de RAG y la búsqueda con IA — Aprenda cómo las bases de datos vectoriales convierten texto en embeddings, potencian la búsqueda semántica y los pipelines RAG, e impulsan aplicaciones de IA en el mundo real como las recomendaciones.