Hace unos meses recibimos un encargo que, a primera vista, parecía sencillo: «Queremos poder preguntarle cosas a nuestros contratos».
El cliente era un despacho de abogados con más de doce años de historial documental — cientos de contratos, dictámenes, acuerdos de confidencialidad y resoluciones judiciales acumulados en carpetas de red. La búsqueda era mediante nombre de archivo. Cualquier consulta transversal — «¿en qué contratos tenemos una cláusula de no competencia con duración superior a dos años?» — requería abrir documentos uno a uno.
Lo que parecía sencillo acabó siendo un proyecto de ocho semanas con más de una sorpresa. Aquí está el relato honesto de lo que construimos, lo que fallamos, y lo que volvería a hacer exactamente igual.
El sistema que diseñamos: RAG desde cero
La arquitectura que elegimos se llama RAG — Retrieval-Augmented Generation. La idea es simple en teoría:
- Procesa y trocea los documentos en fragmentos (chunks)
- Convierte cada fragmento en un vector numérico (embedding)
- Almacena esos vectores en una base de datos especializada
- Cuando el usuario hace una pregunta, búscala en el espacio vectorial
- Pasa los fragmentos más relevantes al modelo de lenguaje como contexto
- El modelo responde usando solo esa información — y puede citar la fuente
Usuario pregunta
│
▼
Embed pregunta ──────────────────────────────────────┐
│ │
▼ Vector DB
Búsqueda semántica ───── Top-K fragmentos ────────────┘
(similaridad coseno) │
▼
Prompt construido
+ Contexto relevante
│
▼
GPT-4o mini
│
▼
Respuesta + fuentes citadas
En la práctica, cada una de esas flechas tiene más complejidad de la que parece. Aquí están los detalles.
La ingesta: donde se gana o se pierde todo
El primer error que cometimos fue subestimar el preprocesado. Los documentos llegaban en una mezcla de .docx, .pdf y —esto fue una sorpresa— PDFs escaneados sin capa de texto.
Para los documentos digitales usamos pdf-parse y mammoth desde TypeScript. Para los escaneados, integramos Tesseract.js con un pipeline de limpieza:
import Tesseract from 'tesseract.js';
import { createCanvas } from 'canvas';
async function extractTextFromScannedPDF(pdfBuffer: Buffer): Promise<string> {
const pages = await convertPDFToImages(pdfBuffer); // pdf2pic
const texts: string[] = [];
for (const page of pages) {
const { data } = await Tesseract.recognize(page, 'spa', {
logger: () => {}, // silenciar en producción
});
texts.push(data.text);
}
return texts.join('\n\n').replace(/\s{3,}/g, '\n\n').trim();
}
El problema del chunking
Una vez con el texto limpio, hay que dividirlo en fragmentos. La decisión aquí tiene un impacto enorme en la calidad de las respuestas.
Probamos tres estrategias:
| Estrategia | Tamaño | Resultado |
|---|---|---|
| Chunks fijos (512 tokens) | Uniforme | Cortaba cláusulas a mitad — contexto roto |
| Por párrafo | Variable | Mejor, pero párrafos de 3 palabras generaban ruido |
| Semántica + solapamiento | ~400 tokens, 10% overlap | ✓ Óptimo para este caso |
La estrategia ganadora utiliza solapamiento entre chunks para que una cláusula que cruza el límite siga siendo recuperable:
function chunkDocument(text: string, chunkSize = 400, overlap = 40): string[] {
const sentences = text.match(/[^.!?]+[.!?]+/g) ?? [text];
const chunks: string[] = [];
let current: string[] = [];
let tokenCount = 0;
for (const sentence of sentences) {
const tokens = sentence.split(/\s+/).length;
if (tokenCount + tokens > chunkSize && current.length > 0) {
chunks.push(current.join(' '));
// Solapamiento: conserva las últimas N "palabras" del chunk anterior
const overlapSentences = current.slice(-Math.ceil(current.length * (overlap / 100)));
current = [...overlapSentences];
tokenCount = overlapSentences.join(' ').split(/\s+/).length;
}
current.push(sentence);
tokenCount += tokens;
}
if (current.length > 0) chunks.push(current.join(' '));
return chunks;
}
Los embeddings y la elección del vector store
Para los embeddings usamos text-embedding-3-small de OpenAI. Con un corpus de ~18.000 fragmentos, el coste total de embedding inicial fue de menos de 0,80 €. La actualización incremental cuando llega un documento nuevo cuesta céntimos.
Para almacenar los vectores valoramos tres opciones:
| Opción | Ventaja | Inconveniente |
|---|---|---|
| pgvector (PostgreSQL) | Infraestructura existente, sin coste adicional | Requiere tuning para escala alta |
| Pinecone | Servicio gestionado, simple | Coste mensual, dependencia externa |
| Qdrant (self-hosted) | Alto rendimiento, gratuito | Requiere gestionar otro servicio |
Elegimos pgvector porque el cliente ya tenía PostgreSQL en producción y los 18.000 vectores estaban muy por debajo del punto donde el rendimiento se degrada. Una consulta de similaridad tarda ~8ms.
-- Extensión pgvector
CREATE EXTENSION IF NOT EXISTS vector;
-- Tabla de fragmentos
CREATE TABLE document_chunks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
content TEXT NOT NULL,
embedding vector(1536),
page_number INTEGER,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Índice HNSW para búsqueda aproximada eficiente
CREATE INDEX ON document_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
La búsqueda semántica desde TypeScript:
import { openai } from './openai-client';
import { db } from './db';
async function semanticSearch(query: string, topK = 6) {
// 1. Embed la pregunta del usuario
const embeddingResponse = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: query,
});
const queryVector = embeddingResponse.data[0].embedding;
// 2. Buscar los K fragmentos más similares
const results = await db.query<{
id: string;
content: string;
document_name: string;
page_number: number;
similarity: number;
}>(
`SELECT
dc.id,
dc.content,
d.name AS document_name,
dc.page_number,
1 - (dc.embedding <=> $1) AS similarity
FROM document_chunks dc
JOIN documents d ON d.id = dc.document_id
ORDER BY dc.embedding <=> $1
LIMIT $2`,
[`[${queryVector.join(',')}]`, topK]
);
return results.rows.filter(r => r.similarity > 0.72);
}
El umbral 0.72 lo ajustamos empíricamente. Por debajo de ese valor, los fragmentos recuperados dejaban de ser relevantes para la pregunta.
El prompt: más artesanía de lo que esperábamos
Esta es la parte que más subestiman los proyectos de IA: el diseño del prompt del sistema. Aquí está la versión que mejor funcionó, después de unas diez iteraciones:
const SYSTEM_PROMPT = `Eres un asistente jurídico especializado en analizar documentos legales.
INSTRUCCIONES:
- Responde únicamente con información presente en los fragmentos de contexto proporcionados.
- Si la información no está en el contexto, di exactamente: "No he encontrado información sobre esto en los documentos disponibles."
- Cita siempre la fuente: nombre del documento y número de página entre corchetes, por ejemplo [Contrato_Telefonica_2023.pdf, p. 4].
- Cuando haya información contradictoria entre documentos, señálalo explícitamente.
- No hagas suposiciones legales ni ofrezcas asesoramiento jurídico.
- Responde en español formal.`;
async function askAssistant(question: string): Promise<AssistantResponse> {
const chunks = await semanticSearch(question);
if (chunks.length === 0) {
return {
answer: 'No he encontrado información relevante en los documentos disponibles.',
sources: [],
};
}
const context = chunks
.map(c => `[${c.document_name}, p. ${c.page_number}]\n${c.content}`)
.join('\n\n---\n\n');
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini',
temperature: 0, // cero temperatura para respuestas deterministas
max_tokens: 1024,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: `CONTEXTO:\n${context}\n\nPREGUNTA: ${question}` },
],
});
return {
answer: completion.choices[0].message.content ?? '',
sources: chunks.map(c => ({ name: c.document_name, page: c.page_number })),
tokensUsed: completion.usage?.total_tokens ?? 0,
};
}
Por qué
temperature: 0: En un contexto jurídico, la creatividad del modelo es un riesgo. Queremos respuestas reproducibles basadas estrictamente en el contexto. Con temperatura 0, si haces la misma pregunta dos veces, obtienes la misma respuesta.
Los resultados a las 6 semanas
Después de seis semanas de uso real por parte del equipo jurídico:
| Métrica | Valor |
|---|---|
| Documentos indexados | 2.847 |
| Fragmentos vectorizados | 18.203 |
| Consultas respondidas | 1.340 |
| Tasa de respuesta útil* | 91,4% |
| Tiempo medio de respuesta | 2,1 s |
| Coste API por consulta | ~0,003 € |
*Valoración subjetiva del equipo: «¿esta respuesta te ha resultado útil?»
El caso de uso más frecuente resultó ser inesperado: no buscar cláusulas específicas, sino verificaciones cruzadas del tipo «¿tenemos algún contrato con [empresa] que incluya penalización por retraso?». Antes era un proceso de 30-40 minutos. Ahora tarda 3 segundos.
Lo que haríamos diferente
No introduciríamos datos brutos al vector store. Los primeros documentos que indexamos no pasaron por limpieza de texto. Los encabezados y pies de página se indexaron como contenido real, generando fragmentos inútiles que contaminaban las búsquedas. La limpieza previa al chunking es imprescindible.
Evaluaríamos antes de desplegar. Tardamos demasiado en construir un benchmark de preguntas con respuestas esperadas. Sin él, los cambios de prompt eran intuiciones, no mediciones. Ahora empezamos cualquier proyecto de IA con un conjunto de prueba.
Usaríamos streaming desde el inicio. Las primeras versiones esperaban a que el modelo generara toda la respuesta antes de mostrarla. Con una respuesta de 600 tokens a 2 segundos, la espera se hacía incómoda. El streaming — enviar tokens al cliente a medida que se generan — mejora la percepción de velocidad enormemente, incluso si el tiempo total es el mismo.
Conclusión
Un sistema RAG bien construido no es mágico — es ingeniería. La mayor parte del trabajo no está en llamar a la API de OpenAI sino en el preprocesado de datos, la estrategia de chunking, el diseño del prompt y la evaluación continua.
Lo que sí es real es el impacto. Un equipo que pasaba horas buscando información en documentos ahora tiene esa información en segundos, con las fuentes citadas y sin alucinar datos que no existen.
Si tienes un repositorio de documentos — contratos, informes, manuales técnicos, expedientes — y quieres que tu equipo pueda consultarlos en lenguaje natural, esto es completamente viable hoy, con un coste operativo muy bajo y sin comprometer la privacidad de los datos.
¿Te interesa explorar algo similar para tu empresa? Cuéntanos el proyecto — hacemos una primera llamada de diagnóstico sin compromiso.