Email copiado — support@tuurt.com
Cargando experiencia
ia · 22 de septiembre de 2026 · 7 min

RAG con pgvector en Postgres: por qué el chunking decide la relevancia antes que el modelo

Un chunking que corta párrafos a la mitad arruina la recuperación en pgvector aunque el modelo de embeddings sea bueno. Vemos cómo cortar por estructura del documento en vez de por caracteres, y por qué aumentar el solape empeoró la búsqueda en lugar de arreglarla.

Por Equipo Tuurt

RAG con pgvector en Postgres: por qué el chunking decide la relevancia antes que el modelo

Un sistema de RAG (retrieval-augmented generation) sobre Postgres con pgvector parece, en el papel, un problema resuelto: se generan embeddings de los documentos, se guardan en una columna vector, se indexan, y en cada consulta se busca por similitud coseno los fragmentos más cercanos al embedding de la pregunta. Lo que falla en la práctica casi nunca es el modelo de embeddings ni el índice de Postgres. Falla antes, en cómo se cortaron los documentos en fragmentos antes de embeberlos. Un chunking que corta un párrafo a la mitad, separa un encabezado de su contenido, o mezcla dos secciones sin relación en el mismo fragmento arruina la recuperación aunque el modelo de embeddings sea bueno y el índice esté bien configurado, porque el vector guardado ya no representa un concepto coherente sobre el que buscar.

Cómo se estructura la búsqueda en pgvector

La mecánica de pgvector es simple y por eso es fácil subestimar el peso que tiene lo que pasa antes de llegar a ella. Se agrega una columna de tipo vector a la tabla que guarda los fragmentos, se indexa esa columna, y se compara con el operador de distancia coseno:

create extension if not exists vector;

create table document_chunks (
  id bigserial primary key,
  document_id bigint not null references documents(id),
  chunk_index int not null,
  content text not null,
  embedding vector(1536)
);

create index on document_chunks
  using hnsw (embedding vector_cosine_ops);

Y la consulta de recuperación en tiempo de ejecución busca los k fragmentos más cercanos al embedding de la pregunta del usuario:

select content, embedding <=> $1 as distancia
from document_chunks
order by embedding <=> $1
limit 5;

Este esquema no distingue si el content de cada fila es un párrafo completo con sentido propio o la mitad de una oración cortada por un límite de caracteres arbitrario. Postgres busca correctamente el vector más cercano a la pregunta, pero no tiene forma de saber si ese vector representa una idea completa o un fragmento de idea. Esa responsabilidad queda del lado de cómo se construyeron las filas de document_chunks, y ahí se decide si la búsqueda devuelve algo útil.

Por qué el chunking pesa más que el modelo de embeddings

Un embedding de un fragmento de texto es, en la práctica, un resumen vectorial de su contenido semántico. Si el fragmento contiene una sola idea coherente, el vector apunta con claridad hacia esa idea en el espacio de embeddings, y una pregunta relacionada con esa idea va a caer cerca. Si el fragmento contiene el final de una idea y el comienzo de otra completamente distinta -algo que ocurre todo el tiempo cuando se corta texto por número de caracteres sin mirar el contenido-, el modelo de embeddings no tiene más remedio que producir un vector que es, en esencia, un promedio de ambas ideas. Ese vector no queda cerca de ninguna de las dos preguntas que uno esperaría que recuperara, porque no representa ninguna de las dos con precisión.

El caso más común de este problema no es dramático: es un encabezado de sección que queda en un fragmento y el contenido de esa sección en el siguiente. Una pregunta sobre "cómo configurar el timeout de reintentos" puede caer semánticamente muy cerca del encabezado "Configuración de reintentos" y no del fragmento que efectivamente contiene los pasos, si el corte de chunking separó ambas cosas. El sistema recupera el título sin el contenido, el modelo generador recibe un fragmento que dice de qué se trata la sección sin decir en qué consiste, y la respuesta termina siendo vaga o inventada, no porque el modelo generador alucine sin motivo, sino porque el contexto que se le entregó ya venía incompleto.

Chunking ingenuo por número de caracteres

La primera versión que probamos cortaba el texto por una cantidad fija de caracteres, sin mirar la estructura del documento:

function chunkPorCaracteres(texto, tamano = 1000, solape = 100) {
  const chunks = [];
  let inicio = 0;

  while (inicio < texto.length) {
    const fin = Math.min(inicio + tamano, texto.length);
    chunks.push(texto.slice(inicio, fin));
    inicio += tamano - solape;
  }

  return chunks;
}

Es la implementación más sencilla posible, y por eso resulta tentador dejarla así cuando el objetivo inmediato es "que el RAG funcione". El problema aparece en cualquier documento con estructura real: manuales técnicos, documentación de API, contratos con cláusulas numeradas. Un bloque de código de ejemplo puede quedar cortado a la mitad entre dos fragmentos, una tabla puede perder su fila de encabezados en un fragmento distinto al de sus datos, y una lista numerada puede empezar en un fragmento y terminar en el siguiente sin que ninguno de los dos, por separado, tenga sentido completo. El solape de cien caracteres ayuda a que algunos cortes no caigan justo en el peor punto posible, pero no resuelve el problema de fondo: la función no sabe qué es un párrafo, un encabezado o un bloque de código, solo sabe contar caracteres.

Chunking que respeta la estructura del documento

La corrección no fue ajustar el tamaño del fragmento ni el solape, sino cambiar el criterio de corte: cortar primero por la estructura del documento -encabezados, párrafos, bloques de código- y solo subdividir por tamaño cuando una sección estructural individual excede el presupuesto de tokens del embedding.

function chunkPorEstructura(markdown, maxTokens = 500) {
  const secciones = markdown.split(/(?=^#{1,3} )/m);
  const chunks = [];

  for (const seccion of secciones) {
    if (estimarTokens(seccion) <= maxTokens) {
      chunks.push(seccion.trim());
      continue;
    }

    const parrafos = seccion.split(/\n\n+/);
    let actual = '';

    for (const parrafo of parrafos) {
      if (estimarTokens(actual + parrafo) > maxTokens && actual) {
        chunks.push(actual.trim());
        actual = '';
      }
      actual += parrafo + '\n\n';
    }
    if (actual.trim()) chunks.push(actual.trim());
  }

  return chunks.filter(c => c.length > 0);
}

La diferencia con la versión ingenua es que esta función nunca corta dentro de un párrafo ni separa un encabezado del inicio de su sección: el primer nivel de corte respeta los límites naturales del documento, y solo subdivide una sección cuando es demasiado grande para un embedding, y ahí lo hace por párrafo completo, no por conteo de caracteres. Un bloque de código delimitado por triples backticks queda dentro de un único párrafo para este split, así que tampoco se corta a la mitad salvo que exceda el presupuesto de tokens por sí solo, caso que conviene tratar aparte guardando el bloque como fragmento propio en vez de forzarlo dentro del límite.

Lo que no funcionó: aumentar el solape para compensar cortes malos

Antes de cambiar el criterio de corte, intentamos resolver el problema de otra forma: mantener el chunking por caracteres, pero subir el solape de cien a varios cientos de caracteres, con la idea de que un solape más grande reduciría la probabilidad de que una idea completa quedara partida entre dos fragmentos. No funcionó, y además introdujo un problema nuevo. Con más solape, fragmentos consecutivos terminaban compartiendo la mayor parte de su contenido, así que sus embeddings quedaban muy cerca entre sí en el espacio vectorial. Una búsqueda que antes devolvía cinco fragmentos con información distinta empezó a devolver copias casi idénticas del mismo pasaje, porque todas caían dentro del radio de similitud de la pregunta al mismo tiempo. El limit 5 se llenaba con variaciones del mismo fragmento en lugar de cubrir las partes más relevantes del documento, y el contexto que recibía el modelo generador era menos diverso, no más completo. La corrección real no estaba en la cantidad de solape sino en dejar de cortar sin mirar la estructura del texto.

Cuándo vale la pena invertir en esto

Un chunking por caracteres sin estructura es aceptable para documentos cortos y planos, como una lista de preguntas frecuentes donde cada entrada ya es autocontenida y más chica que el límite de tokens. Deja de serlo en cuanto los documentos tienen jerarquía real -manuales, documentación técnica, especificaciones- porque ahí la unidad de sentido no es un número fijo de caracteres, es la sección, el párrafo o el bloque de código tal como el autor los definió. La pregunta que usamos para decidir cuánto invertir en el chunking es simple: si se le mostrara un fragmento aislado a una persona, sin el resto del documento alrededor, ¿tendría sentido completo por sí solo? Si la respuesta es no de forma consistente, el problema no está en el modelo de embeddings ni en el índice de pgvector, está en el corte que se hizo antes de que cualquiera de los dos entrara en juego.

pgvector rag postgres embeddings
← Volver al blog