Es la noche de juegos, tus amigos están sentados alrededor de la mesa de juego, esperando ver en qué personaje de Dungeons & Dragons (D&D) se convertirán y en qué misión se embarcarán. Esta noche, eres el Dungeon Master (narrador y guía), creador de emocionantes encuentros para desafiar y cautivar a tus jugadores. Tu confiable Manual de Monstruos de D&D contiene miles de criaturas. Encontrar el monstruo perfecto para cada situación entre la gran cantidad de opciones puede ser abrumador. El enemigo ideal debe coincidir con el entorno, la dificultad y la narrativa del momento.
¿Qué pasaría si pudiéramos crear una herramienta que encuentre al instante el monstruo más adecuado para cada escenario? ¿Una herramienta que considere múltiples factores simultáneamente , asegurando que cada encuentro sea lo más envolvente y emocionante posible?
Embarquémonos en nuestra propia búsqueda: ¡construir el sistema definitivo para encontrar monstruos, utilizando el poder de la búsqueda vectorial de múltiples atributos!
La búsqueda vectorial representa una revolución en la recuperación de información. La incorporación de vectores (al tener en cuenta el contexto y el significado semántico) permite que la búsqueda vectorial arroje resultados más relevantes y precisos, gestione no solo datos estructurados sino también no estructurados y en varios idiomas, y sea escalable. Pero para generar respuestas de alta calidad en aplicaciones del mundo real, a menudo necesitamos asignar diferentes pesos a atributos específicos de nuestros objetos de datos.
Existen dos enfoques comunes para la búsqueda de vectores de atributos múltiples. Ambos comienzan incorporando por separado cada atributo de un objeto de datos. La principal diferencia entre estos dos enfoques radica en cómo se almacenan y se buscan nuestras incorporaciones.
spaces
de Superlinked también nos permiten ponderar cada atributo en el momento de la consulta para obtener resultados más relevantes, sin posprocesamiento. A continuación, utilizaremos estos dos enfoques para implementar una herramienta de búsqueda de vectores de atributos múltiples: ¡un buscador de monstruos de Dungeons and Dragons! Nuestras sencillas implementaciones, especialmente la segunda, ilustrarán cómo crear sistemas de búsqueda más potentes y flexibles, que puedan manejar consultas complejas y multifacéticas con facilidad, cualquiera sea su caso de uso.
Si no tienes experiencia en búsquedas de similitud de vectores, ¡no te preocupes! Te ayudamos: consulta nuestros artículos sobre componentes básicos .
¡Muy bien, vamos a cazar monstruos!
Primero, generaremos un pequeño conjunto de datos sintéticos de monstruos, solicitando un Modelo de Lenguaje Grande (LLM):
Generate two JSON lists: 'monsters' and 'queries'. 1. 'monsters' list: Create 20 unique monsters with the following properties: - name: A distinctive name - look: Brief description of appearance (2-3 sentences) - habitat: Where the monster lives (2-3 sentences) - behavior: How the monster acts (2-3 sentences) Ensure some monsters share similar features while remaining distinct. 2. 'queries' list: Create 5 queries to search for monsters: - Each query should be in the format: {look: "...", habitat: "...", behavior: "..."} - Use simple, brief descriptions (1-3 words per field) - Make queries somewhat general to match multiple monsters Output format: { "monsters": [ {"name": "...", "look": "...", "habitat": "...", "behavior": "..."}, ... ], "queries": [ {"look": "...", "habitat": "...", "behavior": "..."}, ... ] }
Echemos un vistazo a una muestra del conjunto de datos generado por nuestro LLM. Nota: la generación de LLM no es determinista, por lo que los resultados pueden diferir.
Aquí están nuestros primeros cinco monstruos:
# | nombre | mirar | hábitat | comportamiento |
---|---|---|---|---|
0 | Luminoth | Criatura parecida a una polilla con alas y antenas brillantes. | Bosques densos y selvas con flora bioluminiscente | Emite patrones de luz relajantes para comunicarse y atraer presas. |
1 | Espectro acuático | Figura humanoide translúcida hecha de agua fluyendo. | Ríos, lagos y zonas costeras | Cambia de forma para mezclarse con los cuerpos de agua y controla las corrientes. |
2 | Gólem de corazón de piedra | Humanoide masivo compuesto de formaciones rocosas entrelazadas. | Montañas rocosas y ruinas antiguas | Hiberna durante siglos y despierta para proteger su territorio. |
3 | Sombra susurrante | Ser sombrío y amorfo con ojos brillantes. | Bosques oscuros y edificios abandonados. | Se alimenta del miedo y susurra verdades inquietantes. |
4 | Bailarina del céfiro | Graciosa criatura aviar con plumas iridiscentes. | Altas cumbres montañosas y llanuras azotadas por el viento | Crea exhibiciones aéreas fascinantes para atraer parejas. |
...y nuestras consultas generadas:
| Mirar | Hábitat | Comportamiento |
---|---|---|---|
0 | Brillante | Lugares oscuros | Manipulación de la luz |
1 | Elemental | Entornos extremos | Control ambiental |
2 | Cambio de forma | Paisajes variados | Creación de ilusiones |
3 | Cristalino | Zonas ricas en minerales | Absorción de energía |
4 | Etéreo | Atmosférico | Influencia de la mente |
Vea el conjunto de datos original y ejemplos de consultas aquí .
Configuremos a continuación los parámetros que usaremos en ambos enfoques (ingenuo y supervinculado).
Generamos nuestras incrustaciones vectoriales con:
sentence-transformers/all-mpnet-base-v2.
Para simplificar, limitaremos nuestra salida a las 3 coincidencias principales. (Para ver el código completo, incluidas las importaciones y funciones auxiliares necesarias, consulte el cuaderno ).
LIMIT = 3 MODEL_NAME = "sentence-transformers/all-mpnet-base-v2"
Ahora, ¡comencemos nuestra búsqueda de monstruos con múltiples atributos! Primero, probaremos el enfoque ingenuo .
En nuestro enfoque ingenuo, incorporamos atributos de forma independiente y los almacenamos en diferentes índices. En el momento de la consulta, ejecutamos múltiples búsquedas kNN en todos los índices y luego combinamos todos nuestros resultados parciales en uno.
Comenzamos definiendo una clase
NaiveRetriever
para realizar una búsqueda basada en similitud en nuestro conjunto de datos, utilizando nuestras incrustaciones generadas por all-mpnet-base-v2
.
class NaiveRetriever: def __init__(self, data: pd.DataFrame): self.model = SentenceTransformer(MODEL_NAME) self.data = data.copy() self.ids = self.data.index.to_list() self.knns = {} for key in self.data: embeddings = self.model.encode(self.data[key].values) knn = NearestNeighbors(metric="cosine").fit(embeddings) self.knns[key] = knn def search_key(self, key: str, value: str, limit: int = LIMIT) -> pd.DataFrame: embedding = self.model.encode(value) knn = self.knns[key] distances, indices = knn.kneighbors( [embedding], n_neighbors=limit, return_distance=True ) ids = [self.ids[i] for i in indices[0]] similarities = (1 - distances).flatten() # by definition: # cosine distance = 1 - cosine similarity result = pd.DataFrame( {"id": ids, f"score_{key}": similarities, key: self.data[key][ids]} ) result.set_index("id", inplace=True) return result def search(self, query: dict, limit: int = LIMIT) -> pd.DataFrame: results = [] for key, value in query.items(): if key not in self.knns: continue result_key = self.search_key(key, value, limit=limit) result_key.drop(columns=[key], inplace=True) results.append(result_key) merged_results = pd.concat(results, axis=1) merged_results["score"] = merged_results.mean(axis=1, skipna=False) merged_results.sort_values("score", ascending=False, inplace=True) return merged_results naive_retriever = NaiveRetriever(df.set_index("name"))
Utilicemos la primera consulta de nuestra lista generada anteriormente y busquemos monstruos usando nuestro naive_retriever
:
query = { 'look': 'glowing', 'habitat': 'dark places', 'behavior': 'light manipulation' } naive_retriever.search(query)
Nuestro
naive_retriever
devuelve los siguientes resultados de búsqueda para cada atributo:
identificación | puntuación_mira | mirar |
---|---|---|
Sombra susurrante | 0,503578 | Ser sombrío y amorfo con ojos brillantes. |
Djinn de tormenta de arena | 0,407344 | Vórtice de arena con símbolos brillantes |
Luminoth | 0,378619 | Criatura parecida a una polilla con alas y antenas brillantes. |
¡Genial! Los resultados de los monstruos que obtuvimos son relevantes: todos tienen alguna característica "brillante".
Veamos qué devuelve el enfoque ingenuo cuando buscamos los otros dos atributos.
identificación | puntuación_hábitat | hábitat |
---|---|---|
Sombra susurrante | 0,609567 | Bosques oscuros y edificios abandonados. |
Red de hongos | 0,438856 | Cavernas subterráneas y bosques húmedos |
Elemental de la vid espinosa | 0,423421 | Ruinas cubiertas de vegetación y selvas densas |
identificación | puntuación_comportamiento | comportamiento |
---|---|---|
Grafiti viviente | 0,385741 | Cambia de forma para mimetizarse con el entorno y absorbe pigmentos. |
Draco de alas de cristal | 0,385211 | Almacena gemas preciosas y puede refractar la luz en rayos poderosos. |
Luminoth | 0,345566 | Emite patrones de luz relajantes para comunicarse y atraer presas. |
Todos los monstruos recuperados poseen los atributos deseados. A primera vista, los resultados de la búsqueda ingenua pueden parecer prometedores, pero necesitamos encontrar monstruos que posean los tres atributos simultáneamente . Combinemos nuestros resultados para ver qué tan bien logran nuestros monstruos este objetivo:
identificación | puntuación_mira | puntuación_hábitat | puntuación_comportamiento |
---|---|---|---|
Sombra susurrante | 0,503578 | 0,609567 | |
Djinn de tormenta de arena | 0,407344 | | |
Luminoth | 0,378619 | | 0,345566 |
Red de hongos | | 0,438856 | |
Elemental de la vid espinosa | | 0,423421 | |
Grafiti viviente | | | 0,385741 |
Draco de alas de cristal | | | 0,385211 |
Y aquí es donde se hacen evidentes los límites del enfoque ingenuo. Evaluemos:
look
: Se recuperaron tres monstruos (Whispering Shade, Sandstorm Djinn y Luminoth).habitat
: Solo un monstruo de los resultados look
fue relevante (Whispering Shade).behavior
: Solo un monstruo de los resultados look
fue relevante (Luminoth), pero es diferente del que es relevante para habitat
.En resumen, el enfoque de búsqueda ingenua no logra encontrar monstruos que satisfagan todos los criterios a la vez. ¿Quizás podamos solucionar este problema recuperando de manera proactiva más monstruos para cada atributo? Probemos con 6 monstruos por atributo, en lugar de 3. Veamos lo que genera este enfoque:
identificación | puntuación_mira | puntuación_hábitat | puntuación_comportamiento |
---|---|---|---|
Sombra susurrante | 0,503578 | 0,609567 | |
Djinn de tormenta de arena | 0,407344 | 0,365061 | |
Luminoth | 0,378619 | | 0,345566 |
Medusa nebulosa | 0,36627 | | 0,259969 |
Pulpo de Dreamweaver | 0,315679 | | |
Luciérnaga cuántica | 0,288578 | | |
Red de hongos | | 0,438856 | |
Elemental de la vid espinosa | | 0,423421 | |
Fantasma de niebla | | 0,366816 | 0,236649 |
Gólem de corazón de piedra | | 0,342287 | |
Grafiti viviente | | | 0,385741 |
Draco de alas de cristal | | | 0,385211 |
Espectro acuático | | | 0,283581 |
Ya hemos recuperado 13 monstruos (¡más de la mitad de nuestro pequeño conjunto de datos!) y todavía tenemos el mismo problema: ninguno de estos monstruos fue recuperado para los tres atributos.
Aumentar el número de monstruos recuperados (más allá de 6) podría resolver nuestro problema, pero crea problemas adicionales:
En resumen, el enfoque ingenuo es demasiado incierto e ineficiente para una búsqueda multiatributo viable, especialmente en producción.
Implementemos nuestro segundo enfoque para ver si funciona mejor que el ingenuo.
Primero, definimos el esquema, los espacios, el índice y la consulta:
@schema class Monster: id: IdField look: String habitat: String behavior: String monster = Monster() look_space = TextSimilaritySpace(text=monster.look, model=MODEL_NAME) habitat_space = TextSimilaritySpace(text=monster.habitat, model=MODEL_NAME) behavior_space = TextSimilaritySpace(text=monster.behavior, model=MODEL_NAME) monster_index = Index([look_space, habitat_space, behavior_space]) monster_query = ( Query( monster_index, weights={ look_space: Param("look_weight"), habitat_space: Param("habitat_weight"), behavior_space: Param("behavior_weight"), }, ) .find(monster) .similar(look_space.text, Param("look")) .similar(habitat_space.text, Param("habitat")) .similar(behavior_space.text, Param("behavior")) .limit(LIMIT) ) default_weights = { "look_weight": 1.0, "habitat_weight": 1.0, "behavior_weight": 1.0 }
Ahora, iniciamos el ejecutor y cargamos los datos:
monster_parser = DataFrameParser(monster, mapping={monster.id: "name"}) source: InMemorySource = InMemorySource(monster, parser=monster_parser) executor = InMemoryExecutor(sources=[source], indices=[monster_index]) app = executor.run() source.put([df])
Ejecutemos la misma consulta que ejecutamos en nuestra implementación del enfoque ingenuo anterior:
query = { 'look': 'glowing', 'habitat': 'dark places', 'behavior': 'light manipulation' } app.query( monster_query, limit=LIMIT, **query, **default_weights )
identificación | puntaje | mirar | hábitat | comportamiento |
---|---|---|---|---|
Sombra susurrante | 0,376738 | Ser sombrío y amorfo con ojos brillantes. | Bosques oscuros y edificios abandonados. | Se alimenta del miedo y susurra verdades inquietantes. |
Luminoth | 0,340084 | Criatura parecida a una polilla con alas y antenas brillantes. | Bosques densos y selvas con flora bioluminiscente | Emite patrones de luz relajantes para comunicarse y atraer presas. |
Grafiti viviente | 0,330587 | Criatura colorida y bidimensional que habita en superficies planas. | Áreas urbanas, en particular muros y vallas publicitarias. | Cambia de forma para mimetizarse con el entorno y absorbe pigmentos. |
¡Y listo! Esta vez, cada uno de nuestros mejores monstruos tiene una puntuación alta que representa una especie de "media" de las tres características que queremos que tenga nuestro monstruo. Desglosemos la puntuación de cada monstruo en detalle:
identificación | mirar | hábitat | comportamiento | total |
---|---|---|---|---|
Sombra susurrante | 0,167859 | 0,203189 | 0,005689 | 0,376738 |
Luminoth | 0,126206 | 0,098689 | 0,115189 | 0,340084 |
Grafiti viviente | 0,091063 | 0,110944 | 0,12858 | 0,330587 |
Tanto el segundo como el tercer resultado, Luminoth y Living Graffiti, poseen las tres características deseadas. El resultado superior, Whispering Shade, aunque es menos relevante en términos de manipulación de la luz (como se refleja en su puntuación behavior
(0,006), tiene características "brillantes" y un entorno oscuro que hacen que su look
(0,168) y habitat
(0,203) tengan puntuaciones muy altas, lo que le otorga la puntuación total más alta (0,377), lo que lo convierte en el monstruo más relevante en general. ¡Qué mejora!
¿Podemos replicar nuestros resultados? Probemos con otra consulta y averigüémoslo.
query = { 'look': 'shapeshifting', 'habitat': 'varied landscapes', 'behavior': 'illusion creation' }
identificación | puntaje | mirar | hábitat | comportamiento |
---|---|---|---|---|
Fantasma de niebla | 0,489574 | Humanoide etéreo, parecido a la niebla, con rasgos cambiantes. | Pantanos, páramos y costas brumosas | Atrae a los viajeros por mal camino con ilusiones y susurros. |
Bailarina del céfiro | 0,342075 | Graciosa criatura aviar con plumas iridiscentes. | Altas cumbres montañosas y llanuras azotadas por el viento | Crea exhibiciones aéreas fascinantes para atraer parejas. |
Sombra susurrante | 0,337434 | Ser sombrío y amorfo con ojos brillantes. | Bosques oscuros y edificios abandonados. | Se alimenta del miedo y susurra verdades inquietantes. |
¡Genial! Nuestros resultados vuelven a ser excelentes.
¿Qué sucede si queremos encontrar monstruos que sean similares a un monstruo específico de nuestro conjunto de datos? Probémoslo con un monstruo que aún no hemos visto: Harmonic Coral. Podríamos extraer atributos para este monstruo y crear parámetros de consulta manualmente. Pero Superlinked tiene un método with_vector
que podemos usar en el objeto de consulta. Debido a que el id de cada monstruo es su nombre, podemos expresar nuestra solicitud de manera tan simple como:
app.query( monster_query.with_vector(monster, "Harmonic Coral"), **default_weights, limit=LIMIT )
identificación | puntaje | mirar | hábitat | comportamiento |
---|---|---|---|---|
Coral armónico | 1 | Estructura ramificada, similar a un instrumento musical, con zarcillos vibrantes. | Mares poco profundos y pozas de marea | Crea melodías complejas para comunicar e influir en las emociones. |
Pulpo de Dreamweaver | 0,402288 | Cefalópodo con tentáculos que brillan como auroras | Fosas oceánicas profundas y cuevas submarinas | Influye en los sueños de las criaturas cercanas. |
Espectro acuático | 0,330869 | Figura humanoide translúcida hecha de agua fluyendo. | Ríos, lagos y zonas costeras | Cambia de forma para mezclarse con los cuerpos de agua y controla las corrientes. |
El resultado principal es el más relevante, el propio Harmonic Coral, como se esperaba. Los otros dos monstruos que recupera nuestra búsqueda son Dreamweaver Octopus y Aqua Wraith. Ambos comparten elementos temáticos ( atributos ) importantes con Harmonic Coral:
habitat
)behavior
)look
) Supongamos ahora que queremos dar más importancia al atributo look
. El marco Superlinked nos permite ajustar fácilmente los pesos en el momento de la consulta. Para facilitar la comparación, buscaremos monstruos similares a Harmonic Coral, pero con nuestros pesos ajustados para favorecer look
.
weights = { "look_weight": 1.0, "habitat_weight": 0, "behavior_weight": 0 } app.query( monster_query.with_vector(monster, "Harmonic Coral"), limit=LIMIT, **weights )
identificación | puntaje | mirar | hábitat | comportamiento |
---|---|---|---|---|
Coral armónico | 0,57735 | Estructura ramificada, similar a un instrumento musical, con zarcillos vibrantes. | Mares poco profundos y pozas de marea | Crea melodías complejas para comunicar e influir en las emociones. |
Elemental de la vid espinosa | 0,252593 | Criatura parecida a una planta con un cuerpo de enredaderas y espinas retorcidas. | Ruinas cubiertas de vegetación y selvas densas | Crece rápidamente y controla la vida vegetal circundante. |
Serpiente de plasma | 0,243241 | Criatura con forma de serpiente hecha de energía crepitante. | Tormentas eléctricas y centrales eléctricas | Se alimenta de corrientes eléctricas y puede cortocircuitar la tecnología. |
Todos nuestros resultados tienen (apropiadamente) apariencias similares: "Rama con zarcillos vibrantes", "Criatura parecida a una planta con un cuerpo de enredaderas y espinas retorcidas", "Parecido a una serpiente".
Ahora, hagamos otra búsqueda, ignorando la apariencia y buscando en cambio monstruos que sean similares en términos de habitat
y behavior
simultáneamente:
weights = { "look_weight": 0, "habitat_weight": 1.0, "behavior_weight": 1.0 }
identificación | puntaje | mirar | hábitat | comportamiento |
---|---|---|---|---|
Coral armónico | 0,816497 | Estructura ramificada, similar a un instrumento musical, con zarcillos vibrantes. | Mares poco profundos y pozas de marea | Crea melodías complejas para comunicar e influir en las emociones. |
Pulpo de Dreamweaver | 0,357656 | Cefalópodo con tentáculos que brillan como auroras | Fosas oceánicas profundas y cuevas submarinas | Influye en los sueños de las criaturas cercanas. |
Fantasma de niebla | 0,288106 | Humanoide etéreo, parecido a la niebla, con rasgos cambiantes. | Pantanos, páramos y costas brumosas | Atrae a los viajeros por mal camino con ilusiones y susurros. |
Una vez más, el método Superlinked produce excelentes resultados. Los tres monstruos viven en entornos acuáticos y poseen habilidades de control mental.
Por último, intentemos otra búsqueda, ponderando los tres atributos de manera diferente, para encontrar monstruos que, en comparación con el Coral Armónico, se vean algo similares, vivan en hábitats muy diferentes y posean un comportamiento muy similar:
weights = { "look_weight": 0.5, "habitat_weight": -1.0, "behavior_weight": 1.0 }
identificación | puntaje | mirar | hábitat | comportamiento |
---|---|---|---|---|
Coral armónico | 0,19245 | Estructura ramificada, similar a un instrumento musical, con zarcillos vibrantes. | Mares poco profundos y pozas de marea | Crea melodías complejas para comunicar e influir en las emociones. |
Luminoth | 0,149196 | Criatura parecida a una polilla con alas y antenas brillantes. | Bosques densos y selvas con flora bioluminiscente | Emite patrones de luz relajantes para comunicarse y atraer presas. |
Bailarina del céfiro | 0,136456 | Graciosa criatura aviar con plumas iridiscentes. | Altas cumbres montañosas y llanuras azotadas por el viento | Crea exhibiciones aéreas fascinantes para atraer parejas. |
¡Otra vez excelentes resultados! Nuestros otros dos monstruos recuperados, Luminoth y Zephyr Dancer, tienen un comportamiento similar al de Harmonic Coral y viven en hábitats diferentes a los de Harmonic Coral. También se ven muy diferentes a Harmonic Coral. (Si bien los zarcillos de Harmonic Coral y la antena de Luminoth son características algo similares, solo redujimos el look_weight
en 0,5, y el parecido entre los dos monstruos termina allí).
Veamos cómo se distribuyen las puntuaciones generales de estos monstruos en términos de atributos individuales:
identificación | mirar | hábitat | comportamiento | total |
---|---|---|---|---|
Coral armónico | 0,19245 | -0,3849 | 0,3849 | 0,19245 |
Luminoth | 0,052457 | -0,068144 | 0,164884 | 0,149196 |
Bailarina del céfiro | 0,050741 | -0,079734 | 0,165449 | 0,136456 |
Al ponderar negativamente habitat_weight
(-1,0), "rechazamos" deliberadamente a los monstruos con hábitats similares y, en su lugar, sacamos a la superficie monstruos cuyos entornos son diferentes al de Harmonic Coral, como se ve en las puntuaciones habitat
negativas de Luminoth y Zephyr Dancer. Las puntuaciones behavior
de Luminoth y Zephyr Dancer son relativamente altas, lo que indica su similitud conductual con Harmonic Coral. Sus puntuaciones look
son positivas, pero más bajas, lo que refleja cierta similitud visual, aunque no extrema, con Harmonic Coral.
En resumen, nuestra estrategia de reducir habitat_weight
a -1,0 y look_weight
a 0,5, pero mantener behavior_weight
en 1,0 resulta eficaz para sacar a la superficie monstruos que comparten características de comportamiento clave con Coral Armónico, pero que tienen entornos muy diferentes y tienen un aspecto al menos algo diferente.
La búsqueda de vectores de atributos múltiples es un avance significativo en la recuperación de información, ya que ofrece más precisión, comprensión contextual y flexibilidad que la búsqueda básica de similitud semántica. Sin embargo, nuestro enfoque ingenuo (arriba) -almacenar y buscar vectores de atributos por separado, y luego combinar los resultados- es limitado en capacidad, sutileza y eficiencia cuando necesitamos recuperar objetos con múltiples atributos simultáneos. (Además, múltiples búsquedas kNN toman más tiempo que una sola búsqueda con vectores concatenados).
Para manejar situaciones como esta, es mejor almacenar todos los vectores de atributos en el mismo almacén de vectores y realizar una única búsqueda , ponderando los atributos en el momento de la consulta. El enfoque Superlinked es más preciso, eficiente y escalable que el enfoque ingenuo para cualquier aplicación que requiera una recuperación de vectores de atributos múltiples, rápida, confiable y matizada, ya sea que su caso de uso sea abordar desafíos de datos del mundo real en su sistema de comercio electrónico o de recomendación... o algo completamente diferente, como luchar contra monstruos.
Publicado originalmente aquí .