import requests
import re
# Gutenberg pretends everything is English, which
# means "Hát gyöngyömadta" gets really mangled
= requests.get("https://www.gutenberg.org/files/38852/38852-0.txt")
response = response.content.decode("utf-8")
text
# Cleaning up newlines
= text.replace("\r", "")
text = re.sub("\n(?=[^\n])", "", text)
text
# Saving the book
with open('book.txt', 'w') as f:
f.write(text)
Hi, I’m Soma! You can find me on email at jonathan.soma@gmail.com, on Twitter at @dangerscarf, or maybe even on this newsletter I’ve never sent.
Multi-language document Q&A with LangChain and GPT-3.5-turbo
Using GPT, LangChain, and vector stores to ask questions of documents in languages you don’t speak
I don’t speak Hungarian, but I demand to have my questions about Hungarian folktales answered! Let’s use GPT to do this for us.
This might be useful if you’re doing a cross-border investigation, are interested in academic papers outside of your native tongue, or are just interested in learning how LangChain and document Q&A works.
In this tutorial, we’ll look at:
- Why making ChatGPT read an whole book is impossible
- How to provide GPT (and other AI tools) with context to provide answers
If you don’t want to read all of this nonsense you can go directly to the LangChain source and check out Question Answering or Question Answering with Sources. This just adds a bit of multi-language sparkle on top!
Our source material
We’ll begin by downloading the source material. If your original documents are in PDF form or anything like that, you’ll want to convert them to text first.
Our reference is a book of folktales called Eredeti népmesék by László Arany on Project Gutenberg. It’s just a basic text file so we can download it easily.
And the text is indeed in Hungarian:
print(text[3000:4500])
be, de az is épen úgy járt, mint abátyja, ez is kiszaladt a szobából.
Harmadik nap a legfiatalabb királyfin volt a sor; a bátyjai be se’akarták ereszteni, hogy ha ők ki nem tudták venni az apjokból, biz’ e’se’ sokra megy, de a királyfi nem tágitott, hanem bement. Mikor elmondtahogy m’ért jött, ehez is hozzá vágta az öreg király a nagy kést, de eznem ugrott félre, hanem megállt mint a peczek, kicsibe is mult, hogybele nem ment a kés, a sipkáját kicsapta a fejéből, úgy állt meg azajtóban. De a királyfi még ettől se’ ijedt meg, kihúzta a kést azajtóból, odavitte az apjának. ,,Itt van a kés felséges király atyám, hamegakar ölni, öljön meg, de elébb mondja meg mitől gyógyulna meg aszeme, hogy a bátyáim megszerezhessék.’’
Nagyon megilletődött ezen a beszéden a király, nemhogy megölte volnaezért a fiát, hanem össze-vissza ölelte, csókolta. No kedves fiam –mondja neki – nem hiában voltál te egész életemben nekem legkedvesebbfiam, de látom most is te szántad el magad legjobban a halálra az énmeggyógyulásomért, (mert a kést is csak azért hajitottam utánatok, hogymeglássam melyikötök szállna értem szembe a halállal), most hát nekedmegmondom, hogy mitől gyógyulna meg a szemem. Hát kedves fiam,messze-messze a Verestengeren is túl, a hármashegyen is túl lakik egykirály, annak van egy aranytollu madara, ha én annak a madárnak csakegyszer hallhatnám meg a gyönyörű éneklését, mindjárt meggyógyulnéktőle; de nincs annyi kincs, hogy od’adná érte az a király, mert annyiannak az országában az aran
Luckily for us, GPT speaks Hungarian! So if we tell it to read the book, it’ll be able to answer all of our English-language questions without a problem. But there’s one problem: the book is not a short tiny paragraph.
Life would be nice if we could just feed it directly to ChatGPT and start asking questions, but you can’t make ChatGPT read a whole book. After it gets partway through the book ChatGPT starts forgetting the earlier pieces!
There are a few tricks to get around this when asking a question. We’ll work with one of the simplest for now:
- Split our original text up into smaller passages
- Find the passages most relevant to our question
- Send those passages to GPT along with our question
Newer LLMs can deal with a lot more tokens at a time – GPT-4 has both an 8k and 32k version – but hey, I don’t have an invite and we work with what we’ve got.
Part 1: Split our original text up into passages
To do pretty much everything from here on out we’re relying on LangChain, a really fun library that allows you to bundle together different common tasks when working with language models. It’s best trick is chaining together AI at different steps in the process, but for the moment we’re just using its text search abilities.
We’re going to split our text up into 1000-character chunks, which should be around 150-200 words apiece. I’m also going to add a little overlap.
from langchain.document_loaders import TextLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
= TextLoader('book.txt')
loader = loader.load()
documents
= RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
text_splitter = text_splitter.split_documents(documents) docs
Technically speaking I’m using a RecursiveCharacterTextSplitter
, which tries to keep paragraphs and sentences and all of those things together, so it might go above or below 1000. But it should generally hit the mark.
len(docs)
440
Overall this gave us just over 400 documents. Let’s pick one at random to check out, just to make sure things went okay.
109] docs[
Document(page_content='Mikor aztán eljött a lakodalom napja, felöltözött, de olyan ruhába, hogyTündérországban se igen látni párját sátoros ünnepkor se, csak elfogta acselédje szemefényét. Mire a királyi palotához ért, már ott ugyancsakszólott a muzsika, úgy tánczoltak, majd leszakadt a ház, még a süketnekis bokájába ment a szép muzsika.', lookup_str='', metadata={'source': 'book.txt'}, lookup_index=0)
It’s a little short, but it’s definitely part of the folktales. According to Google Translate:
When the day of the wedding came, she dressed up, but in such a dress that one would not see her partner in a fairyland even during a tent festival, she only caught the eye of her mistress. By the time he got to the royal palace, the music was already playing there too, they were dancing like that, and then the house was torn apart, the beautiful music even went to the deaf man’s ankles
Sounds like a pretty fun party!
Part 2: Find the passages most relevant to our question
Understanding text embeddings and semantic search
If we’re asking questions about a wedding, we can’t just look for the text wedding – our documents are in Hungarian, so that’s lakodalom (I think). Instead, we’re going to use someting called embeddings.
Embeddings take a word, sentence, or snippet of text and turn it into a string of numbers. Take the sentences below as an example: I’ve scored each one of them as to how much they’re about shopping, home, and animals.
sentence | shopping | home | animals | result |
---|---|---|---|---|
You should buy a house | 0.9 | 0.8 | 0 | (0.9, 0.8, 0.0) |
The cat is in the house | 0 | 1 | 0.8 | (0.0, 1.0, 0.8) |
The dog bought a pet mouse | 1 | 0.2 | 1 | (1.0, 0.2, 1.0) |
Let’s say we have a fourth sentence – the dog is at home. I’ve decided it scores (0.0 1.0 0.9)
since it’s about home and animals, but not shipping. How can we find a similar text?
The cat is in the house is the best match from our original list, even though it doesn’t have any words that match. But if we ignore the words and look at the scores, it’s clearly the best match! That’s more or less the basic idea behind text embeddings and semantic search.
Instead of reasonable categories like mine, actual embeddings are something like 384 or 512 different dimensions your text is scored on. And unlike “shopping” or “animal” above, the dimensions aren’t anything you can understand. They’re generated by computers that have read a lot lot lot of the internet, so we just have to trust them!
You might want to read my introduction to word embeddings and conceptual document similarity for more details.
Creating and searching our embeddings database
There are many, many embeddings out there, and they each score text differently. We need one that supports English (for our queries) and Hungarian (for the dataset): while not all of them support multiple languages, it isn’t hard to find some that do!
We’re going to pick paraphrase-multilingual-MiniLM-L12-v2
since it supports a delightful 50 languages. That way we can ask questions in French or Italian, or maybe add some Japanese folklore to the mix later on.
from langchain.embeddings import HuggingFaceEmbeddings
= HuggingFaceEmbeddings(model_name='paraphrase-multilingual-MiniLM-L12-v2') embeddings
These multilingual embeddings have read enough sentences across the all-languages-speaking internet to somehow know things like that cat and lion and Katze and tygrys and 狮 are all vaguely feline. At this point don’t need to know how it works, just that it gets the job done!
In order to find the most relevant pieces of text, we’ll also need something that can store and search embeddings. That way when we want to find anything about weddings it won’t have a problem finding lakodalom.
We’re going to use Chroma for no real reason, just because it has a convenient LangChain extension. It sets the whole thing up in one line of code - we just need to give it our documents and the embeddings model.
# You'll probably need to install chromadb
# !pip install chromadb
from langchain.vectorstores import Chroma
= Chroma.from_documents(docs, embeddings) db
Running Chroma using direct local API.
Using DuckDB in-memory for database. Data will be transient.
Now that everything is stored in our searchable Chroma database, we can look for passages about weddings at a festival.
# k=1 because we only want one result
"weddings at a festival with loud music", k=1) db.similarity_search(
[Document(page_content='Eltelt az egy hónap, elérkezett az esküvő napja, ott volt a sok vendég,köztök a boltos is, csak a vőlegényt meg a menyasszonyt nem lehetettlátni. Bekövetkezett az ebéd ideje is, mindnyájan vígan ültek le azasztalhoz, elkezdtek enni. Az volt a szokás a gróf házánál, hogy mindenembernek egy kis külön tálban vitték az ételt; a boltos amint a magatáljából szedett levest, hát csak alig tudta megenni, olyan sótalanvolt, nézett körül só után, de nem volt az egész asztalon; a másodikétel még sótalanabb volt, a harmadik meg már olyan volt, hogy hozzá se’tudott nyúlni. Kérdezték tőle hogy mért nem eszik? tán valami baja vanaz ételnek? amint ott vallatták, eszébe jutott a lyánya, hogy az nekiazt mondta, hogy úgy szereti, mint a sót, elkezdett sírni; kérdeztékaztán tőle, hogy mért sír, akkor elbeszélt mindent, hogy volt neki egylyánya, az egyszer neki azt mondta, hogy úgy szereti mint a sót, őmegharagudott érte, elkergette a házától, lám most látja, hogy milyenigazságtalan volt iránta, milyen jó a só, ,,de hej ha még egyszervisszahozná az isten hozzám, majd meg is becsülném, első lenne aházamnál; meg is bántam én azt már sokszor, de már akkor késő volt.’’', lookup_str='', metadata={'source': 'book.txt'}, lookup_index=0)]
It’s a match! In the next step we’ll use this process to find passages related to our question, then we’ll then pass those along to GPT as context for our questions.
Part 3: Send the matches to GPT along with our question
This is the part where LangChain really shines. We just say “hey, go get the relevant passages from our database, then go talk to GPT for us!”
First, we’ll fire up our connection to GPT (you’ll need to provide your own API key!). In this case we’re specifically using GPT-3.5-turbo, because we aren’t cool enough to have GPT-4 yet.
from langchain.llms import OpenAI
# Connect to GPT-3.5 turbo
= "sk-..."
openai_api_key
# Use temperature=0 to get the same results every time
= OpenAI(
llm ="gpt-3.5-turbo",
model_name=0,
temperature=openai_api_key) openai_api_key
Second, we’ll put together our vector-based Q&A. This is a custom LangChain tool that takes our original question, finds relevant passages, and packages it all up to send over to the large language model (in this case, GPT).
# Vector-database-based Q&A
= VectorDBQA.from_chain_type(
qa =llm,
llm="stuff",
chain_type=db
vectorstore )
Let’s see it in action!
I’m going to ask some questions about Zsuzska, who according to some passages apparently stole some of the devil’s belongings (I don’t really know anything about her, this is just from a couple random passages I translated for myself!).
= "What did Zsuzska steal from the devil?"
query qa.run(query)
'The tenger-ütő pálczát (sea-beating stick).'
= "Why did Zsuzska steal from the devil?"
query qa.run(query)
"Zsuzska was forced to steal from the devil by the king, who threatened her with death if she didn't."
A previous time I ran this query GPT explained that the king’s aunts were jealous of Zsuzska, and they were the ones who convinced the king to make the demand of her. Since it’s been lost to the sands of time, maybe GPT can provide some more details.
= "Why were the king's aunts jealous of Zsuzska?"
query qa.run(query)
"The king's aunts were jealous of Zsuzskát because the king had grown to love her and they wanted to undermine her by claiming that she could not steal the devil's golden cabbage head."
That’s a good amount of information about Zsuzska! Let’s try another character, Janko.
= "Who did Janko marry?"
query qa.run(query)
'Janko married a beautiful princess.'
= "How did Janko meet the princess?"
query qa.run(query)
"The context does not provide information on a character named Janko meeting the king's daughter."
I know for a fact that Janko met the princess because he stole her clothes while she was swimming in a lake, but I guess the appropriate context didn’t get sent to GPT. It actually used to get the question right before I changed the embeddings! In the next section we’ll see how to provide more context and hopefully get better answers.
There’s also a big long story about a red or bloody row that had to do with a character’s mother coming back to protect him. Let’s see what we can learn about it!
= "Who was the bloody cow?"
query qa.run(query)
'The bloody cow was a cow that Ferkó rode away on after throwing the lasso at it.'
= "Why was Ferko's mother disguised as a cow?"
query qa.run(query)
"Ferko's mother was not disguised as a cow, but rather the red cow was actually Ferko's mother, the first queen."
Improving our answers from GPT
When we asked what was stolen from the devil, we were told “The tenger-ütő pálczát (sea-beating stick).” I know for a fact more things were stolen than that!
If we provide better context, we can hopefully get better answers. Usually “better context” means “more context,” so we have two major options:
- Increase the size of our window/include more overlap so passages are longer
- Provide more passages to GPT as context when asking for an answer
Since I haven’t seen the second one show up too many places, let’s do that one. We’ll increase the number of passages to provide as context by adding k=8
(by default it sends 4 passages).
= VectorDBQA.from_chain_type(
qa =llm,
llm="stuff",
chain_type=db,
vectorstore=8
k )
At this point we have to be careful of two things: money and token limits.
- Money: Larger requests that include more tokens (words, characters) cost more.
- Token limits: We have around 3,000 words to work with for each GPT-3.5 request. If each chunk is up to 250 words long, this gets us up to 2,000 words before we add in our question. We should be safe!
But we want good answers, right??? Let’s see if it works:
= "What did Zsuzská steal from the devil?"
query qa.run(query)
"Zsuzska stole the devil's tenger-ütő pálczája (sea-beating stick), tenger-lépő czipője (sea-stepping shoes), and arany kis gyermek (golden baby) in an arany bölcső (golden cradle). She also previously stole the devil's tenger-ütőpálczát (sea-beating stick) and arany fej káposztát (golden head cabbage)."
Perfect! That gold cabbage sounds great, and it’s almost time for lunch, so let’s wrap up with one more thing.
Seeing the context
If you’re having trouble getting good answers to your questions, it might be because the context you’re providing isn’t very good.
I was actually having not-so-great answers earlier, but when I changed from the distiluse-base-multilingual-cased-v2
embeddings to the paraphrase-multilingual-MiniLM-L12-v2
embeddings all the context passages became so much more relavant! I honestly don’t know the difference between them, just that one provided more useful snippets to GPT.
To help debug similar situations, let’s look at how to inspect the context that is being provided to GPT with each search!
Method one: Context from the question
We can plug right into our VectorDBQA
to see what context is being sent to GPT. To do this, just include the return_source_documents=True
parameter.
= VectorDBQA.from_chain_type(
qa =llm,
llm="stuff",
chain_type=db,
vectorstore=True
return_source_documents )
= "What did Zsuzská steal from the devil?"
query = qa({"query": query}) result
Now the response has two pieces instead of just being plain text:
result
is the actual text responsesource_documents
are the passages provided as context
"result"] result[
'Zsuzská stole the tenger-ütő pálczát (sea-beater stick) from the devil.'
"source_documents"] result[
[Document(page_content='Hiába tagadta szegény Zsuzska, nem használt semmit, elindult hát nagyszomorúan. Épen éjfél volt, mikor az ördög házához ért, aludt az ördögis, a felesége is. Zsuzska csendesen belopódzott, ellopta a tenger-ütőpálczát, avval bekiáltott az ablakon.\n– Hej ördög, viszem ám már a tenger-ütő pálczádat is.\n– Hej kutya Zsuzska, megöletted három szép lyányomat, elloptad atenger-lépő czipőmet, most viszed a tenger-ütő pálczámat, de majdmeglakolsz te ezért.\nUtána is szaladt, de megint csak a tengerparton tudott közel jutnihozzá, ott meg Zsuzska megütötte a tengert a tenger-ütő pálczával,kétfelé vált előtte, utána meg összecsapódott, megint nem foghatta megaz ördög. Zsuzska ment egyenesen a királyhoz.\n– No felséges király, elhoztam már a tengerütő pálczát is.', lookup_str='', metadata={'source': 'book.txt'}, lookup_index=0),
Document(page_content='De Zsuzska nem adta;,,Tán bolond vagyok, hogy visszaadjam, mikor kivülvagyok már vele az udvaron?!’’ Az ördög kergette egy darabig, de sehogyse tudta utolérni, utoljára is visszafordult, Zsuzska pedig mentegyenesen a király elibe, od’adta neki az arany fej káposztát.\n– No felséges király elhoztam már ezt is!\nA két nénjét Zsuzskának, majd hogy meg nem ütötte a guta, mikormegtudták, hogy Zsuzskának most se’ lett semmi baja, másnap megintbementek a királyhoz.\n– Jaj felséges király van még annak az ördögnek egy arany kis gyermekeis arany bölcsőben, Zsuzska azt beszéli fűnek-fának, hogy ő azt is eltudná lopni.\nMegint behivatta a király Zsuzskát.\n– Fiam Zsuzska, azt hallottam, hogy van annak az ördögnek egy arany kisgyermeke is, arany bölcsőben, te azt is el tudod lopni, azt beszélted,azért ha az éjjel el nem lopod, halálnak halálával halsz meg.', lookup_str='', metadata={'source': 'book.txt'}, lookup_index=0),
Document(page_content='– No felséges király, elhoztam már a tengerütő pálczát is.\nA király még jobban megszerette Zsuzskát, hogy olyan életre való, de anénjei még jobban irigykedtek rá, csakhamar megint avval árulták be,hogy van annak az ördögnek egy arany fej káposztája is, Zsuzska azt isel tudná lopni, azt mondta. A király megint ráparancsolt Zsuzskára erősparancsolattal, hogy ha a káposztát el nem lopja, halálnak halálával halmeg.\nElindult hát szegény Zsuzska megint, el is ért szerencsésen épen éjfélreaz ördög kertjibe, levágta az arany fej káposztát, avval bekiáltott azablakon.\n– Hej ördög, viszem ám már az arany fej káposztádat is.\n– Hej kutya Zsuzska, megöletted három szép lyányomat, elloptad atenger-lépő czipőmet, elloptad a tenger-ütő pálczámat, most viszed azarany fej káposztámat, csak ezt az egyet add vissza, soha szemedre sevetem.', lookup_str='', metadata={'source': 'book.txt'}, lookup_index=0),
Document(page_content='Zsuzska csak nevette, de majd hogy sírás nem lett a nevetésből, mert azördög utána iramodott, Zsuzska meg nem igen tudott a nehéz bölcsővelszaladni, úgy annyira, hogy mire a tengerparthoz értek, tiz lépés nemsok, de annyi se volt köztök, hanem ott aztán Zsuzska felrántotta atenger-lépő czipőt, úgy átlépte vele a tengert, mint ha ott se lettvolna, avval mént egyenesen a király elibe, od’adta neki az arany kisgyermeket.\nA király a mint meglátta, csak egy szikrába mult, hogy össze-vissza nemcsókolta Zsuzskát, de az is csak egy cseppbe mult ám, hogy a két nénjemeg nem pukkadt mérgibe, mikor meghallotta, hogy Zsuzska megintvisszakerült. Fúrta az oldalukat rettenetesen az irigység, mert látták,hogy a király napról-napra jobban szereti Zsuzskát. Bementek hát akirályhoz megint, azt hazudták neki hogy Zsuzska azt mondta, hogy vanannak az ördögnek egy zsák arany diója, ő azt is el tudná lopni.\nMaga elibe parancsolta a király megint Zsuzskát:', lookup_str='', metadata={'source': 'book.txt'}, lookup_index=0)]
Method two: Just ask your database
If you already know what GPT is going to say in response and you’re debugging a specific query, you can just ask your database what the relevant snippets are! That way you avoid the costs of actually talking to the API.
"What did Zsuzská steal from the devil?", k=2) db.similarity_search(
[Document(page_content='Hiába tagadta szegény Zsuzska, nem használt semmit, elindult hát nagyszomorúan. Épen éjfél volt, mikor az ördög házához ért, aludt az ördögis, a felesége is. Zsuzska csendesen belopódzott, ellopta a tenger-ütőpálczát, avval bekiáltott az ablakon.\n– Hej ördög, viszem ám már a tenger-ütő pálczádat is.\n– Hej kutya Zsuzska, megöletted három szép lyányomat, elloptad atenger-lépő czipőmet, most viszed a tenger-ütő pálczámat, de majdmeglakolsz te ezért.\nUtána is szaladt, de megint csak a tengerparton tudott közel jutnihozzá, ott meg Zsuzska megütötte a tengert a tenger-ütő pálczával,kétfelé vált előtte, utána meg összecsapódott, megint nem foghatta megaz ördög. Zsuzska ment egyenesen a királyhoz.\n– No felséges király, elhoztam már a tengerütő pálczát is.', lookup_str='', metadata={'source': 'book.txt'}, lookup_index=0),
Document(page_content='De Zsuzska nem adta;,,Tán bolond vagyok, hogy visszaadjam, mikor kivülvagyok már vele az udvaron?!’’ Az ördög kergette egy darabig, de sehogyse tudta utolérni, utoljára is visszafordult, Zsuzska pedig mentegyenesen a király elibe, od’adta neki az arany fej káposztát.\n– No felséges király elhoztam már ezt is!\nA két nénjét Zsuzskának, majd hogy meg nem ütötte a guta, mikormegtudták, hogy Zsuzskának most se’ lett semmi baja, másnap megintbementek a királyhoz.\n– Jaj felséges király van még annak az ördögnek egy arany kis gyermekeis arany bölcsőben, Zsuzska azt beszéli fűnek-fának, hogy ő azt is eltudná lopni.\nMegint behivatta a király Zsuzskát.\n– Fiam Zsuzska, azt hallottam, hogy van annak az ördögnek egy arany kisgyermeke is, arany bölcsőben, te azt is el tudod lopni, azt beszélted,azért ha az éjjel el nem lopod, halálnak halálával halsz meg.', lookup_str='', metadata={'source': 'book.txt'}, lookup_index=0)]
You can keep playing with your k
values until you get what you think is enough context.
Improvements and next steps
This is a collection of folktales, not one long story. That means asking about something like a wedding might end up mixing together all sorts of different stories! Our next step will allow us to add other books, filter stories from one another, and more techniques that can help with larger, more complex datasets.
If you’re interested in hearing when it comes out, feel free to follow me @dangerscarf or hop on my mailing list. Questions, comments, and blind cat adoption inquiries can go to jonathan.soma@gmail.com.