Inicio / Artículos

Búsqueda híbrida en Postgres para un asistente de salud: esquema, particionamiento, RRF y evaluaciones.

Búsqueda híbrida en Postgres para un asistente de salud: particionamiento consciente de la estructura, pgvector con RRF, cuantización binaria, umbrales de puntuación y una evaluación adecuada.

Editor: REACTAPP.TOP19 min de lectura4283 palabras

Fuentes: RAG in Postgres: What I Learned Building a Health Assistant Where Being Wrong Is Expensive

Las demostraciones de una “IA para la salud” suelen verse idénticas. Un conjunto de datos público se almacena en un repositorio vectorial, un prompt indica que el modelo es un asistente médico útil, y las primeras tres preguntas reciben respuestas excelentes. Luego llega la cuarta pregunta.

Esta contiene una negación. O su respuesta correcta depende de un documento revisado hace dieciocho meses que reemplazó a uno escrito en 2011. O dos fuentes públicas fiables difieren, y el comportamiento adecuado es señalarlo y explicar por qué es importante, en lugar de elegir a uno de ellos como correcto. O la respuesta honesta es “No lo sé, pregúntele a un clínico”, pero un modelo entrenado para ser útil escribe de todos modos un párrafo seguro de sí mismo.

Esa cuarta pregunta es donde realmente reside el producto, y casi todo el esfuerzo necesario para responderla no tiene lugar en el modelo de lenguaje. Se encuentra en la recuperación de información, que en este caso significó Postgres. Lo que sigue describe esa capa: esquema, fragmentación, la consulta de búsqueda híbrida, cuantización y un mecanismo de evaluación que evita que se envíen respuestas erróneas. Es independiente de cualquier fuente de datos, y estas ideas son aplicables a cualquier corpus en el que una respuesta incorrecta tenga consecuencias graves.

Cuatro formas en que el texto sanitario supera al RAG ingenuo

Los corpus abiertos de salud comparten cuatro características que arruinan silenciosamente el proceso estándar de fragmentación, incrustación, similitud coseno e inserción en el prompt.

  1. El significado no tiene la forma de un párrafo. Una monografía de un fármaco cuenta con secciones separadas para contraindicaciones, interacciones y dosis. Al dividirla en ventanas deslizantes de 512 tokens, alguna de esas partes contendrá la mitad de una contraindicación y la mitad de una tabla de dosis. Esa parte se recupera de manera satisfactoria, con una alta similitud coseno, pero con un bajo valor de veracidad. Es el fallo más peligroso en todo el sistema, ya que las métricas de recuperación siguen pareciendo buenas.
  • El vocabulario es bimodal. Los usuarios describen un ardor estomacal después de las comidas, mientras que el corpus indica “dolor epigástrico, posprandial”. La búsqueda semántica cubre bien esa brecha. Pero los usuarios también escriben identificadores exactos, como el nombre de un fármaco, un código o un compuesto, y en esos casos los embeddings rinden peor que un índice de palabras clave. Un modelo que coloca al metoprolol y al metoprolol succinato en puntos vecinos está ayudando a que alguien cometa un error. Se necesitan ambos modos de recuperación, fusionados y no elegidos de forma separada.
  • La recencia es semántica. En otros contextos, un documento antiguo es ligeramente peor. En el ámbito de la salud puede ser incluso perjudicial, ya que las guías se revisan precisamente porque la versión anterior era incorrecta. La recencia no puede ser un factor decisivo añadido al final; debe ser un campo de primera importancia sobre el cual tanto la capa de recuperación como la capa de respuesta tomen en consideración.
  • Las respuestas tienen una estructura legal definida. Algunas preguntas deben ser dirigidas según un procedimiento establecido, y no respondidas de forma casual: síntomas de emergencia, decisiones sobre dosis para una persona específica, y cualquier cosa que constituya un diagnóstico. Estos casos requieren un camino fijo, basado en reglas, que no pueda verse influenciado por las percepciones del modelo.
  • Cada uno de estos casos representa un problema de capa de recuperación de información, lo que conduce a decisiones sencillas que, sin embargo, acaban teniendo la mayor importancia.

    ¿Por qué Postgres y no una base de datos vectorial?

    El equilibrio entre opciones merece una explicación clara, ya que el argumento de que “no se necesita una base de datos vectorial” se ha vuelto vago, y existen casos reales en los que sí es necesaria.

    Una base de datos vectorial dedicada ofrece una mejor tasa de recuperación por milisegundo a escala muy alta, tipos de índices más completos, búsquedas filtradas diseñadas para filtrar desde el principio, y escalabilidad horizontal que no requiere consideraciones adicionales. Con cientos de millones de vectores bajo una intensa carga de consultas, eso representa una verdadera ventaja que debe aprovecharse.

    Postgres ofrece un único límite de transacción. Puede parecer algo menor, pero no lo es. En un corpus que cambia constantemente, una arquitectura de dos sistemas conlleva costos reales:

    • Cuando se modifica un documento de origen, sus fragmentos antiguos deben eliminarse, se deben insertar nuevos, se deben volver a calcular las representaciones vectoriales y se deben actualizar los metadatos, todo de forma atómica. Con un almacén de vectores separado, no existe tal atomicidad. En casos extremos, el índice de palabras clave y el índice vectorial pueden discrepar sobre qué existe, lo que hace que el asistente cite un documento que ya no refleja el motivo por el cual fue citado.
    • El filtrado por licencia, jurisdicción, autoridad de origen, fecha de revisión e idioma, seguido de un ordenamiento por distancia vectorial, corresponde a una cláusula WHERE en Postgres. En dos sistemas diferentes, esto implica ya sea una réplica de metadatos mantenida sincronizada o una rutina de obtención excesiva de datos seguida de un filtrado posterior que devuelve silenciosamente menos resultados de los solicitados.
    • Descubrir por qué se recuperó una respuesta específica hace seis semanas implica un proceso de unión de datos.

    El límite de escalabilidad existe, pero es mayor de lo que la gente imagina; con pgvector actual, es posible manejar cómodamente decenas de millones de datos en una instancia bien ajustada. Una regla sensata es comenzar con Postgres y considerar una migración posterior como un hito de éxito y no como un objetivo de diseño. Un proyecto que nunca alcanza ese límite evita problemas relacionados con los sistemas distribuidos. Uno que sí lo alcanza termina con un producto funcional, registros reales de consultas y una visión clara de sus necesidades.

    Las versiones son importantes. pgvector 0.7.0 introdujo halfvec, sparsevec, indexación de tipo bit y binary_quantize. La versión 0.8.0 añadió búsquedas iterativas en índices, lo que facilita el problema de filtrado descrito a continuación, y la línea 0.8.x incluyó soporte para Postgres 18. En la versión 0.6.x, la mayor parte de lo que se explica a continuación no funcionará tal como está escrito.

    Estructura

    Hay cuatro tablas que conforman el diseño. La regla es que la procedencia debe ser estructural, nunca un bloque JSON: todo aquello sobre lo que la capa de respuestas debe hacer referencia, filtrar o razonar cuenta con su propia columna.

    CREATE EXTENSION IF NOT EXISTS vector;
    CREATE EXTENSION IF NOT EXISTS pg_trgm;
    -- Where knowledge comes from, and how much we trust it.
    CREATE TABLE source (
      id bigserial PRIMARY KEY,
      slug text UNIQUE NOT NULL,
      license text NOT NULL, -- redistribution rules are a query concern
      authority smallint NOT NULL CHECK (authority BETWEEN 1 AND 5),
      base_url text
    );
    -- One row per upstream document, at whatever granularity upstream uses.
    CREATE TABLE document (
      id bigserial PRIMARY KEY,
      source_id bigint NOT NULL REFERENCES source(id),
      external_id text NOT NULL, -- the stable ID assigned upstream
      title text NOT NULL,
      url text,
      published_at date,
      revised_at date, -- recency is a column, not a vibe
      superseded_by bigint REFERENCES document(id),
      lang text NOT NULL DEFAULT 'en',
      raw_hash bytea NOT NULL, -- skip work on unchanged re-ingests
      meta jsonb NOT NULL DEFAULT '{}',
      UNIQUE (source_id, external_id)
    );
    -- Retrieval unit. Note heading_path: structure survives chunking.
    CREATE TABLE chunk (
      id bigserial PRIMARY KEY,
      document_id bigint NOT NULL REFERENCES document(id) ON DELETE CASCADE,
      ord int NOT NULL,
      heading_path text[], -- {Contraindications,"Renal impairment"}
      body text NOT NULL,
      body_tsv tsvector GENERATED ALWAYS AS
        (to_tsvector('english', body)) STORED,
      token_count int NOT NULL,
      content_hash bytea NOT NULL,
      lang text NOT NULL, -- denormalized from document
      is_current boolean NOT NULL, -- denormalized: superseded_by IS NULL
      UNIQUE (document_id, ord)
    );
    CREATE INDEX chunk_tsv_idx ON chunk USING gin (body_tsv);
    CREATE INDEX chunk_trgm_idx ON chunk USING gin (body gin_trgm_ops);
    -- Embeddings are versioned by model. This is not optional.
    -- lang and is_current are DENORMALIZED from document -- see below.
    CREATE TABLE embedding (
      chunk_id bigint NOT NULL REFERENCES chunk(id) ON DELETE CASCADE,
      model text NOT NULL,
      vec halfvec(1536) NOT NULL,
      lang text NOT NULL,
      is_current boolean NOT NULL,
      PRIMARY KEY (chunk_id, model)
    );
    

    Hay cuatro detalles que soportan la carga.

    Los embeddings se identifican por bloque y modelo. Los modelos de embeddings cambiarán. Cuando eso ocurra, ambas versiones deben estar activas al mismo tiempo para poder realizar pruebas A/B con tráfico real y cambiarlas sin necesidad de un período de mantenimiento. Coloque el modelo en la clave principal desde el primer día y cree un índice parcial por modelo para que cada uno permanezca compacto:

    CREATE INDEX embedding_hnsw_v3_en
      ON embedding USING hnsw (vec halfvec_cosine_ops)
      WITH (m = 16, ef_construction = 128)
      WHERE model = 'emb-v3' AND lang = 'en' AND is_current;
    

    El idioma y la bandera actual se copian intencionadamente tanto en el chunk como en el embedding. Este es el único caso en el que la normalización se rompió a propósito, y la razón tardó un tiempo vergonzosamente largo en hacerse evidente. Un predicado de índice parcial en Postgres solo puede hacer referencia a las columnas de la tabla que está indexada. Un índice en el embedding no puede incluir WHERE document.is_current; Postgres lo rechaza de inmediato, incluso con subconsultas. Para integrar filtros críticos en el índice vectorial, algo necesario por las razones explicadas en la trampa de filtrado a continuación, esas columnas deben encontrarse físicamente en la fila del embedding.

    Mantener las copias precisas es complicado, y el primer intento falló dos veces. La bandera actual no se establece durante la incorporación del propio documento. Cambia cuando algún documento posterior reemplaza a este, por lo que la actualización se ejecuta dentro de la transacción del documento reemplazante y debe llegar a cada bloque y fila de incrustación perteneciente al documento retirado. Coloca esa propagación en una función con un único punto de llamada, y escribe una prueba que verifique que los valores denormalizados siempre sean iguales a los del documento padre. Cuando los dos mecanismos de búsqueda discrepan sobre qué filas son actuales, la fusión mezcla posiciones de dos corpora diferentes, y los resultados empeoran silenciosamente sin que haya un error al que recurrir.

    Tampoco es gratuito. Al invertir la bandera, esas filas se eliminan de ambos índices HNSW parciales, y HNSW recupera el espacio solo durante operaciones de vaciado, por lo que un alto volumen de cambios significa tener que lidiar con el aumento del tamaño del índice.

    La columna vectorial es halfvec, no vector. Al almacenar flotantes de 16 bits, la carga bruta disminuye de 6,152 bytes a 3,076 en 1,536 dimensiones, y el índice HNSW se reduce aún más porque los tuplos pequeños se compactan mejor en las páginas. Aquí, la recuperación solo necesita entregar unos 50 candidatos a un reordenador, y en las pruebas la brecha de recuperación se mantuvo dentro de los límites normales. Ventaja adicional: halfvec puede indexarse hasta 4,000 dimensiones, mientras que los vectores normales se detienen en 2,000, lo cual es útil para modelos de embeddings grandes.

    La supresión se almacena como un puntero hacia la misma tabla, no como un valor booleano. Un marcador simple y desactualizado proporciona poca información al asistente. Saber qué documento reemplazó a este le permite explicar que las indicaciones han cambiado y enlazar con la versión actual. Eso implica una unión, posible solo si el enlace se capturó al ingresar los datos.

    El particionamiento es donde reside el mayor valor

    Si hay una lección que se puede extraer aquí, es que el procesador de fragmentos es más importante que el modelo de embedding. Un modelo mejor permite obtener unos pocos puntos adicionales en las pruebas de recuperación de información. Un procesador de fragmentos que respeta la estructura permite distinguir entre un asistente que da respuestas correctas y uno que se equivoca de forma contundente. Se aplican tres reglas principales.

    Primero la estructura, luego el tamaño. Analice cada documento para convertirlo en un árbol de encabezados y mantenga cada fragmento dentro de una sección de nivel superior. Cuando una sección es más pequeña que el tamaño objetivo, déjela tal cual en lugar de unirla a su vecina: existe una contraindicación de 60 tokens que indica que eso es más útil que un fragmento de 500 tokens en el que esa información se pierde. Los límites de tokens son una solución de último recurso cuando la estructura ya no es suficiente, nunca la regla principal.

    Nunca divida una tabla. Las tablas relacionadas con la salud suelen ser grillas de dosis, matrices de interacciones o criterios de elegibilidad; en estos casos, tener la mitad de las filas sin información es peor que no tenerlas. Si el contenido supera el presupuesto establecido, divídalas en grupos de filas, repita el encabezado en cada grupo y convierta el resultado en oraciones antes de incrustarlo. Una línea como “Edad de 6 a 11 años: 5 mg una vez al día” se integra mucho mejor que una grilla separada por barras.

    Coloque el contexto al principio antes de incrustar el texto. El texto incrustado no debe ser el fragmento en bruto, sino el fragmento con su historial restaurado: una línea de encabezado que indique la fuente, el título del documento y la fecha de revisión, seguida del camino de la sección como Contraindicaciones, luego Daño renal, y finalmente el cuerpo del texto. Incruste eso y conserve el cuerpo en bruto para su visualización y citación. Un fragmento que dice “no usar en pacientes con aclaramiento reducido” no dice casi nada por sí solo, ya que no menciona ningún fármaco, condición ni contexto. Solo se vuelve recuperable una vez que el encabezado indica al modelo de qué se trata. Este único cambio mejoró la capacidad de recuperación más que cualquier otro intento de cambiar de modelo, y solo implica costos en tokens en el momento de indexación.

    A continuación se presentan dos notas de implementación. Calcule el hash de contenido sobre el texto contextualizado, de modo que una nueva inserción que solo modifique un encabezado active correctamente la reincorporación. Además, ejecute todo el proceso de reemplazo de documento a fragmentos a representación vectorial dentro de una sola transacción, para que el corpus nunca quede parcialmente actualizado:

    BEGIN;
      DELETE FROM chunk WHERE document_id = $1; -- cascades to embedding
      -- insert new chunks + embeddings
    COMMIT;
    

    Esa transacción es la razón principal por la cual esto se almacena en Postgres.

    Recuperación híbrida con fusión de rangos recíprocos

    Cada consulta realiza una búsqueda semántica y una búsqueda por palabras clave por separado, y luego las fusiona según el rango, nunca según la puntuación bruta. Fusionar las puntuaciones no funciona: la distancia de coseno y ts_rank_cd se encuentran en escalas no relacionadas, no son comparables entre consultas, y cualquier peso aplicado a un conjunto de consultas provocará sobreajuste. La fusión de rangos recíprocos evita completamente este problema al considerar únicamente las posiciones.

    WITH params AS (
      SELECT $1::halfvec(1536) AS qvec,
             $2::text AS qtext,
             60::int AS rrf_k, -- damping constant
             50::int AS pool
    ),
    -- Two rules in this CTE, both non-obvious, both explained below:
    -- 1. qvec comes from a SCALAR subquery, never a joined relation.
    -- 2. Filters live INSIDE each arm, not in the outer query.
    semantic AS (
      SELECT chunk_id, row_number() OVER (ORDER BY dist) AS rank
      FROM (
        SELECT e.chunk_id,
               e.vec <=> (SELECT qvec FROM params) AS dist
        FROM embedding e
        WHERE e.model = 'emb-v3'
          AND e.lang = 'en'
          AND e.is_current
        ORDER BY e.vec <=> (SELECT qvec FROM params)
        LIMIT (SELECT pool FROM params)
      ) s
    ),
    lexical AS (
      SELECT chunk_id, row_number() OVER (ORDER BY score DESC) AS rank
      FROM (
        SELECT c.id AS chunk_id,
               ts_rank_cd(c.body_tsv,
                          websearch_to_tsquery('english',
                            (SELECT qtext FROM params))) AS score
        FROM chunk c
        WHERE c.body_tsv @@ websearch_to_tsquery('english',
                              (SELECT qtext FROM params))
          AND c.lang = 'en' -- same predicate as the semantic arm,
          AND c.is_current -- from the same denormalized source
        ORDER BY score DESC
        LIMIT (SELECT pool FROM params)
      ) l
    ),
    fused AS (
      SELECT chunk_id,
             SUM(1.0 / ((SELECT rrf_k FROM params) + rank)) AS score
      FROM (
        SELECT chunk_id, rank FROM semantic
        UNION ALL
        SELECT chunk_id, rank FROM lexical
      ) u
      GROUP BY chunk_id
    )
    SELECT c.id, d.title, d.url, d.revised_at, s.slug, s.authority,
           c.heading_path, c.body, f.score
    FROM fused f
    JOIN chunk c ON c.id = f.chunk_id
    JOIN document d ON d.id = c.document_id
    JOIN source s ON s.id = d.source_id
    ORDER BY f.score DESC, s.authority DESC, d.revised_at DESC NULLS LAST
    LIMIT 20;
    

    Un fragmento que ocupa el tercer lugar en significado y el séptimo en palabras clave supera en rendimiento a uno en el que solo un único criterio está en primer lugar. Eso es intencional, ya que dos señales independientes que coinciden tienen más valor que una sola señal que grita por sí sola. websearch_to_tsquery es el analizador más adecuado porque tolera entradas reales de usuarios, frases entre comillas e inclusiones de exclusiones con signo menos, sin fallar con texto mal formado. ts_rank_cd es mejor que ts_rank en este caso porque la densidad de cobertura favorece a los términos que aparecen cerca unos de otros, lo cual es adecuado para preguntas que combinan varios conceptos.

    Dos características de esa consulta parecen ser elecciones de estilo, pero en realidad no lo son.

    Trampa número uno: el vector de la consulta debe ser una subconsulta escalar

    En el enfoque semántico, el vector de la consulta llega como una subconsulta escalar, y no como una columna de una relación de parámetros unida. Esa es la diferencia entre un milisegundo y un minuto.

    La versión intuitiva selecciona tanto de la tabla de incrustación como de la relación de parámetros, y luego ordena según la distancia a la columna de parámetros. Funciona bien, es válida y devuelve las filas correctas, y ese es precisamente el motivo por el que supera la revisión de código. El problema es que el planificador solo puede utilizar un índice de vecino más cercano cuando ORDER BY compara la columna indexada con algo fijo para toda la exploración. Un valor obtenido a través de una unión no cumple los requisitos, por lo que no existe ningún camino ordenado: Postgres lee cada fila y la ordena.

    Con cincuenta mil bloques, eso cuesta unos pocos cientos de milisegundos, lo suficientemente lento como para notarse y lo suficientemente ambiguo como para culparlo a la red. Con cinco millones, se produce un tiempo de espera.

    Verifique en lugar de confiar. EXPLIQUE la consulta de recuperación en CI y asegúrese de que el plan contenga un escaneo de índice en el índice HNSW con una línea Order By. Un escaneo secuencial seguido de un ordenamiento significa que el índice vectorial es meramente decorativo. Vale la pena realizar una prueba que falle en esta fase de construcción, ya que una refactorización bien intencionada puede reintroducir el problema sin generar ni una sola respuesta incorrecta, solo respuestas lentas, eventualmente, en producción.

    Trampa dos: filtrado después del límite

    Los filtros, a saber, actual, idioma y no sustituido, deben colocarse dentro de la subconsulta de cada brazo, antes del LIMIT. La primera versión los puso en la consulta exterior, el lugar obvio pero incorrecto.

    HNSW devuelve vecinos más cercanos aproximados. Se toman los 50 mejores por distancia, se filtran posteriormente y quizás solo 12 sobreviven. El grupo inicial de 50 se reduce silenciosamente a 12; la fusión de rangos ahora funciona sobre un conjunto contaminado por filas destinadas a ser descartadas, y un FINAL LIMIT 20 devuelve discretamente 9 resultados. Los colapsos en los resultados no se registran y nada en los registros lo indica. Cada resultado individual sigue pareciendo plausible, por lo que nadie reporta un error. El asistente simplemente empeora sutilmente en preguntas donde el corpus se filtra de manera desigual, lo que en un corpus de salud significa preguntas sobre material recientemente revisado.

    Tres soluciones, en orden de preferencia:

    1. Construir el filtro dentro de un índice parcial. Esa es la razón por la cual el idioma y la bandera actual se copiaron en la fila de incrustación. Una vez que el predicado del índice coincide con la cláusula WHERE de la consulta, el índice ya contiene únicamente las filas filtradas y el problema desaparece. Este enfoque es adecuado para dimensiones de baja cardinalidad y poco dinámicas, como el idioma, la moneda o el nivel de origen, pero no sirve para elementos con muchos valores, ya que eso implicaría mantener cientos de índices.
    2. Escaneos iterativos de índices (pgvector 0.8.0 y versiones posteriores) también permiten manejar filtros demasiado dinámicos como para incluirlos directamente:
    SET hnsw.iterative_scan = relaxed_order;
    SET hnsw.max_scan_tuples = 100000; -- default is 20000
    SET hnsw.scan_mem_multiplier = 4; -- raise if the above is not enough
    

    Al activar esta opción, el índice sigue buscando más candidatos hasta que suficientes superen el filtro. Aplican dos limitaciones: el filtro debe encontrarse en el mismo nivel de consulta que el escaneo del índice, de lo contrario no habrá cambios, y el escaneo tiene un límite, por lo que aún puede devolver menos filas de las solicitadas. Esto atenúa el problema sin eliminarlo. Elegir relaxed_order en lugar de strict_order mejora la recuperación de resultados, pero a costa de que estos no estén ordenados exactamente por distancia, lo cual es aceptable si se utiliza un reordenador posterior.

    Velocidad mediante cuantización binaria

    A gran escala, la solución más económica y efectiva es la recuperación en dos etapas: obtener candidatos de un índice binario según la distancia de Hamming y luego recalcular los puntajes de esa lista reducida con precisión.

    CREATE INDEX embedding_bit_idx ON embedding
      USING hnsw ((binary_quantize(vec)::bit(1536)) bit_hamming_ops)
      WHERE model = 'emb-v3' AND lang = 'en' AND is_current;
    SELECT chunk_id
    FROM (
      SELECT chunk_id, vec
      FROM embedding
      WHERE model = 'emb-v3' AND lang = 'en' AND is_current
      ORDER BY binary_quantize(vec)::bit(1536)
               <~> binary_quantize($1::halfvec(1536))
      LIMIT 400
    ) candidates
    ORDER BY vec <=> $1::halfvec(1536)
    LIMIT 50;
    

    La primera etapa debe calcular un valor excesivo de forma generosa, alrededor de ocho veces el conjunto final. La recalculación exacta en la segunda etapa corrige el orden, por lo que la etapa binaria solo necesita la presencia de candidatos, no sus rangos correctos.

    La precisión en cuanto al ahorro es importante, ya que Internet maneja estos valores de manera poco estricta. Con 1,536 dimensiones, un vector de bits ocupa unos 200 bytes, frente a aproximadamente 3 KB para halfvec, lo que representa una reducción de 16 veces en los datos brutos. La cifra de 32 veces que se menciona en todas partes se refiere a un vector de precisión total, el cual este esquema no utiliza.

    El índice no se reduce 16 veces, y la razón es ilustrativa. Cada tupla HNSW lleva una carga fija de unos pocos cientos de bytes, enlaces del grafo y un encabezado de tupla, independientemente del contenido que almacene. Con 3 KB de datos en formato halfvec, esa carga adicional es insignificante; pero con un vector de bits de 200 bytes, supera el tamaño del propio contenido. Al sumar además el desperdicio generado por el empaquetamiento de páginas, la compresión real queda muy por debajo de la relación inicial. En estos datos, el índice binario resultó ser aproximadamente 4 a 5 veces más pequeño que el índice en formato halfvec. Eso sigue siendo determinante para saber si un índice cabe en la RAM, que es el objetivo principal, pero quien espere una reducción de 32 veces se llevará una desagradable sorpresa. Mida el tamaño con pg_relation_size antes de planificar la capacidad, y utilice vectores realistas: una prueba de rendimiento perdió toda una tarde porque los vectores sintéticos eran todos positivos, binary_quantize generó valores constantes en 1, y Postgres comprimió todo hasta dejarlo sin contenido.

    El resto es rutinario y está bien documentado. Asigne más memoria a maintenance_work_mem antes de crear índices HNSW, de lo contrario la operación se alargará en el disco y tardará horas en lugar de minutos. Elija el valor hnsw.ef_search según la consulta: de 100 a 200 cuando los errores son costosos, alrededor de 40 para autocompletado. Cree los índices de producción DE FORMA CONCURRENTE. Pruebe con sus propios datos, ya que las cifras de recuperación publicadas rara vez son transferibles.

    Reclasificación y la decisión de no devolver resultados

    Fusion devuelve un buen top 20, no un buen top 5, y el prompt solo tiene espacio para 5 resultados. Un reclasificador basado en codificador cruzado, que procesa la consulta y los candidatos como un par en lugar de comparar vectores almacenados, ofrece el mayor mejoramiento en calidad tras la búsqueda híbrida. Es lento, por lo que solo puede analizar 20 candidatos, no un millón.

    Lo que la gente pasa por alto: utilizar las puntuaciones del reordenador como umbral absoluto, y no solo como herramienta de clasificación. Si el mejor candidato queda por debajo de dicho umbral, la respuesta adecuada no es la mejor opción entre las malas disponibles, sino una admisión de que el corpus no contiene información fiable sobre el tema.

    Aquí es donde la mayoría de los asistentes de salud fallan, y la solución radica en elegir un producto cuya funcionalidad esté implementada en el código. Un sistema diseñado para responder a todo inventará respuestas para preguntas poco comunes. Si el reordenamiento solo sirve para clasificar, el modelo siempre recibirá algo, y un modelo adecuado siempre devolverá una respuesta. Un umbral de puntuación convierte un fallo en la recuperación de información en un rechazo directo, en lugar de una fabricación convincente. Establezca dicho umbral utilizando preguntas etiquetadas cuyas respuestas se sepa que faltan en el corpus, y manténgalo cuando los usuarios se quejen de demasiados rechazos.

    Comprender primero la consulta

    Antes de la recuperación existe una capa que genera un retraso adicional: un modelo compacto y rápido que reformula la pregunta antes de que el índice la vea.

    • Ampliar y normalizar. Traducir el lenguaje coloquial al vocabulario del corpus sin descartar el original. Ambos se incluyen en la consulta híbrida: el original en el componente léxico y la ampliación en el componente semántico.
    • Dividir las preguntas compuestas. Una consulta que pregunta si dos medicamentos pueden combinarse durante el embarazo en realidad implica tres búsquedas: cada medicamento por separado, más el aspecto relacionado con el embarazo. Ejecutarlas de forma independiente, fusionar las clasificaciones y volver a ordenar el conjunto combinado.
    • Resolver el contexto. Una pregunta posterior, como la relativa a una dosis más alta, carece de sentido por sí sola. Incluir la pregunta anterior y reformularla como una consulta independiente antes de su incrustación, ya que las incrustaciones no conservan memoria.
    • Clasificar y enrutar. Las señales de emergencia, las solicitudes de dosificación personal y cualquier elemento similar a un diagnóstico omiten la recuperación de información y siguen un camino de respuesta determinista. El clasificador debe ser muy sensible a los estímulos. Un falso positivo resulta en un mensaje ligeramente molesto que indica “por favor, hable con un clínico”, mientras que un falso negativo representa un fallo que pone fin al funcionamiento del producto.

    Base en citas estructuradas

    La fase de generación es breve. La mayor parte del esfuerzo se invierte en el contrato de salida, no en la redacción de la instrucción. Etiquete cada fragmento recuperado en la instrucción con una etiqueta estable y exija una salida estructurada en la que cada afirmación indique las etiquetas en las que se basa:

    {
      "claims": [
        { "text": "…", "sources": [2, 5] },
        { "text": "…", "sources": [1] }
      ],
      "uncertain": true,
      "route_to_clinician": false
    }
    

    Verifique el resultado en el código, no en la solicitud. Una afirmación cuya lista de fuentes está vacía se elimina antes de ser mostrada, y si hay demasiadas afirmaciones, toda la respuesta se descarta y se activa el proceso de rechazo. Esto separa la solicitud de fundamentación de su garantía: una solicitud es una esperanza, un validador es una regla. La tasa de descarte también funciona como una métrica de producción gratuita, un indicador de alucinaciones en tiempo real que puede activar alertas sin que nadie lea las transcripciones.

    Hubo dos decisiones menores que fueron importantes. Mostrar la fecha de revisión del documento junto a cada cita para que los usuarios puedan evaluar por sí mismos su actualidad. Y cuando las fuentes recuperadas son contradictorias, mostrar esa contradicción en lugar de resolverla. “La guía difiere: la fuente A dice X, la fuente B dice Y” es más útil y honesto que un promedio sintetizado.

    Evaluación, o adivinación

    Los cambios en el proceso de envío basados en intuiciones no son una opción aquí. Los modos de fallo son sutiles, la superficie de análisis es amplia y cada configuración afecta a todas las demás. El sistema que funciona bien cuenta con múltiples capas de protección.

    Solo recuperación de información, sin LLM. Unas pocas centenas de consultas con IDs de fragmentos relevantes etiquetados a mano, para medir los índices de recall@20 y nDCG@10. El proceso se ejecuta en segundos, detecta la mayoría de las regresiones y, lo que es crucial, separa la calidad de la recuperación de la calidad de la generación. Cuando las respuestas empeoran, muestra de inmediato si el problema proviene del corpus o de la instrucción dada.

    Fidelidad. En el caso de las respuestas generadas, se debe verificar que cada afirmación esté respaldada por los fragmentos que se citan. La evaluación del modelo funciona bastante bien siempre y cuando el evaluador tenga tareas sencillas: una oración, un pasaje y una respuesta de sí o no sobre su validez.

    Exactitud en el rechazo. Existe un conjunto separado con preguntas cuyo manejo correcto consiste en rechazarlas o redirigirlas, de modo que los cambios en los umbrales no puedan erosionar silenciosamente ese comportamiento.

    Leer más