Freedam
IngenieríaParte 2 de 7 · 26 min de lecturaActualizado el

Cómo construimos la búsqueda de imágenes en PostgreSQL

Como una consulta de búsqueda de imágenes se ejecuta sobre cosas que derivamos de los píxeles (metadatos, texto generado, embeddings o hashes perceptuales) y no sobre los píxeles en bruto, la indexación importa más de lo que parece al principio: el ranking no puede recuperar un término o una señal útil que nunca llegó a un índice.

En freedam, la capa de recuperación se ejecuta en la misma base de datos PostgreSQL que los registros de los recursos, con pg_textsearch para el ranking BM25 y pgvector para el almacenamiento de vectores y la búsqueda HNSW. Los modelos de embeddings se ejecutan en un sidecar local, mientras que los vectores resultantes y ambos índices de búsqueda viven en PostgreSQL, de modo que no hay un Elasticsearch ni una base de datos vectorial aparte que mantener sincronizados.

Este artículo describe esa ruta de producción (cómo construimos el índice, combinamos los resultados léxicos y semánticos, sorteamos los límites de candidatos y comprobamos si un conjunto de resultados está completo) con valores de una biblioteca real y una suite repetible de pruebas de relevancia. Para una visión menos técnica, consulta la página de búsqueda.

Por qué la búsqueda de imágenes es distinta

Una imagen no tiene palabras buscables

Una fila de pedido tiene el nombre de un cliente, un ticket de soporte tiene un cuerpo de texto y un PDF suele tener una capa de texto, mientras que un JPEG nos da píxeles y algo de EXIF, pero ningún término con el que comparar una consulta. Por eso tenemos que construir una o varias representaciones buscables de él; en la práctica usamos cuatro:

Cuatro puentes entre una consulta de texto y los píxeles de una imagen consulta «zapatilla roja» los píxeles sin términos dentro Nombre de archivo y títuloescritos una vez, por una persona, normalmente con prisa Metadatos estructurados y vocabulariosprecisos y filtrables, pero costosos, así que siempre son parciales Texto derivado: OCR, descripción, transcripciónescrito por una máquina: abundante, con ruido y ciego a tu negocio Embedding: 768 números de coma flotantesignificado sin palabras; no sabe deletrear un identificador
Cada representación atiende un tipo distinto de consulta. La búsqueda en producción necesita más de una.

Es tentador elegir una representación y construir el sistema a su alrededor: los productos de «búsqueda con IA» suelen partir de los embeddings, mientras que las bibliotecas de recursos más antiguas tienden a apoyarse en los metadatos. Ambos pueden dar una demo convincente, pero los registros de consultas reales contienen varios tipos distintos de búsquedas:

  • Consultas de identificadores: 4471-RD, SS26-HERO-03, un GAID, un número de factura. Necesitan una coincidencia léxica exacta porque la búsqueda vectorial tiende a devolver otros identificadores que se parecen en el espacio de embeddings.
  • Consultas de nomenclatura: galaxy a52 case, taper plate, biodegradable mailer. Piden coincidencia léxica sobre un vocabulario controlado, además de tolerancia a cómo se escribió el término.
  • Consultas de descripción: team celebrating outdoors, something calm for the newsletter header. Quizá nadie etiquetó el recurso con esas palabras, así que aquí es donde los embeddings deberían ayudar.
  • Consultas por ejemplo: «más como este», «¿ya tenemos licencia para esta fotografía?». No interviene texto en ninguno de los dos extremos.

Un solo método de recuperación no puede manejar bien los cuatro, así que el problema práctico es cómo combinarlos sin debilitar las coincidencias exactas y cómo saber cuándo los límites de candidatos han descartado resultados válidos.

Dónde falla cada enfoque

Los compromisos son bastante predecibles:

La búsqueda por palabras clave necesita palabras con las que coincidir. Funciona bien con identificadores y terminología conocida, pero solo cuando esos términos llegaron al índice, y no detecta cambios simples de redacción cuando, por ejemplo, el fotógrafo escribió autumn y quien hace marketing busca fall.

La búsqueda vectorial es débil en exactitud. Es útil para consultas descriptivas, pero mala con códigos de producto y otros identificadores. Como una consulta de vecino más cercano devuelve k filas aunque ninguna sea una buena coincidencia, una consulta común de una palabra como chair puede llenar la página de interiores vagamente relacionados cuando el umbral es demasiado laxo.

El etiquetado manual es costoso y pierde coherencia con el tiempo. Un vocabulario controlado es valioso, pero rara vez está completo y acaba divergiendo del lenguaje que la gente usa al buscar.

Un LLM es mejor interpretando la consulta que ordenando el corpus. Ejecutar un modelo sobre 200.000 recursos en cada búsqueda sería lento, costoso y difícil de reproducir, así que lo usamos antes de la recuperación, donde puede convertir una frase en filtros estructurados.

Nuestra ruta de búsqueda de texto tiene, por tanto, dos índices sobre el mismo documento: BM25 para las coincidencias léxicas y un embedding para las coincidencias semánticas. Fusionamos sus posiciones, añadimos una ruta aparte para los casos que necesitan garantías de exhaustividad (recall) más fuertes e informamos de si la búsqueda estaba completa.

Construir el documento de búsqueda

Lo que la búsqueda puede encontrar depende de lo que llegó al índice, así que freedam combina los metadatos disponibles y el texto derivado de cada recurso en un documento de búsqueda almacenado, y lo reconstruye cada vez que el recurso cambia.

Cómo se compilan el documento de búsqueda y el embedding de un recurso FUENTES CONSTRUCTORES ÍNDICES título, nombre de archivo, descripción autor, copyright, ubicación palabras clave + etiquetas de IA, descripción texto de OCR, texto extraído valores de metadatos personalizados término de vocabulario + ancestros nombres de colecciones personas confirmadas (rostros) etiquetas de puntos de enfoque subtítulos / transcripción documento de búsqueda todo, ponderado peso = repetición literal ancestro 3 / padre 2 / hoja 1 una fila por idioma texto del embedding un subconjunto con presupuesto: título 300 / desc. 1500 meta 2000 / OCR 2000 8000 caracteres en total léxico content_tsv, generado y almacenado un índice bm25(content) por idioma k1 = 1.2, b = 0.75, parcial por idioma índice GIN de trigramas para tolerar erratas 20 configuraciones de stemmer semántico nomic-embed-text-v1.5, 768 dimensiones alineado con nomic-embed-vision-v1.5 HNSW, coseno, m = 16 ef_construction = 200 parcial: solo vectores no nulos Toda fuente alimenta el documento de búsqueda; el texto del embedding toma una selección del mismo material con presupuesto de caracteres.
Los mismos datos de origen producen un documento léxico y un embedding. Ambos se almacenan en la misma fila de PostgreSQL y se actualizan en la misma transacción.

Tres detalles de implementación resultaron importantes.

Implementamos los pesos de los campos mediante repetición. Un campo con peso n se añade n veces porque el índice BM25 no tiene el concepto de campo, pero esto también aumenta la longitud del documento, que la normalización por longitud de BM25 (b = 0.75) penaliza. Encontramos once videos de producto que superaban a las imágenes de producto esperadas en una consulta por un nombre de lugar porque sus documentos tenían entre 155 y 493 caracteres mientras que los de las imágenes tenían entre 746 y 1280, de modo que el esquema de ponderación había introducido también un sesgo por longitud de documento.

Aplanamos la jerarquía del vocabulario dentro del documento. Un recurso etiquetado con el término hoja espresso machine recibe además small appliances (padre, peso 2) y kitchen (ancestro, peso 3) escritos en su documento. Esto hace que una taxonomía sea buscable y no solo filtrable: una consulta por la categoría encuentra la hoja sin un join ni conocimiento del árbol.

El tsvector es una columna generada almacenada. La recuperación híbrida comprueba en cada candidato la cobertura de términos después del escaneo ordenado, así que almacenamos el tsvector como una columna GENERATED ALWAYS AS (…) STORED en lugar de descomprimir y analizar el documento con to_tsvector(content) en cada búsqueda. El compromiso es que su correspondencia entre idioma y stemmer queda fija en el esquema, así que pasar de 7 a 20 configuraciones exigió una migración que reconstruyó la columna y los índices BM25 afectados. El tsvector almacenado, el índice BM25 y el plainto_tsquery del momento de la consulta deben usar el mismo stemmer; de lo contrario, la comprobación de cobertura puede rechazar coincidencias válidas.

Una trampa de tokenización

Un problema con el que nos topamos afectaba a términos de vocabulario separados por barras en una biblioteca de comercio minorista, donde etiquetas de compatibilidad de dispositivos como Galaxy A52/A52S y iPhone 12/12 Pro estaban presentes en los recursos y en el documento de búsqueda (un LIKE '%A52%' encontraba 178), pero galaxy a52 case devolvía solo doce resultados.

El analizador english de PostgreSQL clasifica un valor unido por barras como un único token de tipo file, de modo que ts_debug('english', 'Galaxy A52/A52S') produce el lexema A52/A52S y no a52 más a52s: el texto estaba indexado, pero el término que usaba la consulta nunca existió en el índice BM25.

Las etiquetas unidas por barras se indexan como un solo lexema hasta que se separan ANTES Galaxy A52/A52S etiqueta del vocabulario, literal analizador galaxi asciiword A52/A52S un token, tipo «file» consulta «a52»: sin coincidencia 12 de 178 recursos encontrables DESPUÉS: SE EMITEN LAS FORMAS SEPARADAS JUNTO AL ORIGINAL Galaxy A52/A52S constructor A52/A52S · A52 · A52S se conserva el original, así que la búsqueda de la forma exacta sigue funcionando consulta «a52»: coincide 178 recursos; recall 0,06 → 1,00
Cincuenta y cinco etiquetas de vocabulario unidas por barras cubrían 2811 recursos en una biblioteca, pero ninguno se encontraba por sus partes aunque los datos parecían correctos.

La solución fue emitir A52 y A52S junto al valor original A52/A52S; reindexar la misma entrada habría producido los mismos tokens, así que el cambio correspondía al constructor de documentos.

Hubo tres conclusiones útiles:

  • Aplica este tipo de normalización en el límite más estrecho que sea útil. Separar todas las barras del documento también afectó a EXIF (1/100, 1:1.8/26), a voltajes (50/60Hz), a URL, a referencias de directivas de la UE (2009/125/EC) y a conjunciones (and/or, und/oder), así que ahora separamos solo las etiquetas de vocabulario.
  • Comprueba los términos por la ruta de recuperación de producción, ya que nuestro diccionario de erratas y prefijos usa un tokenizador distinto y ya informaba de a52 en 178 documentos aunque BM25 solo podía recuperar doce.
  • Mantén consultas de control no relacionadas en la suite de relevancia, ya que la corrección dirigida cambió las longitudes de los documentos y costó un caso de prueba existente cuyo conjunto de candidatos ya estaba en el tope.

Dos rutas de recuperación sobre una tabla

BM25 con pg_textsearch

Ejecutamos BM25 con la extensión pg_textsearch en la misma instancia de PostgreSQL que contiene los recursos, de modo que no hay un clúster de búsqueda aparte ni un trabajo de sincronización de índices.

Esto mantiene la búsqueda en la misma transacción que el resto de la aplicación, de modo que un recurso recién importado es buscable sin esperar a un worker de sincronización, mientras que los permisos, los embargos y los derechos de uso siguen siendo predicados SQL normales y no un segundo modelo de autorización. En un despliegue autoalojado, además, elimina un servicio con estado.

El costo principal es que el rendimiento de las consultas depende mucho de la forma que ve el planificador de PostgreSQL. Algunos detalles importaron en nuestras consultas:

  • Los índices son por idioma y parciales. Construimos un índice BM25 por idioma con WITH (text_config=…, k1=1.2, b=0.75) WHERE language_code = 'xx', así que recurrir a otro idioma significa cambiar a la vez el nombre del índice, el predicado de idioma y la configuración de texto; una versión anterior cambiaba solo el nombre del índice y producía un índice parcial que no podía servir las filas del idioma solicitado.
  • El escaneo ordenado del índice solo se mantiene rápido mientras tiene un límite de filas. Un escaneo BM25 ordenado sigue su ruta top-k cuando el ejecutor recibe un límite, que sobrevive a un CTE referenciado una vez porque el planificador lo integra en la consulta principal, y también a una tabla derivada con su propio LIMIT, pero no a una subconsulta escalar ni a un CTE referenciado dos veces. Referenciar un CTE por segunda vez cambió la consulta de 6 ms a 7,3 s, así que ahora arrastramos los indicadores de pertenencia por la unión con BOOL_OR en lugar de volver a hacer un join con ese CTE.
  • Los predicados no indexables van después del escaneo, no dentro. La cobertura de términos, es decir, «¿contiene este documento realmente todos los términos de la consulta?», se verifica contra el content_tsv almacenado sobre el conjunto acotado de candidatos que devolvió el escaneo. Si pones ese predicado dentro del escaneo ordenado, el planificador abandona el índice a favor de un merge join con reevaluación por fila; medimos 7,4 s para una consulta de esa forma.

El escaneo bruto también obtiene 3× el número configurado de candidatos porque el escaneo ordenado de pg_textsearch emite aproximadamente tres filas por documento; con bm25_top_k = 1000, el límite bruto es 3000 y el escaneo tarda unos 10 ms en nuestra biblioteca de pruebas, pero la latencia sube bruscamente cuando el límite bruto se acerca a 6000.

Búsqueda vectorial con pgvector

El texto y las imágenes se representan como vectores en el mismo espacio de 768 dimensiones con nomic-embed-text-v1.5 y nomic-embed-vision-v1.5, ambos servidos por un sidecar local para que el contenido de los recursos no salga del despliegue. Los modelos de Nomic usan prefijos de tarea (generamos los embeddings de los documentos con el prefijo search_document y los de las consultas con search_query), y un prefijo equivocado sigue produciendo vectores pero reduce la relevancia sin producir un error evidente.

pgvector almacena esos vectores y proporciona el índice HNSW, para el que usamos distancia coseno, m = 16 y ef_construction = 200, con un índice parcial sobre los vectores no nulos. Guardamos brevemente en caché los embeddings de consultas, incluidas las marcas de fallo, de modo que si el sidecar no está disponible, la búsqueda recurre a BM25 en lugar de reintentar la llamada fallida al modelo en cada pulsación de tecla.

Un único umbral de distancia no funcionó para todas las consultas, así que lo variamos según la longitud de la consulta, y los valores no son monótonos:

Forma de la consulta Distancia coseno máx.
1 palabra 0,35
2 palabras 0,42
de 3 a 5 palabras 0,45
de 6 a 10 palabras (o > 30 car.) 0,48
> 10 palabras (o > 60 car.) 0,40

Usamos umbrales más estrictos en ambos extremos por razones distintas: una sola palabra como chair tiene un vecindario semántico amplio y puede coincidir con la mayoría de las fotografías de interiores, mientras que un título de producto largo y pegado tiende a producir un embedding menos específico que queda débilmente cerca de muchos recursos. Las consultas intermedias son lo bastante específicas como para describir algo sin promediar demasiado texto, así que ahí permitimos un umbral más laxo.

Aplicamos una regla adicional a las consultas de una sola palabra (exigen una coincidencia léxica), de modo que una fila que solo coincide por similitud vectorial se descarta, en lugar de dejar que un término de nicho sin coincidencia real devuelva una página completa de imágenes semánticamente cercanas.

Fusionar las dos listas de resultados

Como una puntuación BM25 y una distancia coseno miden cosas distintas, normalizarlas a una puntuación común haría que el resultado dependiera del corpus, del idioma y de los umbrales del momento, así que combinamos sus posiciones con Reciprocal Rank Fusion (RRF):

rrf_score(d) = w_bm25 / (k + rank_bm25(d))  +  w_vector / (k + rank_vector(d))
                                                          k = 60,  w = 1.0
Reciprocal rank fusion de una lista BM25 y una lista vectorial POSICIONES BM25 se conservan los 1000 primeros (escaneo bruto 3000) 1 recurso 4471 2 recurso 9310 8 recurso 1180 40 recurso 7702 … sin coincidencia vectorial POSICIONES VECTORIALES los 200 primeros, con filtro de distancia 1 recurso 6025 3 recurso 7702 11 recurso 4471 62 recurso 1180 … sin coincidencia léxica FULL OUTER JOIN una fila puede estar en una lista o en ambas 1/(60+r₁) + 1/(60+r₂) posición ausente ⇒ 0,0, nunca una penalización FUSIONADO 4471 0,0305 7702 0,0259 1180 0,0229 6025 0,0164 9310 0,0161 7702 quedó 40.º en el léxico, 3.º en el semántico, así que gana. después, por orden: impulso por frase (el título contiene la consulta literal = 2, el documento la contiene = 1) ▸ rrf_score ▸ id del recurso umbral adaptativo según el número de palabras ▸ las filas de una sola palabra deben tener posición léxica ▸ se admiten las bandas garantizadas
RRF no necesita calibrar puntuaciones porque un documento ausente de una lista aporta simplemente cero desde ese canal, mientras que el full outer join permite competir a una coincidencia puramente semántica.

Tres detalles de implementación causaron errores o comportamientos sorprendentes.

Mantén la aritmética de la fusión en coma flotante. Si la base de datos evalúa la división como aritmética de enteros, las puntuaciones fraccionarias de RRF se reducen a cero y la consulta sigue devolviendo filas pero ya no las ordena de forma significativa, así que convertimos los operandos de forma explícita.

El umbral adaptativo tiene efectos estructurales. Descartamos las filas de baja puntuación usando una puntuación RRF mínima según el número de palabras: 0.008 para una palabra, 0.015 para dos, 0.025 para tres a cinco, 0.028 para seis a diez y 0.012 para más. Cada valor se escala por (w_bm25 + w_vector)/2 × 61/(k+1) para que cambiar k o los pesos no lo invalide. Esto tiene dos consecuencias directas:

  • Una consulta de dos palabras nunca puede devolver cero resultados porque una fila solo vectorial puntúa 1/(60 + rank), y el umbral de dos palabras de 0.015 admite exactamente las posiciones del 1 al 6, razón por la cual una consulta absurda de dos palabras devuelve exactamente seis cosas.
  • Con tres a cinco palabras, el umbral de 0.025 supera la mejor puntuación posible solo vectorial de 1/61 ≈ 0.0164, lo que hace estructuralmente imposible una coincidencia puramente semántica en esa franja, de modo que todo resultado de una consulta de cuatro palabras lleva una posición léxica.

Las coincidencias léxicas verificadas se saltan el umbral. Para consultas de diez palabras o menos, comprobamos si cada candidato BM25 contiene todos los términos de la consulta, de modo que esas filas no necesitan la puntuación RRF mínima y el umbral se aplica solo a las filas sin una coincidencia léxica verificada.

Los topes de candidatos pueden ocultar coincidencias

Todo sistema de recuperación limita cuántos candidatos puntuará, y nuestros valores por defecto son 1000 candidatos BM25 de un escaneo bruto de 3000 filas, 200 candidatos vectoriales y 1000 filas en el conjunto fusionado.

Una vez que un recurso queda fuera de un tope de candidatos, las etapas de ranking posteriores no pueden verlo, pero la primera página puede seguir pareciendo razonable, lo que hace difícil notar el problema. Lo llamamos cap crowding (acaparamiento del tope), y en un caso 1953 documentos contenían todos los términos de la consulta pero competían por 1000 posiciones BM25, dejando excluidos los recursos empatados por el desempate alfabético final.

Para las búsquedas en las que necesitamos una exhaustividad más fuerte, añadimos una banda garantizada al conjunto de candidatos: sus filas reciben posiciones después del escaneo normal, y cada tope posterior las deja pasar de forma explícita, haciéndolas alcanzables sin colocarlas por delante de los resultados ordenados normalmente.

Las bandas garantizadas sobreviven al tope de candidatos CANDIDATOS ORDENADOS pos. 1 … 998 pos. 999 pos. 1000 tope pos. 1001, descartada pos. 1002, descartada … 953 más, en silencio BANDA GARANTIZADA Filas que deben poder encontrarse sea cual sea su posición: • miembros de la colección en la que buscaste • (opcionalmente) todo recurso cuyo título lleve el término Entran en las posiciones rawLimit + n, más allá de toda posición real del escaneo, y MIN(best_pos) conserva la posición real de todo lo que ya estaba dentro del corte. Por qué el orden no se altera Las filas de la banda se ordenan después de toda fila ordenada, así que la primera página que ve el usuario es idéntica byte a byte al ranking sin bandas. Cada filtro posterior, el umbral de RRF y el LIMIT final, lleva un «…O esta fila está en una banda» explícito. La marca sube por la unión con BOOL_OR en lugar de un segundo join, para que el escaneo conserve su límite de filas.
La banda cambia qué filas son alcanzables sin cambiar el orden de las filas ordenadas.

Buscar dentro de una colección

El cap crowding lo vimos con más frecuencia al buscar dentro de una colección: si 30 de 400 miembros de una colección contienen un término, una búsqueda en todo el tenant puede devolver solo cuatro. Esto ocurre porque los recursos de fuera de la colección ocupan las demás posiciones del top 1000 antes de que se aplique el filtro de la colección, de modo que los resultados que faltan son un problema de recuperación y no de ranking.

En cada búsqueda con un término de texto, freedam resuelve el ámbito actual y crea para él una banda de recuperación. La implementación depende del número de recursos de ese ámbito:

Estrategia de banda de ámbito según su tamaño 1 32 2000 50.000 ∞ TAMAÑO DEL ÁMBITO: MIEMBROS DE LA COLECCIÓN EN LA QUE BUSCAS puntuar a cada miembro sin escaneo: se ordena el documento de cada miembro completo por construcción ≈ 0,6 ms por miembro + escaneo vectorial exacto coseno por fuerza bruta sobre los miembros, sin pérdida de recall de ANN en el ámbito < 40 ms con 2000 escaneo ordenado profundo leer la lista ordenada hasta cubrir el ámbito, hasta 45.000 filas ≈ 2,7 µs/fila → ≈ 120 ms sin banda un ámbito tan grande ya se solapa mucho con el conjunto de candidatos de todo el tenant el trabajo extra no compensaría
La estrategia cambia con el tamaño del ámbito, y en un ámbito pequeño puntuamos a cada miembro porque es la única variante que sigue siendo demostrablemente completa con cualquier tamaño de corpus.

Las búsquedas sin ámbito no ejecutan este SQL adicional; materializamos la banda solo cuando hay un ámbito y su tamaño encaja en alguna de las estrategias útiles anteriores.

Informar de si los resultados están completos

Junto con los resultados, freedam devuelve una información más: si la búsqueda encontró todas las coincidencias cuya existencia puede demostrar.

Esto importa cuando un usuario aplica una acción a todo el conjunto de resultados, porque «selecciona los 1240 resultados y aplica esta política de derechos» solo es seguro cuando 1240 es el conjunto completo; si la recuperación se detuvo en 1000 candidatos, la misma operación actualizaría solo una parte de la biblioteca prevista.

Calculamos un veredicto de exhaustividad con sondas: columnas COUNT adicionales que registran cuántas filas produjo cada fuente de candidatos y si esa fuente alcanzó su tope.

Ejecución de búsqueda en dos fases con una sonda de exhaustividad dentro de la transacción FASE 1 antes de abrir ninguna transacción congelar todos los ajustes en una instantánea inmutable obtener todos los embeddings que pueda necesitar el árbol de filtros tras esta línea, sin llamadas externas FASE 2 una transacción de solo lectura, aislamiento REPEATABLE READ compilar el árbol de filtros → SQL consulta de conteo · consulta de página sentencia de sonda, una por hoja cada conteo describe los mismos datos de los que salieron los resultados VEREDICTO Completo Alcanzó el límite Desconocido la evidencia que falta termina aquí, nunca en Completo Dos reglas que mantienen honestos los conteos 1. El SQL de la sonda se arma con los mismos fragmentos que la consulta de resultados, nunca a mano, para que no pueda desajustarse. 2. Las columnas de sonda son tablas derivadas de una fila con cross join en el árbol FROM principal: la única forma en que cada escaneo incorporado conserva su límite de filas. Como subconsultas escalares serían correctas y unas mil veces más lentas.
Los resultados y sus sondas de exhaustividad se ejecutan sobre la misma instantánea de la base de datos en una transacción.

El veredicto es conservador: una fuente informa Completo solo cuando puede dar cuenta de todas las coincidencias. Puntuar a cada miembro de una colección pequeña es, por tanto, completo por construcción, mientras que un escaneo profundo es completo solo cuando la población dentro del ámbito se mantiene por debajo del tope de la banda y el escaneo no alcanza su límite de filas. Las fuentes nuevas de candidatos son Desconocido por defecto, y los mecanismos de respaldo como la corrección de erratas o la expansión por prefijo obtienen su propio veredicto en lugar de heredar el de la consulta original.

Este trabajo también nos dejó una regla útil para la monitorización: si el comportamiento monitorizado se rompe, la comprobación debe producir un resultado distinto. Teníamos un monitor que se quedaba en silencio después de que su tarea muriera, una prueba de desempate que seguía en verde después de que el desempate desapareciera de tres capas de SQL y advertencias de calidad que no podían cambiar el código de salida, así que ahora nos hacemos una pregunta simple al añadir una comprobación: ¿qué salida cambia cuando esto se rompe?

Otras formas de buscar

La ruta anterior empieza con una consulta de texto, pero también admitimos respaldos por erratas y prefijos, entrada de imágenes, rostros confirmados y filtros en lenguaje natural.

Erratas y prefijos, en ese orden

También indexamos el documento con un índice GIN de trigramas para las sugerencias «¿quisiste decir?» y el respaldo por erratas, con correcciones que se aplican token por token contra el vocabulario propio de la biblioteca y un umbral de similitud más estricto para los tokens cortos. La expansión por prefijo tiene un presupuesto de candidatos aparte y se ejecuta primero, porque de lo contrario la corrección de erratas podría reescribir un prefijo válido antes de que tuviera ocasión de coincidir.

Los índices, diccionarios y reglas de respaldo específicos de cada idioma se tratan en Buscar en una biblioteca en veinte idiomas.

Búsqueda por imagen

La búsqueda inversa de imágenes («¿ya hemos visto esto?», «encuentra el resto de esta sesión», «¿es esto un casi duplicado de algo que ya licenciamos?») usa un índice distinto: generamos hashes perceptuales para seis regiones de cada imagen, recuperamos candidatos con multi-index hashing y los verificamos con un vector de miniatura y un embedding visual, porque la búsqueda por hash es barata y amplia, mientras que la verificación vectorial es más lenta y más precisa. La pipeline completa de candidatos y verificación se trata en Encontrar la misma imagen dos veces.

Rostros

Los rostros usan un detector y un espacio de embeddings de 512 dimensiones aparte, de buffalo_l de InsightFace, con HNSW y distancia coseno, y la detección se ejecuta en un sidecar local. El modelo agrupa rostros similares, pero una persona debe confirmar la identidad antes de que añadamos el nombre al documento de búsqueda, de modo que photos of <person> at the launch event se convierte en una consulta de texto ordinaria. Las decisiones sobre detección, identidad y umbral se tratan en Un rostro no es un nombre.

Lenguaje natural, compilado a un árbol de filtros

Para la búsqueda conversacional, el asistente de freedam no ordena recursos sino que convierte la frase en un árbol de filtros, la misma estructura que produce la interfaz de filtros avanzados. La solicitud lleva además una intención (replace, refine, add, remove o reset) que controla cómo se combina con los filtros activos antes de que el compilador de consultas existente ejecute ese árbol con los mismos permisos y comprobaciones de exhaustividad que cualquier otra búsqueda.

Los usuarios pueden inspeccionar y editar los filtros generados y, una vez compilada, la recuperación es determinista, requiere una llamada al modelo en lugar de una por candidato y solo puede devolver recursos que existen. Los agentes externos usan la misma ruta por MCP con los permisos asociados a su token, mientras que el compilador de filtros y el modelo de estado conversacional se tratan en El chat que nunca ve tu biblioteca.

Cómo probamos la búsqueda

Los umbrales y los límites de candidatos anteriores son valores ajustados y no constantes generales, así que los evaluamos con un manifiesto de consultas realistas y una verdad de referencia acordada. La suite se ejecuta por la ruta de búsqueda de producción sobre una réplica de una biblioteca real e informa de aprobado/suspenso estricto, recall@10, recall@50 y posición recíproca media por categoría, siempre en modo de solo lectura: puede buscar y seleccionar las filas de la verdad de referencia, pero no puede cambiar ajustes ni reconstruir un índice.

Las primeras ejecuciones cambiaron nuestra forma de trabajar en la búsqueda:

  1. La primera ejecución completa encontró quince fallos totales en sesenta casos. Quienes usaban la misma búsqueda la habían descrito como «bastante buena» porque los primeros resultados solían parecer plausibles.
  2. Los fallos se agrupaban por mecanismo. Los quince fallos venían de unas pocas causas (truncamiento de ámbito, tokenización, cap crowding y efectos de la longitud del documento), de modo que corregir una causa mejoraba varios casos a la vez.
  3. Nuestro diagnóstico inicial solía ser erróneo. Atribuimos cinco regresiones de exhaustividad a un cambio de tokenización, pero las mediciones mostraron que cuatro no se habían movido en absoluto y que la causa real era la longitud del documento.
  4. Una corrección acertada aún puede tener un costo. Restringir la separación por barras a las etiquetas de vocabulario resolvió el error pero perdió un caso de prueba que se beneficiaba de documentos competidores más largos, y la suite pasó de 48 aprobados a 47, así que publicamos la corrección y registramos el compromiso.

Lo que volveríamos a hacer

Las decisiones que llevaríamos a otro sistema de búsqueda son directas:

  • Haz el trabajo de indexación al principio. La calidad de la recuperación está acotada por el documento y los vectores que almacenaste, porque el ranking no puede recuperar un término que el tokenizador nunca emitió.
  • Fusiona posiciones, no puntuaciones. Reciprocal rank fusion no requiere calibrar puntuaciones y sigue siendo útil cuando cambian el corpus, el idioma o los umbrales. k = 60 es un punto de partida razonable y rara vez fue el ajuste que valía la pena afinar en nuestro caso.
  • Varía los umbrales según la forma de la consulta. Las consultas de una palabra y de diez palabras se comportan de forma distinta, y en las de una sola palabra, exigir una coincidencia léxica elimina una gran cantidad de ruido semántico.
  • Trata los topes de candidatos como una restricción de corrección. Añade una ruta que pueda alcanzar filas importantes más allá del tope, informa de la truncación, o ambas cosas.
  • Devuelve un estado desconocido. Completo, truncado y desconocido son más útiles que un total que parece exacto pero no lo es.
  • Construye las sondas a partir de la consulta de producción. Una versión escrita aparte a mano acabará desajustándose.
  • Prueba también la instrumentación. Nuestro diccionario de términos informaba de a52 en 178 documentos mientras que BM25 solo podía recuperar doce.
  • Mantén consultas de control estables en la suite de relevancia. Muestran qué cambió fuera de los casos que una corrección debía mejorar.

Implementación y demo

Esta ruta de recuperación la comparten la galería, la API, el SDK de TypeScript, el asistente y los agentes conectados por MCP, con BM25 ejecutándose mediante pg_textsearch y la búsqueda vectorial HNSW mediante pgvector en la misma instancia de PostgreSQL que los recursos. El sidecar de embeddings se ejecuta en local, de modo que ningún contenido de los recursos sale del despliegue, y se usa la misma pila en las instalaciones autoalojadas.

Para una visión general del producto, consulta búsqueda y descubrimiento, o prueba la demo para ejecutar la búsqueda sobre una biblioteca multimedia de ejemplo; una buena prueba es una descripción visual que normalmente no aparecería en etiquetas introducidas a mano.

Para desarrolladores

Sigue leyendo