06_SPR/04 -- Búsqueda sobre chunks.md

Búsqueda sobre chunks

El pipeline del capítulo anterior deja module_embeddings_file_chunk poblada con un vector por fragmento. Ahora hace falta una forma de consultarla — primero desde DWall directamente, y luego desde el agente RAG, que es el cliente final de toda esta infraestructura. El módulo expone dos endpoints que cubren esos dos casos: uno semántico (qué chunks se parecen a esta pregunta) y uno determinista (dame los chunks de este archivo en estas páginas).

Otra vez más, con el objetivo de simplificar ambas consultas y abstraer los filtros a efectuar sobre la base de datos volvemos a utilizar el patrón criteria.

Búsqueda semántica: cómo se calcula la similitud

El método search del caso de uso es deliberadamente corto:

public List<FileChunkSearchResult> search(final FileChunkSearchCriteria criteria) {
    final float[] queryVector = 
    fileEmbeddingGenerator.generateQuery(List.of(criteria.queryText())).get(0);
    return fileChunkSearchRepository.searchSimilar(queryVector, criteria);
}

Solo hace dos cosas: pide a Gemini el vector de la consulta y delega en el repositorio la comparación contra los vectores ya almacenados. De todo lo demas el el mismo criteria el que lo gestiona. Para los chunks que se indexan se usa RETRIEVAL_DOCUMENT; para la consulta del usuario se usa RETRIEVAL_QUERY. El propio modelo aprende a colocar la pregunta y el documento en regiones cercanas del espacio aunque no compartan vocabulario, y eso solo funciona si cada lado se proyecta con el taskType correcto.

Donde sí pasa la lógica interesante es en el repositorio:

final String distanceExpr = "embedding <=> '" + vec + "'::vector";
final Field<Double> distance = DSL.field(distanceExpr, Double.class);
final Field<Double> score = DSL.field("1 - (" + distanceExpr + ")", Double.class);

Condition condition = score.ge(criteria.minScore());
if (criteria.hasFileFilter()) {
    condition = condition.and(MODULE_EMBEDDINGS_FILE_CHUNK.FILE_ID.eq(criteria.fileId()));
}

return context.select(..., score.as("score"))
    .from(MODULE_EMBEDDINGS_FILE_CHUNK)
    .where(condition)
    .orderBy(distance.asc())
    .limit(criteria.topN())
    .fetch(...);

Otra vez más, el operador <=> es la pieza que hace que esto funcione: es el operador de distancia coseno de la extensión pgvector, aplicado entre la columna embedding (vector indexado) y el vector de la consulta literalizado como '[0.12, -0.04, ...]'::vector. Devuelve un valor en [0, 2] donde 0 significa vectores idénticos y 2 vectores diametralmente opuestos. La fórmula score = 1 - distance traslada esa distancia a un rango más cómodo en [-1, 1] —en la práctica casi siempre [0, 1] para textos relacionados— donde 1 es similitud perfecta y se puede interpretar directamente como una nota de relevancia.

El minScore que llega en el criteria se aplica como WHERE, descartando los chunks que no superan el umbral antes de ordenar. El ORDER BY distance ASC LIMIT topN recupera los topN mejores. Y el filtro opcional por fileId permite restringir la búsqueda a un único archivo, útil cuando el usuario ya está mirando un documento concreto y solo quiere encontrar pasajes dentro de él.

Integración con el RAG

Cuando el operador le pregunta algo al agente, este construye su contexto en dos pasos: primero recupera los recursos de DWall semánticamente próximos a la pregunta —la búsqueda que ya existía sobre variables, reglas y consultas—, y después llama a /api/web/embeddings/files/search con el mismo texto para enganchar también los pasajes de los documentos del cliente que aporten información relevante. El resultado de ambas búsquedas se concatena y se mete en el prompt del modelo de generación, que es quien finalmente redacta la respuesta.

El score que devuelve la búsqueda es lo que el agente usa para decidir cuántos chunks meter en el prompt: no tiene sentido inflar el contexto con pasajes de relevancia 0.3, así que se queda con los que superan un umbral configurable y los más cercanos primero. Por eso el minScore y el topN son parámetros del cliente y no constantes del servidor — el agente los ajusta según el coste-calidad que quiera para cada llamada.