Iedereen is dol op tekst-embeddingmodellen, en wel om een goede reden: ze zijn uitstekend in het coderen van ongestructureerde tekst, waardoor het makkelijker wordt om semantisch vergelijkbare content te ontdekken. Het is geen verrassing dat ze de ruggengraat vormen van de meeste RAG-applicaties, vooral met de huidige nadruk op het coderen en ophalen van relevante informatie uit documenten en andere tekstuele bronnen. Er zijn echter duidelijke voorbeelden van vragen die men zou kunnen stellen waarbij de tekst-embeddingbenadering van RAG-applicaties tekortschiet en onjuiste informatie levert.
Zoals gezegd zijn text embeddings geweldig in het coderen van ongestructureerde tekst. Aan de andere kant zijn ze niet zo geweldig in het omgaan met gestructureerde informatie en bewerkingen zoals filteren , sorteren of aggregaties . Stel je een simpele vraag voor als:
Wat is de best beoordeelde film die in 2024 uitkomt?
Om deze vraag te beantwoorden, moeten we eerst filteren op releasejaar, gevolgd door sorteren op beoordeling. We zullen onderzoeken hoe een naïeve benadering met tekst-embeddings presteert en vervolgens laten zien hoe je met dergelijke vragen omgaat. Deze blogpost laat zien dat je bij het werken met gestructureerde databewerkingen zoals filteren, sorteren of aggregeren andere tools moet gebruiken die structuur bieden, zoals knowledge graphs.
De code is beschikbaar op GitHub .
Voor deze blogpost gebruiken we het recommendations-project in Neo4j Sandbox . Het recommendations-project gebruikt de MovieLens-dataset , die films, acteurs, beoordelingen en meer informatie bevat.
De volgende code zal een LangChain-wrapper instantiëren om verbinding te maken met de Neo4j-database:
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)
Daarnaast hebt u een OpenAI API-sleutel nodig die u in de volgende code doorgeeft:
os.environ["OPENAI_API_KEY"] = getpass.getpass("OpenAI API Key:")
De database bevat 10.000 films, maar tekst-embeddings zijn nog niet opgeslagen. Om te voorkomen dat we embeddings voor al deze films berekenen, taggen we de 1.000 best beoordeelde films met een secundair label genaamd Target :
graph.query(""" MATCH (m:Movie) WHERE m.imdbRating IS NOT NULL WITH m ORDER BY m.imdbRating DESC LIMIT 1000 SET m:Target """)
Beslissen wat je wilt embedden is een belangrijke overweging. Omdat we het filteren op jaar en sorteren op beoordeling demonstreren, zou het niet eerlijk zijn om die details uit de embed-tekst weg te laten. Daarom heb ik ervoor gekozen om het releasejaar, de beoordeling, de titel en de beschrijving van elke film vast te leggen.
Hier is een voorbeeld van de tekst die we zullen insluiten voor de film The Wolf of 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
Je zou kunnen zeggen dat dit geen goede aanpak is voor het insluiten van gestructureerde data, en ik zou dat niet tegenspreken omdat ik de beste aanpak niet ken. Misschien moeten we ze in plaats van key-value items omzetten naar tekst of zoiets. Laat het me weten als je ideeën hebt over wat beter zou kunnen werken.
Het Neo4j Vector-object in LangChain heeft een handige methode from_existing_graphwaar u kunt selecteren welke tekstkenmerken gecodeerd moeten worden:
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", )
In dit voorbeeld gebruiken we OpenAI's text-embedding-3-small model voor embeddinggeneratie. We initialiseren het Neo4jVector-object met behulp van de from_existing_graph -methode. De parameter node_label filtert de nodes die gecodeerd moeten worden, met name die met het label Target . De parameter text_node_properties definieert de node-eigenschappen die ingebed moeten worden, waaronder plot , title , year en imdbRating . Tot slot definieert embedding_node_property de eigenschap waar de gegenereerde embeddings worden opgeslagen, aangeduid als embedding .
Laten we beginnen met het zoeken naar een film op basis van de plot of beschrijving:
pretty_print( neo4j_vector.similarity_search( "What is a movie where a little boy meets his hero?" ) )
Resultaten:
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
De resultaten lijken over het algemeen vrij solide. Er is consequent een jongetje bij betrokken, hoewel ik niet zeker weet of hij altijd zijn held ontmoet. Maar nogmaals, de dataset bevat slechts 1.000 films, dus de opties zijn enigszins beperkt.
Laten we nu een query proberen die wat basisfiltering vereist:
pretty_print( neo4j_vector.similarity_search( "Which movies are from year 2016?" ) )
Resultaten:
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
Het is grappig, maar er is geen enkele film uit 2016 geselecteerd. Misschien kunnen we betere resultaten krijgen met een andere tekstvoorbereiding voor codering. Tekst-embeddings zijn hier echter niet van toepassing, omdat we te maken hebben met een eenvoudige gestructureerde databewerking waarbij we documenten of, in dit voorbeeld, films moeten filteren op basis van een metadata-eigenschap. Metadatafiltering is een bekende techniek die vaak wordt gebruikt om de nauwkeurigheid van RAG-systemen te verbeteren.
De volgende query die we zullen proberen, vereist wat sortering:
pretty_print( neo4j_vector.similarity_search("Which movie has the highest imdb score?") )
Resultaten:
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
Als je bekend bent met IMDb-beoordelingen, weet je dat er genoeg films zijn die boven de 8,3 scoren. De hoogst gewaardeerde titel in onze database is eigenlijk een serie — Band of Brothers — met een indrukwekkende 9,6-beoordeling. Nogmaals, tekst-embeddings presteren slecht als het gaat om het sorteren van resultaten.
Laten we ook een vraag evalueren die een vorm van aggregatie vereist:
pretty_print(neo4j_vector.similarity_search("How many movies are there?"))
Resultaten:
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
De resultaten zijn hier absoluut niet behulpzaam, omdat we vier willekeurige films terugkrijgen. Het is vrijwel onmogelijk om uit deze vier willekeurige films de conclusie te trekken dat er in totaal 1.000 films zijn die we hebben getagd en ingebed voor dit voorbeeld.
Wat is de oplossing? Het is eenvoudig: vragen over gestructureerde bewerkingen zoals filteren, sorteren en aggregeren vereisen tools die zijn ontworpen om met gestructureerde data te werken.
Op dit moment lijkt het erop dat de meeste mensen denken aan de text2query-benadering, waarbij een LLM een databasequery genereert om te interacteren met een database op basis van de opgegeven vraag en het opgegeven schema. Voor Neo4j is dit text2cypher, maar er is ook text2sql voor SQL-databases. In de praktijk blijkt het echter niet betrouwbaar en niet robuust genoeg te zijn voor productiegebruik.
Cypher statement generation evaluation. Overgenomen uit mijn blogpost over Cypher evaluation .
U kunt technieken gebruiken zoals chain of thought, few-shot examples of fine-tuning, maar het bereiken van een hoge nauwkeurigheid blijft in dit stadium vrijwel onmogelijk. De text2query-aanpak werkt goed voor eenvoudige vragen over rechttoe rechtaan databaseschema's, maar dat is niet de realiteit van productieomgevingen. Om dit aan te pakken, verplaatsen we de complexiteit van het genereren van databasequery's weg van een LLM en behandelen het als een codeprobleem waarbij we databasequery's deterministisch genereren op basis van functie-invoer. Het voordeel is een aanzienlijk verbeterde robuustheid, hoewel dit ten koste gaat van verminderde flexibiliteit. Het is beter om de reikwijdte van de RAG-applicatie te beperken en die vragen nauwkeurig te beantwoorden, in plaats van te proberen alles te beantwoorden maar dit onnauwkeurig te doen.
Omdat we databasequery's genereren — in dit geval Cypher-statements — op basis van functie-inputs, kunnen we de toolmogelijkheden van LLM's benutten. In dit proces vult de LLM de relevante parameters in op basis van gebruikersinput, terwijl de functie de benodigde informatie ophaalt. Voor deze demonstratie implementeren we eerst twee tools: een voor het tellen van films en een andere voor het weergeven ervan, en maken we vervolgens een LLM-agent met behulp van LangGraph.
We beginnen met het implementeren van een tool voor het tellen van films op basis van vooraf gedefinieerde filters. Eerst moeten we definiëren wat die filters zijn en aan een LLM beschrijven wanneer en hoe ze te gebruiken:
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 biedt verschillende manieren om functie-inputs te definiëren, maar ik geef de voorkeur aan de Pydantic-aanpak. In dit voorbeeld hebben we drie filters beschikbaar om filmresultaten te verfijnen: min_year, max_year en min_rating. Deze filters zijn gebaseerd op gestructureerde data en zijn optioneel, aangezien de gebruiker ervoor kan kiezen om er een, alle of geen op te nemen. Daarnaast hebben we een grouping_key-input geïntroduceerd die de functie vertelt of de telling moet worden gegroepeerd op een specifieke eigenschap. In dit geval is de enige ondersteunde groepering op jaar, zoals gedefinieerd in de enumsection.
Laten we nu de eigenlijke functie definiëren:
@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)
De movie_count-functie genereert een Cypher-query om films te tellen op basis van optionele filters en groeperingssleutels. Het begint met het definiëren van een lijst met filters met bijbehorende waarden die als argumenten worden verstrekt. De filters worden gebruikt om dynamisch de WHERE-component te bouwen, die verantwoordelijk is voor het toepassen van de opgegeven filtervoorwaarden in de Cypher-instructie, inclusief alleen die voorwaarden waarbij de waarden niet None zijn.
De RETURN-clausule van de Cypher-query wordt vervolgens geconstrueerd, door te groeperen op de opgegeven grouping_key of door simpelweg het totale aantal films te tellen. Ten slotte voert de functie de query uit en retourneert de resultaten.
De functie kan indien nodig worden uitgebreid met meer argumenten en meer ingewikkelde logica, maar het is belangrijk om ervoor te zorgen dat de functie overzichtelijk blijft, zodat een LLM deze correct en nauwkeurig kan aanroepen.
Opnieuw moeten we beginnen met het definiëren van de argumenten van de functie:
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")
We houden dezelfde drie filters aan als in de movie count-functie, maar voegen het description-argument toe. Met dit argument kunnen we films zoeken en weergeven op basis van hun plot met behulp van vector similarity search. Alleen omdat we gestructureerde tools en filters gebruiken, betekent dit niet dat we geen tekst-embedding en vector-zoekmethoden kunnen opnemen. Omdat we meestal niet alle films willen retourneren, voegen we een optionele k-invoer toe met een standaardwaarde. Bovendien willen we voor het weergeven de films sorteren om alleen de meest relevante te retourneren. In dit geval kunnen we ze sorteren op beoordeling of releasejaar.
Laten we de functie implementeren:
@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
Deze functie haalt een lijst met films op op basis van meerdere optionele filters: beschrijving, jaarbereik, minimale beoordeling en sorteervoorkeuren. Als alleen een beschrijving wordt gegeven zonder andere filters, voert het een vectorindexovereenkomstzoekopdracht uit om relevante films te vinden. Wanneer er extra filters worden toegepast, construeert de functie een Cypher-query om films te matchen op basis van de opgegeven criteria, zoals releasejaar en IMDb-beoordeling, en combineert deze met een optionele beschrijvinggebaseerde overeenkomst. De resultaten worden vervolgens gesorteerd op de overeenkomstscore, IMDb-beoordeling of jaar, en beperkt tot k films.
We implementeren een eenvoudige ReAct- agent met behulp van LangGraph.
De agent bestaat uit een LLM en tools stap. Terwijl we met de agent interacteren, roepen we eerst de LLM aan om te beslissen of we tools moeten gebruiken. Vervolgens voeren we een lus uit:
De code-implementatie is zo eenvoudig als het maar kan. Eerst binden we de tools aan de LLM en definiëren we de assistent-stap:
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"])]}
Vervolgens definiëren we de LangGraph-stroom:
# 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()
We definiëren twee knooppunten in de LangGraph en koppelen ze met een voorwaardelijke rand. Als een tool wordt aangeroepen, wordt de flow naar de tools geleid; anders worden de resultaten teruggestuurd naar de gebruiker.
Laten we nu onze agent testen:
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()
Resultaten:
In de eerste stap kiest de agent ervoor om de movie-list tool te gebruiken met de juiste descriptionparameter. Het is onduidelijk waarom hij een kvalue van 5 selecteert, maar hij lijkt dat getal te prefereren. De tool retourneert de vijf meest relevante films op basis van de plot, en de LLM vat ze aan het einde eenvoudigweg samen voor de gebruiker.
Als we ChatGPT vragen waarom het de k-waarde van 5 prefereert, krijgen we het volgende antwoord.
Laten we nu een iets complexere vraag stellen waarvoor metadatafiltering nodig is:
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()
Resultaten:
Deze keer werden extra argumenten gebruikt om alleen films uit de jaren 90 te filteren. Dit voorbeeld zou een typisch voorbeeld zijn van metadatafiltering met behulp van de pre-filtering-benadering. De gegenereerde Cypher-instructie beperkt de films eerst door te filteren op hun releasejaar. In het volgende deel gebruikt de Cypher-instructie tekst-embeddings en vector similarity search om films te vinden over een klein meisje dat haar held ontmoet.
Laten we proberen films te tellen op basis van verschillende voorwaarden:
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()
Resultaten:
Met een speciale tool voor tellen verschuift de complexiteit van de LLM naar de tool, waardoor de LLM alleen verantwoordelijk is voor het vullen van de relevante functieparameters. Deze scheiding van taken maakt het systeem efficiënter en robuuster en vermindert de complexiteit van de LLM-invoer.
Omdat de agent meerdere tools sequentieel of parallel kan aanroepen, testen we dit met iets nog complexer:
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()
Resultaten
Zoals gezegd kan de agent meerdere tools aanroepen om alle benodigde informatie te verzamelen om de vraag te beantwoorden. In dit voorbeeld begint het met het opsommen van de hoogst gewaardeerde films om te identificeren wanneer de best gewaardeerde film is uitgebracht. Zodra het die gegevens heeft, roept het de movie count tool aan om het aantal films te verzamelen dat na het opgegeven jaar is uitgebracht, met behulp van een groeperingssleutel zoals gedefinieerd in de vraag.
Hoewel tekst-embeddings uitstekend zijn voor het doorzoeken van ongestructureerde data, schieten ze tekort als het gaat om gestructureerde bewerkingen zoals filteren , sorteren en aggregeren . Deze taken vereisen tools die zijn ontworpen voor gestructureerde data, die de precisie en flexibiliteit bieden die nodig zijn om deze bewerkingen te verwerken. De belangrijkste conclusie is dat het uitbreiden van de set tools in uw systeem u in staat stelt om een breder scala aan gebruikersvragen te behandelen, waardoor uw applicaties robuuster en veelzijdiger worden. Het combineren van gestructureerde data-benaderingen en ongestructureerde tekstzoektechnieken kan nauwkeurigere en relevantere antwoorden opleveren, wat uiteindelijk de gebruikerservaring in RAG-applicaties verbetert.
Zoals altijd is de code beschikbaar op GitHub .
Om meer te weten te komen over dit onderwerp, kunt u op 7 november naar NODES 2024 komen, onze gratis virtuele ontwikkelaarsconferentie over intelligente apps, knowledge graphs en AI. Meld u nu aan!