A todo el mundo le encantan los modelos de incrustación de texto, y por una buena razón: son excelentes para codificar texto no estructurado, lo que facilita el descubrimiento de contenido semánticamente similar. No es de extrañar que constituyan la columna vertebral de la mayoría de las aplicaciones RAG, especialmente con el énfasis actual en la codificación y recuperación de información relevante de documentos y otros recursos textuales. Sin embargo, existen ejemplos claros de preguntas que uno podría plantearse en las que el enfoque de incrustación de texto en las aplicaciones RAG falla y proporciona información incorrecta.
Como se mencionó, las incrustaciones de texto son excelentes para codificar texto no estructurado. Por otro lado, no son tan buenas para manejar información estructurada y operaciones como filtrado , ordenación o agregaciones . Imagine una pregunta simple como:
¿Cuál es la película mejor valorada estrenada en 2024?
Para responder a esta pregunta, primero debemos filtrar por año de lanzamiento y luego ordenar por calificación. Analizaremos cómo funciona un enfoque ingenuo con incrustaciones de texto y luego demostraremos cómo abordar estas preguntas. Esta publicación del blog muestra que, al tratar con operaciones de datos estructurados, como el filtrado, la clasificación o la agregación, es necesario utilizar otras herramientas que proporcionen estructura, como los gráficos de conocimiento.
El código está disponible en GitHub .
Para esta publicación del blog, utilizaremos el proyecto de recomendaciones en Neo4j Sandbox . El proyecto de recomendaciones utiliza el conjunto de datos MovieLens , que contiene películas, actores, calificaciones y más información.
El siguiente código creará una instancia de un contenedor LangChain para conectarse a la base de datos Neo4j:
os.environ["NEO4J_URI"] = "bolt://44.204.178.84:7687" os.environ["NEO4J_USERNAME"] = "neo4j" os.environ["NEO4J_PASSWORD"] = "minimums-triangle-saving" graph = Neo4jGraph(refresh_schema=False)
Además, necesitarás una clave API de OpenAI que deberás pasar en el siguiente código:
os.environ["OPENAI_API_KEY"] = getpass.getpass("OpenAI API Key:")
La base de datos contiene 10.000 películas, pero aún no se han almacenado las incrustaciones de texto. Para evitar calcular las incrustaciones de todas ellas, etiquetaremos las 1.000 películas mejor valoradas con una etiqueta secundaria denominada Target :
graph.query(""" MATCH (m:Movie) WHERE m.imdbRating IS NOT NULL WITH m ORDER BY m.imdbRating DESC LIMIT 1000 SET m:Target """)
Decidir qué incluir es un aspecto importante a tener en cuenta. Dado que demostraremos cómo filtrar por año y ordenar por calificación, no sería justo excluir esos detalles del texto incluido. Por eso elegí capturar el año de estreno, la calificación, el título y la descripción de cada película.
A continuación se muestra un ejemplo del texto que incorporaremos a la película El lobo de Wall Street :
plot: Based on the true story of Jordan Belfort, from his rise to a wealthy stock-broker living the high life to his fall involving crime, corruption and the federal government. title: Wolf of Wall Street, The year: 2013 imdbRating: 8.2
Se podría decir que este no es un buen enfoque para incorporar datos estructurados, pero no lo discutiría porque no conozco el mejor enfoque. Tal vez, en lugar de elementos clave-valor, deberíamos convertirlos en texto o algo así. Avísame si tienes alguna idea sobre lo que podría funcionar mejor.
El objeto Vector Neo4j en LangChain tiene un método conveniente from_existing_graph donde puede seleccionar qué propiedades de texto deben codificarse:
embedding = OpenAIEmbeddings(model="text-embedding-3-small") neo4j_vector = Neo4jVector.from_existing_graph( embedding=embedding, index_name="movies", node_label="Target", text_node_properties=["plot", "title", "year", "imdbRating"], embedding_node_property="embedding", )
En este ejemplo, utilizamos el modelo text-embedding-3-small de OpenAI para la generación de incrustaciones. Inicializamos el objeto Neo4jVector utilizando el método from_existing_graph. El parámetro node_label filtra los nodos que se van a codificar, específicamente aquellos etiquetados como Target . El parámetro text_node_properties define las propiedades de los nodos que se van a incrustar, incluyendo plot , title , year y imdbRating . Finalmente, embedding_node_property define la propiedad donde se almacenarán las incrustaciones generadas, designada como embedding .
Comencemos intentando encontrar una película basándonos en su trama o descripción:
pretty_print( neo4j_vector.similarity_search( "What is a movie where a little boy meets his hero?" ) )
Resultados:
plot: A young boy befriends a giant robot from outer space that a paranoid government agent wants to destroy. title: Iron Giant, The year: 1999 imdbRating: 8.0 plot: After the death of a friend, a writer recounts a boyhood journey to find the body of a missing boy. title: Stand by Me year: 1986 imdbRating: 8.1 plot: A young, naive boy sets out alone on the road to find his wayward mother. Soon he finds an unlikely protector in a crotchety man and the two have a series of unexpected adventures along the way. title: Kikujiro (Kikujirô no natsu) year: 1999 imdbRating: 7.9 plot: While home sick in bed, a young boy's grandfather reads him a story called The Princess Bride. title: Princess Bride, The year: 1987 imdbRating: 8.1
Los resultados parecen bastante sólidos en general. Siempre hay un niño involucrado, aunque no estoy seguro de si siempre conoce a su héroe. Por otra parte, el conjunto de datos solo incluye 1000 películas, por lo que las opciones son algo limitadas.
Ahora probemos una consulta que requiere un filtrado básico:
pretty_print( neo4j_vector.similarity_search( "Which movies are from year 2016?" ) )
Resultados:
plot: Six short stories that explore the extremities of human behavior involving people in distress. title: Wild Tales year: 2014 imdbRating: 8.1 plot: A young man who survives a disaster at sea is hurtled into an epic journey of adventure and discovery. While cast away, he forms an unexpected connection with another survivor: a fearsome Bengal tiger. title: Life of Pi year: 2012 imdbRating: 8.0 plot: Based on the true story of Jordan Belfort, from his rise to a wealthy stock-broker living the high life to his fall involving crime, corruption and the federal government. title: Wolf of Wall Street, The year: 2013 imdbRating: 8.2 plot: After young Riley is uprooted from her Midwest life and moved to San Francisco, her emotions - Joy, Fear, Anger, Disgust and Sadness - conflict on how best to navigate a new city, house, and school. title: Inside Out year: 2015 imdbRating: 8.3
Es curioso, pero no se seleccionó ni una sola película de 2016. Tal vez podríamos obtener mejores resultados con una preparación de texto diferente para la codificación. Sin embargo, las incrustaciones de texto no son aplicables aquí, ya que estamos tratando con una operación de datos estructurados simple en la que necesitamos filtrar documentos o, en este ejemplo, películas en función de una propiedad de metadatos. El filtrado de metadatos es una técnica bien establecida que se emplea a menudo para mejorar la precisión de los sistemas RAG.
La siguiente consulta que intentaremos requiere un poco de clasificación:
pretty_print( neo4j_vector.similarity_search("Which movie has the highest imdb score?") )
Resultados:
plot: A silent film production company and cast make a difficult transition to sound. title: Singin' in the Rain year: 1952 imdbRating: 8.3 plot: A film about the greatest pre-Woodstock rock music festival. title: Monterey Pop year: 1968 imdbRating: 8.1 plot: This movie documents the Apollo missions perhaps the most definitively of any movie under two hours. Al Reinert watched all the footage shot during the missions--over 6,000,000 feet of it, ... title: For All Mankind year: 1989 imdbRating: 8.2 plot: An unscrupulous movie producer uses an actress, a director and a writer to achieve success. title: Bad and the Beautiful, The year: 1952 imdbRating: 7.9
Si estás familiarizado con las calificaciones de IMDb, sabrás que hay muchas películas con una puntuación superior a 8,3. El título mejor calificado en nuestra base de datos es, en realidad, una serie ( Band of Brothers ), con una impresionante calificación de 9,6. Una vez más, las incrustaciones de texto tienen un rendimiento deficiente cuando se trata de ordenar los resultados.
Evaluemos también una pregunta que requiere algún tipo de agregación:
pretty_print(neo4j_vector.similarity_search("How many movies are there?"))
Resultados:
plot: Ten television drama films, each one based on one of the Ten Commandments. title: Decalogue, The (Dekalog) year: 1989 imdbRating: 9.2 plot: A documentary which challenges former Indonesian death-squad leaders to reenact their mass-killings in whichever cinematic genres they wish, including classic Hollywood crime scenarios and lavish musical numbers. title: Act of Killing, The year: 2012 imdbRating: 8.2 plot: A meek Hobbit and eight companions set out on a journey to destroy the One Ring and the Dark Lord Sauron. title: Lord of the Rings: The Fellowship of the Ring, The year: 2001 imdbRating: 8.8 plot: While Frodo and Sam edge closer to Mordor with the help of the shifty Gollum, the divided fellowship makes a stand against Sauron's new ally, Saruman, and his hordes of Isengard. title: Lord of the Rings: The Two Towers, The year: 2002 imdbRating: 8.7
Los resultados no son de ninguna utilidad en este caso porque obtenemos cuatro películas al azar. Es prácticamente imposible llegar a la conclusión de que hay un total de 1000 películas que etiquetamos e incrustamos para este ejemplo a partir de estas cuatro películas al azar.
¿Cuál es entonces la solución? Es sencilla: las preguntas que implican operaciones estructuradas, como filtrado, ordenación y agregación, necesitan herramientas diseñadas para operar con datos estructurados.
En este momento, parece que la mayoría de la gente piensa en el enfoque text2query, donde un LLM genera una consulta de base de datos para interactuar con una base de datos en función de la pregunta y el esquema proporcionados. Para Neo4j, esto es text2cypher, pero también existe text2sql para bases de datos SQL. Sin embargo, resulta que en la práctica no es confiable ni lo suficientemente robusto para su uso en producción.
Evaluación de la generación de declaraciones de Cypher. Tomado de mi publicación de blog sobre la evaluación de Cypher .
Puede utilizar técnicas como la cadena de pensamiento, ejemplos de pocos intentos o el ajuste fino, pero lograr una alta precisión sigue siendo casi imposible en esta etapa. El enfoque text2query funciona bien para preguntas simples sobre esquemas de bases de datos sencillos, pero esa no es la realidad de los entornos de producción. Para abordar esto, trasladamos la complejidad de generar consultas de bases de datos de un LLM y lo tratamos como un problema de código donde generamos consultas de bases de datos de manera determinista en función de las entradas de funciones. La ventaja es una robustez significativamente mejorada, aunque se produce a costa de una flexibilidad reducida. Es mejor limitar el alcance de la aplicación RAG y responder esas preguntas con precisión, en lugar de intentar responder todo pero hacerlo de manera imprecisa.
Dado que generamos consultas de base de datos (en este caso, declaraciones Cypher) en función de las entradas de funciones, podemos aprovechar las capacidades de las herramientas de LLM. En este proceso, LLM completa los parámetros relevantes en función de la entrada del usuario, mientras que la función se encarga de recuperar la información necesaria. Para esta demostración, primero implementaremos dos herramientas: una para contar películas y otra para enumerarlas, y luego crearemos un agente LLM utilizando LangGraph.
Comenzamos implementando una herramienta para contar películas en base a filtros predefinidos. Primero, tenemos que definir qué son esos filtros y describirle a un LLM cuándo y cómo utilizarlos:
class MovieCountInput(BaseModel): min_year: Optional[int] = Field( description="Minimum release year of the movies" ) max_year: Optional[int] = Field( description="Maximum release year of the movies" ) min_rating: Optional[float] = Field(description="Minimum imdb rating") grouping_key: Optional[str] = Field( description="The key to group by the aggregation", enum=["year"] )
LangChain ofrece varias formas de definir las entradas de la función, pero yo prefiero el enfoque de Pydantic. En este ejemplo, tenemos tres filtros disponibles para refinar los resultados de las películas: min_year, max_year y min_rating. Estos filtros se basan en datos estructurados y son opcionales, ya que el usuario puede optar por incluir alguno, todos o ninguno de ellos. Además, hemos introducido una entrada grouping_key que le indica a la función si debe agrupar el recuento por una propiedad específica. En este caso, la única agrupación admitida es por año, como se define en la sección de enumeración.
Ahora definamos la función real:
@tool("movie-count", args_schema=MovieCountInput) def movie_count( min_year: Optional[int], max_year: Optional[int], min_rating: Optional[float], grouping_key: Optional[str], ) -> List[Dict]: """Calculate the count of movies based on particular filters""" filters = [ ("t.year >= $min_year", min_year), ("t.year <= $max_year", max_year), ("t.imdbRating >= $min_rating", min_rating), ] # Create the parameters dynamically from function inputs params = { extract_param_name(condition): value for condition, value in filters if value is not None } where_clause = " AND ".join( [condition for condition, value in filters if value is not None] ) cypher_statement = "MATCH (t:Target) " if where_clause: cypher_statement += f"WHERE {where_clause} " return_clause = ( f"t.`{grouping_key}`, count(t) AS movie_count" if grouping_key else "count(t) AS movie_count" ) cypher_statement += f"RETURN {return_clause}" print(cypher_statement) # Debugging output return graph.query(cypher_statement, params=params)
La función movie_count genera una consulta Cypher para contar películas según filtros opcionales y una clave de agrupación. Comienza definiendo una lista de filtros con los valores correspondientes proporcionados como argumentos. Los filtros se utilizan para crear dinámicamente la cláusula WHERE, que es responsable de aplicar las condiciones de filtrado especificadas en la declaración Cypher, incluidas solo aquellas condiciones donde los valores no son None.
Luego se construye la cláusula RETURN de la consulta Cypher, ya sea agrupando por la grouping_key proporcionada o simplemente contando el número total de películas. Finalmente, la función ejecuta la consulta y devuelve los resultados.
La función se puede ampliar con más argumentos y una lógica más compleja según sea necesario, pero es importante garantizar que permanezca clara para que un LLM pueda llamarla de manera correcta y precisa.
Nuevamente, tenemos que comenzar definiendo los argumentos de la función:
class MovieListInput(BaseModel): sort_by: str = Field( description="How to sort movies, can be one of either latest, rating", enum=["latest", "rating"], ) k: Optional[int] = Field(description="Number of movies to return") description: Optional[str] = Field(description="Description of the movies") min_year: Optional[int] = Field( description="Minimum release year of the movies" ) max_year: Optional[int] = Field( description="Maximum release year of the movies" ) min_rating: Optional[float] = Field(description="Minimum imdb rating")
Mantenemos los mismos tres filtros que en la función de recuento de películas, pero añadimos el argumento de descripción. Este argumento nos permite buscar y enumerar películas en función de su trama mediante la búsqueda por similitud de vectores. El hecho de que utilicemos herramientas y filtros estructurados no significa que no podamos incorporar la incrustación de texto y los métodos de búsqueda de vectores. Dado que no queremos devolver todas las películas la mayor parte del tiempo, incluimos una entrada k opcional con un valor predeterminado. Además, para la lista, queremos ordenar las películas para que solo devuelvan las más relevantes. En este caso, podemos ordenarlas por clasificación o año de estreno.
Implementemos la función:
@tool("movie-list", args_schema=MovieListInput) def movie_list( sort_by: str = "rating", k : int = 4, description: Optional[str] = None, min_year: Optional[int] = None, max_year: Optional[int] = None, min_rating: Optional[float] = None, ) -> List[Dict]: """List movies based on particular filters""" # Handle vector-only search when no prefiltering is applied if description and not min_year and not max_year and not min_rating: return neo4j_vector.similarity_search(description, k=k) filters = [ ("t.year >= $min_year", min_year), ("t.year <= $max_year", max_year), ("t.imdbRating >= $min_rating", min_rating), ] # Create parameters dynamically from function arguments params = { key.split("$")[1]: value for key, value in filters if value is not None } where_clause = " AND ".join( [condition for condition, value in filters if value is not None] ) cypher_statement = "MATCH (t:Target) " if where_clause: cypher_statement += f"WHERE {where_clause} " # Add the return clause with sorting cypher_statement += " RETURN t.title AS title, t.year AS year, t.imdbRating AS rating ORDER BY " # Handle sorting logic based on description or other criteria if description: cypher_statement += ( "vector.similarity.cosine(t.embedding, $embedding) DESC " ) params["embedding"] = embedding.embed_query(description) elif sort_by == "rating": cypher_statement += "t.imdbRating DESC " else: # sort by latest year cypher_statement += "t.year DESC " cypher_statement += " LIMIT toInteger($limit)" params["limit"] = k or 4 print(cypher_statement) # Debugging output data = graph.query(cypher_statement, params=params) return data
Esta función recupera una lista de películas en función de varios filtros opcionales: descripción, rango de años, calificación mínima y preferencias de clasificación. Si solo se proporciona una descripción sin otros filtros, realiza una búsqueda de similitud de índice vectorial para encontrar películas relevantes. Cuando se aplican filtros adicionales, la función construye una consulta Cypher para hacer coincidir las películas en función de los criterios especificados, como el año de estreno y la calificación de IMDb, combinándolos con una similitud opcional basada en la descripción. Luego, los resultados se ordenan por puntuación de similitud, calificación de IMDb o año, y se limitan a k películas.
Implementaremos un agente ReAct sencillo usando LangGraph.
El agente consta de un LLM y un paso de herramientas. A medida que interactuamos con el agente, primero llamaremos al LLM para decidir si debemos usar herramientas. Luego ejecutaremos un bucle:
La implementación del código es lo más sencilla posible. Primero vinculamos las herramientas al LLM y definimos el paso del asistente:
llm = ChatOpenAI(model='gpt-4-turbo') tools = [movie_count, movie_list] llm_with_tools = llm.bind_tools(tools) # System message sys_msg = SystemMessage(content="You are a helpful assistant tasked with finding and explaining relevant information about movies.") # Node def assistant(state: MessagesState): return {"messages": [llm_with_tools.invoke([sys_msg] + state["messages"])]}
A continuación definimos el flujo LangGraph:
# Graph builder = StateGraph(MessagesState) # Define nodes: these do the work builder.add_node("assistant", assistant) builder.add_node("tools", ToolNode(tools)) # Define edges: these determine how the control flow moves builder.add_edge(START, "assistant") builder.add_conditional_edges( "assistant", # If the latest message (result) from assistant is a tool call -> tools_condition routes to tools # If the latest message (result) from assistant is a not a tool call -> tools_condition routes to END tools_condition, ) builder.add_edge("tools", "assistant") react_graph = builder.compile()
Definimos dos nodos en LangGraph y los vinculamos con una arista condicional. Si se llama a una herramienta, el flujo se dirige a las herramientas; de lo contrario, los resultados se envían de vuelta al usuario.
Ahora probemos nuestro agente:
messages = [ HumanMessage( content="What are the some movies about a girl meeting her hero?" ) ] messages = react_graph.invoke({"messages": messages}) for m in messages["messages"]: m.pretty_print()
Resultados:
En el primer paso, el agente elige utilizar la herramienta de lista de películas con el parámetro de descripción adecuado. No está claro por qué selecciona un valor k de 5, pero parece favorecer ese número. La herramienta devuelve las cinco películas más relevantes según la trama y el LLM simplemente las resume para el usuario al final.
Si le preguntamos a ChatGPT por qué le gusta el valor k de 5, obtenemos la siguiente respuesta.
A continuación, planteemos una pregunta un poco más compleja que requiere filtrado de metadatos:
messages = [ HumanMessage( content="What are the movies from the 90s about a girl meeting her hero?" ) ] messages = react_graph.invoke({"messages": messages}) for m in messages["messages"]: m.pretty_print()
Resultados:
Esta vez, se utilizaron argumentos adicionales para filtrar solo películas de la década de 1990. Este sería un ejemplo típico de filtrado de metadatos utilizando el enfoque de prefiltrado. La declaración Cypher generada primero limita las películas filtrando por su año de estreno. En la siguiente parte, la declaración Cypher utiliza incrustaciones de texto y búsqueda de similitud vectorial para encontrar películas sobre una niña que conoce a su héroe.
Intentemos contar películas en función de varias condiciones:
messages = [ HumanMessage( content="How many movies are from the 90s have the rating higher than 9.1?" ) ] messages = react_graph.invoke({"messages": messages}) for m in messages["messages"]: m.pretty_print()
Resultados:
Con una herramienta dedicada al conteo, la complejidad se desplaza del LLM a la herramienta, y el LLM queda a cargo únicamente de completar los parámetros de función relevantes. Esta separación de tareas hace que el sistema sea más eficiente y robusto, y reduce la complejidad de la entrada del LLM.
Dado que el agente puede invocar múltiples herramientas secuencialmente o en paralelo, probémoslo con algo aún más complejo:
messages = [ HumanMessage( content="How many were movies released per year made after the highest rated movie?" ) ] messages = react_graph.invoke({"messages": messages}) for m in messages["messages"]: m.pretty_print()
Resultados
Como se mencionó, el agente puede invocar múltiples herramientas para recopilar toda la información necesaria para responder la pregunta. En este ejemplo, comienza enumerando las películas mejor calificadas para identificar cuándo se estrenó la película mejor calificada. Una vez que tiene esos datos, llama a la herramienta de recuento de películas para recopilar la cantidad de películas estrenadas después del año especificado, utilizando una clave de agrupación como se define en la pregunta.
Si bien las incrustaciones de texto son excelentes para buscar en datos no estructurados, no son suficientes cuando se trata de operaciones estructuradas como filtrar , ordenar y agregar . Estas tareas requieren herramientas diseñadas para datos estructurados, que ofrecen la precisión y la flexibilidad necesarias para manejar estas operaciones. La conclusión clave es que expandir el conjunto de herramientas en su sistema le permite abordar una gama más amplia de consultas de usuarios, lo que hace que sus aplicaciones sean más sólidas y versátiles. La combinación de enfoques de datos estructurados y técnicas de búsqueda de texto no estructurado puede brindar respuestas más precisas y relevantes, lo que en última instancia mejora la experiencia del usuario en las aplicaciones RAG.
Como siempre, el código está disponible en GitHub .
Para obtener más información sobre este tema, únase a nosotros en NODES 2024 el 7 de noviembre, nuestra conferencia virtual gratuita para desarrolladores sobre aplicaciones inteligentes, gráficos de conocimiento e IA. ¡Regístrese ahora!