StackPractices
intermediate Por Mathias Paulenko

RAG con LangChain y bases vectoriales

Cómo construir un pipeline de Retrieval-Augmented Generation (RAG) usando LangChain y bases de datos vectoriales para búsqueda potenciada por IA

Temas: ai

Visión General

Retrieval-Augmented Generation (RAG) combina un LLM con un paso de recuperación: en lugar de responder solo desde lo que el modelo memorizó durante el entrenamiento, el pipeline extrae los pasajes relevantes de tus propios documentos y los inyecta en el prompt. Esa fundamentación reduce notablemente las alucinaciones y permite al modelo responder preguntas sobre datos privados o recién actualizados.

Esta receta construye el pipeline completo: cargar y fragmentar documentos, generar embeddings, almacenarlos en una base de datos vectorial, recuperar los fragmentos más relevantes en cada consulta y generar la respuesta con LangChain. Los ejemplos usan los paquetes actuales de LangChain (langchain-openai, langchain-chroma, langchain-community) — los imports antiguos tipo from langchain.embeddings import … que todavía aparecen en tutoriales viejos ya no funcionan en una instalación nueva.

Cuándo Usar

Usa este recurso cuando:

  • Quieres que un LLM responda preguntas basadas en tus propios documentos, no solo en sus datos de entrenamiento
  • Necesitas respuestas actualizadas que el modelo base no ha visto
  • Quieres reducir alucinaciones en aplicaciones de dominio específico
  • Estás construyendo una base de conocimiento, un bot de soporte o un sistema de preguntas sobre documentos

No uses RAG cuando todo el corpus cabe en la ventana de contexto del modelo: pegar 30 documentos en el prompt es más simple y barato que mantener una infraestructura de recuperación.

Solución

Python

from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_chroma import Chroma
from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain_core.prompts import ChatPromptTemplate

# 1. Cargar y fragmentar documentos
loader = TextLoader("docs/knowledge_base.txt")
docs = loader.load()
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(docs)

# 2. Embeber fragmentos y almacenarlos en la BD vectorial
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma.from_documents(
    chunks, embeddings, persist_directory="./chroma_db"
)

# 3. Conectar recuperador + LLM en una cadena de recuperación
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
prompt = ChatPromptTemplate.from_template(
    "Responde usando solo este contexto. Si la respuesta no está en "
    "el contexto, di que no lo sabes.\n\nContexto:\n{context}\n\n"
    "Pregunta: {input}"
)
qa_chain = create_retrieval_chain(
    vectorstore.as_retriever(search_kwargs={"k": 4}),
    create_stuff_documents_chain(llm, prompt),
)

# 4. Consultar
result = qa_chain.invoke({"input": "What is the refund policy?"})
print(result["answer"])
for doc in result["context"]:
    print(f"Fuente: {doc.metadata.get('source')}")

JavaScript

import { TextLoader } from 'langchain/document_loaders/fs/text';
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
import { OpenAIEmbeddings, ChatOpenAI } from '@langchain/openai';
import { Chroma } from '@langchain/community/vectorstores/chroma';
import { createRetrievalChain } from 'langchain/chains/retrieval';
import { createStuffDocumentsChain } from 'langchain/chains/combine_documents';
import { ChatPromptTemplate } from '@langchain/core/prompts';

async function buildRag() {
  // 1. Cargar y fragmentar
  const loader = new TextLoader('docs/knowledge_base.txt');
  const docs = await loader.load();
  const splitter = new RecursiveCharacterTextSplitter({ chunkSize: 500, chunkOverlap: 50 });
  const chunks = await splitter.splitDocuments(docs);

  // 2. Embeber y almacenar
  const embeddings = new OpenAIEmbeddings({ model: 'text-embedding-3-small' });
  const vectorstore = await Chroma.fromDocuments(chunks, embeddings, {
    collectionName: 'knowledge_base',
  });

  // 3. Conectar recuperador + LLM
  const llm = new ChatOpenAI({ model: 'gpt-4o-mini', temperature: 0 });
  const prompt = ChatPromptTemplate.fromTemplate(
    'Responde usando solo este contexto.\n\nContexto:\n{context}\n\nPregunta: {input}'
  );
  const chain = await createRetrievalChain({
    retriever: vectorstore.asRetriever({ k: 4 }),
    combineDocsChain: await createStuffDocumentsChain({ llm, prompt }),
  });

  // 4. Consultar
  const result = await chain.invoke({ input: 'What is the refund policy?' });
  console.log(result.answer);
}

buildRag();

Java

// RAG en Java con Spring AI 1.x
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;

// 1. Cargar y dividir documentos
var reader = new TextReader("docs/knowledge_base.txt");
List<Document> documents = reader.get();
var splitter = TokenTextSplitter.builder().withChunkSize(500).build();
List<Document> chunks = splitter.split(documents);

// 2. Almacenar embeddings (vectorStore se autoconfigura: Chroma, PGvector, ...)
vectorStore.add(chunks);

// 3. Recuperar fragmentos relevantes
List<Document> relevant = vectorStore.similaritySearch(
    SearchRequest.builder().query("What is the refund policy?").topK(4).build()
);

// 4. Construir el prompt aumentado
String context = relevant.stream()
    .map(Document::getText)
    .collect(Collectors.joining("\n---\n"));
Prompt prompt = new Prompt(List.of(
    new SystemMessage("Responde usando solo el contexto proporcionado.\nContexto:\n" + context),
    new UserMessage("What is the refund policy?")
));
String answer = chatModel.call(prompt).getResult().getOutput().getText();

Cómo Funciona el Pipeline

flowchart diagram: Docs

Cuatro etapas, y cada una puede fallar de forma independiente:

  1. Ingesta: los documentos se cargan, limpian y dividen en fragmentos. Para el manejo de archivos, consulta validación de archivos. El tamaño de fragmento (normalmente 200–1000 tokens) equilibra granularidad frente a preservación de contexto.
  2. Embedding: cada fragmento se convierte en un vector de alta dimensión. Los vectores capturan significado semántico, no coincidencia de palabras clave.
  3. Recuperación: la pregunta del usuario también se embebe, y la base vectorial devuelve los k fragmentos más similares por similitud coseno o distancia euclidiana. Cuando nada supera tu umbral de relevancia, el pipeline debe responder con un fallback en lugar de adivinar.
  4. Generación: los fragmentos recuperados entran en el prompt como contexto, y el modelo responde fundamentado en ese texto.

Compromisos:

  • Tamaño de fragmento: fragmentos más pequeños dan recuperación precisa pero contexto escaso; los grandes diluyen la relevancia.
  • Valor k: demasiado bajo pierde contexto; demasiado alto desperdicia tokens y puede confundir al modelo.
  • La calidad del modelo de embeddings condiciona la precisión de recuperación más que cualquier otra decisión.

Variantes

TecnologíaEnfoqueNotas
LangChainOrquestación + chainingEcosistema maduro, maneja plantillas de prompt y parsing de salida
LlamaIndexFramework centrado en datosConectores integrados para PDFs, SQL, APIs; mejor para datos complejos
HaystackPipeline modularFuerte en evaluación y monitoreo de producción
Bases vectorialesChroma / Pinecone / Weaviate / pgvectorChroma para local, Pinecone gestionado, pgvector si ya usas Postgres
EmbeddingsOpenAI / Cohere / locales (BGE, E5)OpenAI por calidad; modelos locales para privacidad y costo

Buenas Prácticas

  • Añade metadatos (archivo fuente, número de página) a cada fragmento para que las respuestas puedan citar su origen.
  • Usa chunk_overlap (10–20% del tamaño) para que los cortes no caigan a mitad de una oración.
  • Filtra los fragmentos recuperados con un umbral de relevancia; inyectar texto sin relación perjudica más de lo que ayuda.
  • Re-ordena con un cross-encoder (bge-reranker, Cohere Rerank) después de la primera pasada vectorial. Puntúa los pares consulta-fragmento directamente y mejora la precisión.
  • Usa fragmentación padre-hijo en documentos densos: indexa fragmentos pequeños (~200 tokens) para la recuperación, pero entrega al LLM el fragmento padre (~1000 tokens) como contexto.
  • Implementa indexación incremental: registra las versiones de cada documento y re-embebe solo lo que cambió, en lugar de reconstruir todo el corpus.
  • Registra los fragmentos recuperados, sus puntuaciones de similitud y el feedback del usuario por consulta. No puedes mejorar una calidad de recuperación que no mides. Sigue recall@k y MRR a lo largo del tiempo y alerta cuando caigan.
  • Añade una capa de guardrails antes de llamar al LLM: filtra los fragmentos por PII, contenido tóxico o material fuera de tema.

Errores Comunes

  • Fragmentos demasiado grandes: un fragmento de 2000 tokens diluye la relevancia y desperdicia contexto del prompt. Ajusta el tamaño por tipo de documento: documentación de código, contratos y noticias se fragmentan de forma distinta.
  • Sin overlap: cortar a mitad de oración pierde contexto en los límites entre fragmentos.
  • No deduplicar: ingerir el mismo documento dos veces sesga la recuperación y desperdicia almacenamiento. Usa un hash de contenido o el ID del documento antes de insertar.
  • Métrica de distancia equivocada: coseno y euclidiana producen rankings distintos. Los embeddings de OpenAI esperan coseno; revisa con qué se entrenó tu modelo.
  • Almacenar HTML/PDF en crudo: extrae primero el texto limpio; el ruido del marcado contamina los embeddings.
  • Fragmentos diminutos o vacíos: los fragmentos de menos de ~10 tokens producen embeddings ruidosos. Fíltralos o fusiónalos con los adyacentes durante la ingesta.
  • Ignorar el versionado de embeddings: cambiar de modelo de embeddings invalida todos los vectores almacenados. Registra la versión del modelo en la configuración y re-indexa al cambiar.
  • Sin comportamiento de fallback: cuando nada supera el umbral, el LLM debe decir “no lo sé” en lugar de improvisar desde sus datos de entrenamiento.
  • Evaluar solo con consultas sintéticas: los usuarios reales formulan las preguntas de otra manera que tu conjunto de prueba. Extrae el corpus de evaluación de los logs de producción.
  • Sin metadatos: sin fuente, página o sección no puedes filtrar ni citar resultados. Adjúntalos en el momento de la ingesta.

Solución de Problemas

  • Respuestas inconsistentes entre ejecuciones: fija temperature: 0, usa una semilla donde se soporte y versiona la plantilla del prompt.
  • Prompt injection a través del contenido recuperado: los fragmentos recuperados son entrada no confiable. Mantén el texto del usuario y las instrucciones del sistema claramente separados, y considera filtrar los fragmentos antes de generar.
  • Costos de tokens altos: cachea los embeddings, limita el contexto recuperado y deriva las preguntas simples a un modelo más pequeño.
  • La recuperación devuelve fragmentos irrelevantes: ajusta el tamaño de fragmento, el overlap y los filtros de metadatos. Mide la calidad de recuperación por separado de la generación: una buena respuesta construida sobre fragmentos equivocados es suerte, no señal.
  • Las puntuaciones de evaluación no coinciden con el juicio humano: define una rúbrica, usa más de un juez (humano o LLM) y registra los desacuerdos. La revisión humana sigue siendo la referencia.

Checklist de Producción

  • Fragmentos deduplicados por hash de contenido antes de insertar
  • La métrica de distancia coincide con el modelo de embeddings (coseno para OpenAI)
  • Fragmentos vacíos o de menos de 10 tokens filtrados en la ingesta
  • Versión del modelo de embeddings registrada en la configuración
  • Metadatos en cada fragmento (fuente, página, sección)
  • Tamaño de fragmento y overlap ajustados por tipo de documento
  • Respuesta de fallback cuando ningún fragmento supera el umbral de relevancia
  • Modelo de re-ranking aplicado tras la recuperación vectorial inicial
  • Pipeline de indexación incremental para actualizaciones de documentos
  • Métricas de recuperación (recall@k, MRR) monitorizadas y con alertas

Consideraciones de Escalado

  • Elección de base vectorial: por debajo de ~100K documentos, pgvector es suficiente y te ahorra un servicio aparte. Entre 100K y ~1M, los tiers gestionados de Pinecone o Weaviate son la vía práctica. Por encima de 1M, auto-hospeda Milvus o Qdrant con índices acelerados por GPU.
  • Costo de embedding: indexar 100K documentos con text-embedding-3-small cuesta alrededor de $1, pero re-embeber todo el corpus en cada cambio se acumula, así que la indexación incremental importa a escala.
  • Latencia de recuperación: la búsqueda vectorial sobre 1M de documentos tarda 10–50 ms con un índice HNSW; el re-ranking con cross-encoder añade 50–100 ms. Para menos de 100 ms totales, usa búsqueda ANN y re-ordena solo el top-20.
  • Ventana de contexto: GPT-4o acepta 128K tokens, pero pasar más de ~10K tokens de contexto aumenta el costo y puede degradar las respuestas. Entrega los 3–5 fragmentos superiores tras el re-ranking.
  • Frecuencia de actualización: para contenido que cambia a menudo (noticias, catálogos), dispara la re-indexación desde webhooks; para contenido estático (documentación, políticas), un batch nocturno es suficiente.

Estimación de Costos

ComponenteCostoNotas
Embedding (text-embedding-3-small)$0.02/1M tokensAPI de OpenAI
Embedding (e5-large auto-hospedado)$0Solo costo de GPU
Base vectorial (Pinecone starter)$0100K vectores, 1 namespace
Base vectorial (Pinecone standard)$70/mes5M vectores, namespaces ilimitados
Generación LLM (GPT-4o)$5/1M entrada, $15/1M salidaPor consulta: ~2K entrada + 500 salida
Generación LLM (GPT-4o-mini)$0.15/1M entrada, $0.60/1M salida30x más barato, bueno para Q&A simple

Con 10K consultas al día sobre GPT-4o estás hablando de unos $150/día. Derivar las preguntas simples a GPT-4o-mini reduce eso en torno a un 90%.

Cuándo No Usar RAG

  • Corpus pequeño (<50 documentos): si todo cabe en la ventana de contexto, pégalo directamente; RAG añade infraestructura que no necesitas.
  • Se requieren respuestas literales: RAG devuelve fragmentos, no documentos completos. Para cláusulas legales o muestras de código donde el texto exacto importa, usa búsqueda por palabras clave sobre documentos completos.
  • Datos muy estructurados: las tablas y formularios embeben mal; una consulta SQL suele ganar a la similitud vectorial ahí.
  • Requisitos de tiempo real: los índices son eventualmente consistentes. Si la respuesta debe reflejar datos de hace segundos, consulta la fuente directamente.
  • Citación por afirmación requerida: RAG puede mostrar los fragmentos fuente pero no garantiza que cada oración se trace a una de ellas. Los casos legales o académicos necesitan generación restringida a citas.

Benchmarks de Rendimiento

ComponenteLatenciaThroughputNotas
Embedding (text-embedding-3-small)50-200ms10K tokens/reqAPI de OpenAI
Búsqueda vectorial (pgvector, 100K docs)5-20ms100 QPSÍndice HNSW
Búsqueda vectorial (Pinecone, 1M docs)10-50ms200 QPSServicio gestionado
Re-ranking (cross-encoder)50-100ms20 docs/batchbge-reranker-large
Generación LLM (GPT-4o)1-5s50 concurrentesStreaming habilitado

De extremo a extremo, espera entre 200 ms y 6 s por consulta, y la generación del LLM domina. Los embeddings pre-calculados y el caché de consultas frecuentes bajan el p99.

Lectura Adicional

  • Tutorial de recuperación de LangChain: la guía canónica para el montaje de la cadena usado arriba.
  • pgvector: similitud vectorial dentro de PostgreSQL; el punto de partida con menos fricción si ya usas Postgres.
  • Ragas: evaluación automatizada de RAG; combina bien con las métricas del FAQ de abajo. Ver también evaluación RAG con Ragas.
  • Lewis et al., Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks (2020) — el paper que bautizó la técnica.

Preguntas frecuentes

¿Qué tamaño de fragmento debo usar?

Empieza con 500 tokens y 50 tokens de overlap. Evalúa con consultas reales y ajusta según la densidad del contenido: la documentación de código suele pedir fragmentos más pequeños, los contratos legales más grandes.

¿Puedo usar RAG sin LangChain?

Sí. LangChain solo simplifica la orquestación. La versión manual es: dividir texto → embeber vía API → almacenar en cualquier base vectorial → búsqueda por similitud → formatear el prompt → llamar al LLM.

¿Cómo manejo las actualizaciones de documentos?

Clave los fragmentos por ID de documento. Al actualizar, borra los fragmentos antiguos e inserta los nuevos. Para corpus grandes, usa indexación incremental guiada por timestamps o checksums.

¿Cómo evalúo la calidad del RAG?

Construye un conjunto de prueba de 50–100 preguntas con respuestas conocidas y mide dos cosas por separado:

  1. Precisión de recuperación: ¿la búsqueda vectorial devolvió los fragmentos correctos? Comprueba si el documento fuente aparece en el top-k.
  2. Corrección de la respuesta: ¿el LLM produjo una respuesta correcta a partir de ese contexto? Usa evaluación humana o LLM-as-judge con una rúbrica.

Sigue ambas métricas a lo largo del tiempo al cambiar el tamaño de fragmento, el modelo de embeddings o el prompt. Ragas y TruLens automatizan la mayor parte.

¿Qué modelo de embeddings debería usar?

text-embedding-3-small (OpenAI) es el estándar por equilibrio calidad/costo. Para datos sensibles a la privacidad, ejecuta all-MiniLM-L6-v2 con sentence-transformers en local. Para corpus multilingües, multilingual-e5-large o los embeddings multilingües de Cohere funcionan mejor.

¿Cómo manejo documentos multi-formato (PDF, HTML, Markdown)?

Usa los loaders de LangChain: PyPDFLoader para PDF, UnstructuredHTMLLoader para HTML, TextLoader para Markdown — y pasa todo por el mismo pipeline de fragmentación y embeddings. Guarda el formato fuente en los metadatos para poder filtrar después.

¿Puedo usar RAG con una base de datos SQL en lugar de una vectorial?

Sí. pgvector almacena embeddings dentro de PostgreSQL, así que puedes mezclar similitud vectorial con filtros SQL ("documentos sobre Python publicados después de 2024-01-01"). Para MySQL, usa su soporte vectorial nativo o monta una base vectorial aparte.