Svi vole modele za ugrađivanje teksta i to s dobrim razlogom: izvrsni su u kodiranju nestrukturiranog teksta, što olakšava otkrivanje semantički sličnog sadržaja. Nije iznenađenje da oni čine okosnicu većine RAG aplikacija, posebno s trenutačnim naglaskom na kodiranje i dohvaćanje relevantnih informacija iz dokumenata i drugih tekstualnih izvora. Međutim, postoje jasni primjeri pitanja koja bi se mogla postaviti kada pristup ugrađivanju teksta RAG aplikacijama ne uspijeva i daje netočne informacije.
Kao što je spomenuto, umetanje teksta izvrsno je za kodiranje nestrukturiranog teksta. S druge strane, nisu tako dobri u radu sa strukturiranim informacijama i operacijama kao što su filtriranje , sortiranje ili združivanje . Zamislite jednostavno pitanje poput:
Koji je film s najvećom ocjenom objavljen u 2024.?
Da bismo odgovorili na ovo pitanje, prvo moramo filtrirati prema godini izdavanja, nakon čega slijedi sortiranje prema ocjeni. Ispitat ćemo kako djeluje naivni pristup s ugrađivanjem teksta i zatim pokazati kako se nositi s takvim pitanjima. Ovaj post na blogu pokazuje da kada se bavite operacijama strukturiranih podataka kao što su filtriranje, sortiranje ili združivanje, trebate koristiti druge alate koji pružaju strukturu kao što su grafikoni znanja.
Kod je dostupan na GitHubu .
Za ovaj post na blogu upotrijebit ćemo projekt preporuka u Neo4j Sandboxu . Projekt preporuka koristi skup podataka MovieLens koji sadrži filmove, glumce, ocjene i više informacija.
Sljedeći kod će instancirati LangChain omotač za povezivanje s Neo4j bazom podataka:
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)
Dodatno, trebat će vam OpenAI API ključ koji prosljeđujete u sljedećem kodu:
os.environ["OPENAI_API_KEY"] = getpass.getpass("OpenAI API Key:")
Baza podataka sadrži 10 000 filmova, ali tekstualni umetci još nisu pohranjeni. Kako bismo izbjegli izračun ugrađivanja za sve njih, označit ćemo 1000 najbolje ocijenjenih filmova sekundarnom oznakom pod nazivom Cilj :
graph.query(""" MATCH (m:Movie) WHERE m.imdbRating IS NOT NULL WITH m ORDER BY m.imdbRating DESC LIMIT 1000 SET m:Target """)
Važno je odlučiti što ćete ugraditi. Budući da ćemo demonstrirati filtriranje po godini i sortiranje po ocjeni, ne bi bilo pošteno isključiti te detalje iz ugrađenog teksta. Zato sam odlučio zabilježiti godinu izlaska, ocjenu, naslov i opis svakog filma.
Evo primjera teksta koji ćemo ugraditi za film Vuk s Wall Streeta :
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
Mogli biste reći da ovo nije dobar pristup ugrađivanju strukturiranih podataka, a ja se ne bih raspravljao jer ne znam koji je najbolji pristup. Možda bismo ih umjesto stavki ključ-vrijednost trebali pretvoriti u tekst ili tako nešto. Javite mi ako imate neke ideje o tome što bi moglo funkcionirati bolje.
Objekt Neo4j Vector u LangChainu ima prikladnu metodu from_existing_graph gdje možete odabrati koja svojstva teksta treba kodirati:
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", )
U ovom primjeru koristimo OpenAI-jev text-embedding-3-small model za generiranje ugrađivanja. Inicijaliziramo objekt Neo4jVector pomoću metode from_existing_graph. Parametar node_label filtrira čvorove koji se kodiraju, posebno one s oznakom Target . Parametar text_node_properties definira svojstva čvora koja će biti ugrađena, uključujući plot , title , year i imdbRating . Konačno, svojstvo embedding_node_property definira svojstvo gdje će se generirana ugrađivanja pohraniti, označeno kao ugrađivanje .
Za početak pokušajmo pronaći film na temelju njegove radnje ili opisa:
pretty_print( neo4j_vector.similarity_search( "What is a movie where a little boy meets his hero?" ) )
Rezultati:
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
Rezultati se općenito čine prilično solidnima. Dosljedno je u pitanju mali dječak, iako nisam siguran sreće li uvijek svog heroja. S druge strane, skup podataka uključuje samo 1000 filmova, tako da su opcije donekle ograničene.
Pokušajmo sada s upitom koji zahtijeva osnovno filtriranje:
pretty_print( neo4j_vector.similarity_search( "Which movies are from year 2016?" ) )
Rezultati:
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
Smiješno je, ali niti jedan film iz 2016. nije odabran. Možda bismo mogli dobiti bolje rezultate s drugačijom pripremom teksta za kodiranje. Međutim, ugrađivanje teksta ovdje nije primjenjivo budući da imamo posla s jednostavnom operacijom strukturiranih podataka gdje trebamo filtrirati dokumente ili, u ovom primjeru, filmove na temelju svojstva metapodataka. Filtriranje metapodataka je dobro uspostavljena tehnika koja se često koristi za povećanje točnosti RAG sustava.
Sljedeći upit koji ćemo isprobati zahtijeva malo sortiranja:
pretty_print( neo4j_vector.similarity_search("Which movie has the highest imdb score?") )
Rezultati:
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
Ako ste upoznati s ocjenama na IMDb-u, znate da postoji mnogo filmova s ocjenom iznad 8,3. Naslov s najvišom ocjenom u našoj bazi zapravo je serija — Band of Brothers — s impresivnom ocjenom 9,6. Još jednom, umetanje teksta ima loše rezultate kada je u pitanju sortiranje rezultata.
Procijenimo i pitanje koje zahtijeva neku vrstu zbrajanja:
pretty_print(neo4j_vector.similarity_search("How many movies are there?"))
Rezultati:
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
Rezultati ovdje definitivno nisu od pomoći jer nam se vraćaju četiri nasumična filma. Gotovo je nemoguće iz ova četiri slučajna filma izvesti zaključak da postoji ukupno 1000 filmova koje smo označili i ugradili za ovaj primjer.
Dakle, koje je rješenje? Jednostavno je: pitanja koja uključuju strukturirane operacije poput filtriranja, sortiranja i združivanja trebaju alate dizajnirane za rad sa strukturiranim podacima.
Trenutno se čini da većina ljudi razmišlja o pristupu text2query, gdje LLM generira upit baze podataka za interakciju s bazom podataka na temelju ponuđenog pitanja i sheme. Za Neo4j ovo je text2cypher, ali postoji i text2sql za SQL baze podataka. Međutim, u praksi se pokazalo da nije pouzdan i dovoljno robustan za proizvodnu upotrebu.
Evaluacija generiranja Cypher iskaza. Preuzeto iz mog posta na blogu o Cypher evaluaciji .
Možete koristiti tehnike poput lanca misli, nekoliko primjera ili finog podešavanja, ali postizanje visoke točnosti ostaje gotovo nemoguće u ovoj fazi. Pristup text2query dobro funkcionira za jednostavna pitanja o jednostavnim shemama baze podataka, ali to nije stvarnost proizvodnih okruženja. Kako bismo to riješili, mi pomičemo složenost generiranja upita baze podataka dalje od LLM-a i tretiramo ga kao problem koda gdje generiramo upite baze podataka deterministički na temelju inputa funkcija. Prednost je znatno poboljšana robusnost, iako to dolazi po cijenu smanjene fleksibilnosti. Bolje je suziti opseg RAG aplikacije i točno odgovoriti na ta pitanja, umjesto da pokušavate odgovoriti na sve, ali to učiniti netočno.
Budući da generiramo upite baze podataka - u ovom slučaju, Cypher izjave - na temelju inputa funkcija, možemo iskoristiti mogućnosti alata LLM-a. U ovom procesu, LLM popunjava relevantne parametre na temelju korisničkog unosa, dok funkcija upravlja dohvaćanjem potrebnih informacija. Za ovu demonstraciju prvo ćemo implementirati dva alata: jedan za brojanje filmova i drugi za njihovo ispisivanje, a zatim ćemo stvoriti LLM agenta pomoću LangGrapha.
Počinjemo s implementacijom alata za brojanje filmova na temelju unaprijed definiranih filtara. Prvo, moramo definirati koji su to filtri i opisati LLM-u kada i kako ih koristiti:
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 nudi nekoliko načina za definiranje ulaza funkcija, ali ja više volim Pydantic pristup. U ovom primjeru imamo tri dostupna filtra za pročišćavanje rezultata filmova: min_year, max_year i min_rating. Ti se filtri temelje na strukturiranim podacima i izborni su jer korisnik može odlučiti uključiti bilo koji, sve ili nijedan od njih. Osim toga, uveli smo grouping_key input koji govori funkciji treba li grupirati broj prema određenom svojstvu. U ovom slučaju, jedino podržano grupiranje je prema godini, kako je definirano u popisu.
Sada definirajmo stvarnu funkciju:
@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)
Funkcija movie_count generira Cypher upit za brojanje filmova na temelju izbornih filtara i ključa za grupiranje. Započinje definiranjem popisa filtara s odgovarajućim vrijednostima danim kao argumenti. Filtri se koriste za dinamičku izgradnju WHERE klauzule, koja je odgovorna za primjenu navedenih uvjeta filtriranja u Cypher izjavi, uključujući samo one uvjete gdje vrijednosti nisu None.
Zatim se konstruira RETURN klauzula Cypher upita, bilo grupiranjem prema danom grouping_key ili jednostavnim brojanjem ukupnog broja filmova. Na kraju, funkcija izvršava upit i vraća rezultate.
Funkcija se po potrebi može proširiti s više argumenata i više uključene logike, ali važno je osigurati da ostane jasna kako bi je LLM mogao ispravno i točno pozvati.
Opet, moramo početi definiranjem argumenata funkcije:
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")
Zadržavamo ista tri filtra kao u funkciji brojanja filmova, ali dodajemo argument opisa. Ovaj nam argument omogućuje pretraživanje i popis filmova na temelju njihove radnje pomoću pretraživanja sličnosti vektora. Samo zato što koristimo strukturirane alate i filtre ne znači da ne možemo uključiti metode ugrađivanja teksta i vektorskog pretraživanja. Budući da većinu vremena ne želimo vraćati sve filmove, uključujemo dodatni k ulaz sa zadanom vrijednošću. Osim toga, za popis želimo razvrstati filmove kako bismo vratili samo one najrelevantnije. U ovom slučaju, možemo ih sortirati prema ocjeni ili godini izdavanja.
Implementirajmo funkciju:
@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
Ova funkcija dohvaća popis filmova na temelju više izbornih filtara: opis, raspon godina, minimalna ocjena i postavke sortiranja. Ako je dan samo opis bez drugih filtara, izvodi se pretraživanje sličnosti indeksa vektora kako bi se pronašli relevantni filmovi. Kada se primijene dodatni filtri, funkcija konstruira Cypher upit za podudaranje filmova na temelju navedenih kriterija, kao što su godina izdavanja i IMDb ocjena, kombinirajući ih s neobaveznom sličnošću temeljenom na opisu. Rezultati se zatim razvrstavaju prema ocjeni sličnosti, ocjeni IMDb-a ili godini i ograničeni su na k filmova.
Implementirat ćemo jednostavnog ReAct agenta koristeći LangGraph.
Agent se sastoji od LLM-a i alata. Dok komuniciramo s agentom, prvo ćemo nazvati LLM da odlučimo trebamo li koristiti alate. Zatim ćemo pokrenuti petlju:
Implementacija koda je najjednostavnija. Prvo povezujemo alate s LLM-om i definiramo pomoćni korak:
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"])]}
Zatim definiramo tok LangGraph-a:
# 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()
Definiramo dva čvora u LangGraphu i povezujemo ih uvjetnim rubom. Ako se pozove alat, tok se usmjerava na alate; inače se rezultati šalju natrag korisniku.
Testirajmo sada našeg agenta:
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()
Rezultati:
U prvom koraku, agent odabire korištenje alata za popis filmova s odgovarajućim parametrom opisa. Nejasno je zašto odabire kvrijednost 5, ali čini se da daje prednost tom broju. Alat vraća pet najrelevantnijih filmova na temelju radnje, a LLM ih na kraju jednostavno sažima za korisnika.
Ako pitamo ChatGPT zašto voli k vrijednost 5, dobivamo sljedeći odgovor.
Zatim, postavimo nešto složenije pitanje koje zahtijeva filtriranje metapodataka:
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()
Rezultati:
Ovaj put dodatnim argumentima filtrirani su samo filmovi iz devedesetih. Ovaj bi primjer bio tipičan primjer filtriranja metapodataka korištenjem pristupa prethodnog filtriranja. Generirana Cypher izjava najprije sužava izbor filmova filtriranjem prema godini izlaska. U sljedećem dijelu Cypherova izjava koristi umetanje teksta i pretraživanje sličnosti vektora kako bi pronašla filmove o susretu djevojčice sa svojim junakom.
Pokušajmo prebrojati filmove na temelju različitih uvjeta:
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()
Rezultati:
S namjenskim alatom za brojanje, složenost se pomiče s LLM-a na alat, ostavljajući LLM odgovornim samo za popunjavanje relevantnih parametara funkcije. Ovo odvajanje zadataka čini sustav učinkovitijim i robusnijim te smanjuje složenost LLM unosa.
Budući da agent može pozvati više alata uzastopno ili paralelno, testirajmo ga s nečim još složenijim:
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()
Rezultati
Kao što je spomenuto, agent može pozvati više alata za prikupljanje svih potrebnih informacija za odgovor na pitanje. U ovom primjeru započinje popisom filmova s najvišom ocjenom kako bi se utvrdilo kada je film s najvećom ocjenom objavljen. Nakon što dobije te podatke, poziva alat za brojanje filmova kako bi prikupio broj filmova objavljenih nakon navedene godine, koristeći ključ grupiranja kako je definirano u pitanju.
Dok su umetanja teksta izvrsna za pretraživanje nestrukturiranih podataka, nisu uspješna kada je riječ o strukturiranim operacijama poput filtriranja , sortiranja i združivanja . Ovi zadaci zahtijevaju alate dizajnirane za strukturirane podatke, koji nude preciznost i fleksibilnost potrebnu za rukovanje ovim operacijama. Ključni zaključak je da vam proširenje skupa alata u vašem sustavu omogućuje rješavanje šireg raspona korisničkih upita, čineći vaše aplikacije robusnijim i svestranijim. Kombinacija pristupa strukturiranim podacima i tehnika pretraživanja nestrukturiranog teksta može pružiti točnije i relevantnije odgovore, u konačnici poboljšavajući korisničko iskustvo u RAG aplikacijama.
Kao i uvijek, kod je dostupan na GitHubu .
Da biste saznali više o ovoj temi, pridružite nam se na NODES 2024 7. studenog, našoj besplatnoj virtualnoj konferenciji za razvojne programere o inteligentnim aplikacijama, grafikonima znanja i umjetnoj inteligenciji. Registrirajte se sada!