Construimos un asistente IA para un despacho de abogados — esto es lo que aprendimos

Caso real: cómo diseñamos e implementamos un sistema RAG con GPT-4o para que un equipo jurídico consultara miles de documentos en lenguaje natural. Arquitectura, errores y métricas reales.

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 RAGRetrieval-Augmented Generation. La idea es simple en teoría:

  1. Procesa y trocea los documentos en fragmentos (chunks)
  2. Convierte cada fragmento en un vector numérico (embedding)
  3. Almacena esos vectores en una base de datos especializada
  4. Cuando el usuario hace una pregunta, búscala en el espacio vectorial
  5. Pasa los fragmentos más relevantes al modelo de lenguaje como contexto
  6. 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:

EstrategiaTamañoResultado
Chunks fijos (512 tokens)UniformeCortaba cláusulas a mitad — contexto roto
Por párrafoVariableMejor, 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ónVentajaInconveniente
pgvector (PostgreSQL)Infraestructura existente, sin coste adicionalRequiere tuning para escala alta
PineconeServicio gestionado, simpleCoste mensual, dependencia externa
Qdrant (self-hosted)Alto rendimiento, gratuitoRequiere 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étricaValor
Documentos indexados2.847
Fragmentos vectorizados18.203
Consultas respondidas1.340
Tasa de respuesta útil*91,4%
Tiempo medio de respuesta2,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.

Volver al blog