LangChain 怎麼玩? Retrieval 篇,來做個聊天機器人(ChatBot)吧

Posted on  Feb 8, 2024  in  LangChain , Python 程式設計 - 高階  by  Amo Chen  ‐ 9 min read

LangChain 怎麼玩?入門教學篇 中,我們學會如何透過 LangChain 與語言模型進行互動,不過很可惜的是它不像 ChatGPT 那樣記住對話內容,另外也無法輸入新的資料,訓練它像客服機器人一樣回答特定的問題。

本文將進一步突破這些限制,讓我們能夠做出像 ChatGPT 那樣的對話應用,甚至是輸入新的資料給語言模型,讓它能夠回答特定的問題!

本文環境

$ pip install langchain faiss-cpu

本文需要 Ollama 指令與 Meta 公司提供的 llama2 模型(model),ollama 指令請至 Ollama 官方頁面下載安裝,安裝完成之後即可執行指令安裝 llama2 模型,其安裝指令如下:

$ ollama run llama2

p.s. 執行上述模型需要至少 8GB 記憶體, 3.8G 硬碟空間

Retrieval (檢索)

談如何做聊天機器人之前,同樣要先了解一些概念,特別是 Retreival

首先來看 1 個問題,以下範例是 1 個可以持續與語言模型(language model)互動的範例程式,簡單來說是用 1 個 while 迴圈持續接收我們的輸入字串,並與語言模型互動:

from langchain_community.llms import Ollama
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate

class MyOutputParser(StrOutputParser):
    def parse(self, text):
        return text.replace('Assistant: ', '').strip()

output_parser = MyOutputParser()

llm = Ollama(model='llama2')
prompt = ChatPromptTemplate.from_messages([
    ('user', '{input}'),
])

chain = prompt | llm | output_parser

input_text = input('>>> ')
while input_text.lower() != 'bye':
   print(chain.invoke({'input': input_text}))
   input_text = input('>>> ')

上述範例執行之後,你可以跟語言模型進行互動,你可以試圖告訴它一些事實,例如告訴它我們的名字,接著再問它我們的名字是什麼,你就會發現它像失憶一樣或者無法回答我們的問題,例如下列對話:

>>> Hi, my name is Amo.
Hello Amo! It's nice to meet you. How can I assist you today? Is there something you need help with or would like to chat about?
>>> Do you remember my name?
I'm just an AI and do not have the ability to retain personal information or memories, including names. I'm here to help answer any questions you may have, but I cannot recall specific details about individuals unless they are public figures or widely known entities. Is there anything else I can help with?

這是因為語言模型裡面沒有關於我們的資料/私人資料,所以它無法回答這類不在模型裡的問題,跟 ChatGPT 給我們的那種自然對話體驗全然不同。

為了做到跟 ChatGPT 相似的體驗,我們必須為語言模型做一些額外的步驟,簡化後的步驟如下:

  1. 讓它能夠讀取語言模型以外的資料
  2. 將語言模型以外的資料轉換為 embedding
  3. 將第 2 步所產生的 embedding 存起來
  4. 告訴語言模型要用什麼方式檢索這些資料,最簡單方式是相似度比較

這個流程稱為 Data Connection 或者 Retrieval, 更精確的流程圖如下所示:

data-connection.jpeg 圖片引用來源 - LangChain

實際上,客服機器人也是基於這個流程做出來的,只是它的資料來源(Source)是客服相關的資料,例如常見 Q&A, 各種客服回應樣板等等。

所以,我們接下來要把 Retrieval 功能加入語言模型中。

p.s. 這種具有檢索額外提供的資料,並且產生內容的語言模型應用也稱為 RAG (Retrieval-Augmented Generation) 應用

Retrieval Chain / 檢索鏈

以下是使用 llama2 語言模型加入 Retrieval 功能的生成式 AI 範例,本範例用哈利波特的 3 種魔藥作為資料來源,讓 AI 可以基於這些資料來源回答問題,在此先不解釋程式碼的具體意思,大家可以先玩看看:

from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.chains import create_retrieval_chain
from langchain_core.prompts import ChatPromptTemplate
from langchain_community.llms import Ollama
from langchain_community.embeddings import OllamaEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document


llm = Ollama(model='llama2')
embeddings = OllamaEmbeddings()

docs = [
    Document(page_content='變身水(Polyjuice Potion)可變成其他人的樣貌。不可拿來變身成動物,也對動物產生不了效果(包括半人半動物的生物),誤用動物毛髮的話,則會變成動物的容貌。'),
    Document(page_content='吐真劑(Veritaserum)出自《火盃的考驗》,特徵為像水一樣清澈無味,使用者只要加入三滴,就能強迫飲用者說出真相。它是現存最強大的吐實魔藥,在《哈利波特》的虛構世界觀中受英國魔法部嚴格控管。J·K·羅琳表示,吐真劑最適合用在毫無戒心、易受傷害、缺乏自保技能的人身上,有些巫師能使用鎖心術等方式保護自己免受吐真劑影響。'),
    Document(page_content='福來福喜(Felix Felicix)出自《混血王子》,是一種稀有而且難以調製的金色魔藥,能夠給予飲用者好運。魔藥的效果消失之前,飲用者的所有努力都會成功。假如飲用過量,會導致頭暈、魯莽和危險的過度自信,甚至成為劇毒。'),
]

vectordb = FAISS.from_documents(docs, embeddings)
retriever = vectordb.as_retriever()

prompt = ChatPromptTemplate.from_messages([
    ('system', 'Answer the user\'s questions in Chinese, based on the context provided below:\n\n{context}'),
    ('user', 'Question: {input}'),
])
document_chain = create_stuff_documents_chain(llm, prompt)

retrieval_chain = create_retrieval_chain(retriever, document_chain)

context = []
input_text = input('>>> ')
while input_text.lower() != 'bye':
    response = retrieval_chain.invoke({
        'input': input_text,
        'context': context
    })
    print(response['answer'])
    context = response['context']
    input_text = input('>>> ')

上述範例執行之後,可以試著問問它吐真劑的用途:

>>> 吐真劑的用途是什麼?
答:吐真劑(Veritaserum)的用法是使用者只要加入三滴,就能強制說出真相。

從結果可以看到它確實從輸入的資料中擷取出問題的答案。

帥呀!老皮!

以下開始說明上述範例程式中幾個重要關鍵,首先是使用 Ollama 的 embedding 模型,由於我們使用的是 Ollama 模組載入語言模型,所以在 embedding 模型的部分也是使用 Ollama 免費的 embedding 模型(也可以使用 OpenAI 的 embedding 模型,不過需要付費):

embeddings = OllamaEmbeddings()

這個 embeddings 模型能夠將我們輸入的資料、事實、對話紀錄等轉為向量(vectors)。

接著,我們定義一些需要輸入給語言模型的資料,也就是吐真劑、變身水等魔藥知識,這些知識需要以 Document 類別進行包裝,這個類別專門用來儲存文字以及其 metadata, 例如:

Document(page_content='哈利波特', metadata={'作者': 'JK羅琳', '類別': '奇幻類小說'})

再來需要將輸入的資料結合 embedding 模型建立成 vectordb, 也就是 data connection 流程中的 Store , 此處我們使用的是由 Meta 開發的 FAISS 模組,這個模組可以把輸入的資料轉換為向量之外,還可以變成 in memory 的向量資料庫,省去我們另外架設向量資料庫的麻煩:

vectordb = FAISS.from_documents(docs, embeddings)

如果將 vectordb 的建立化為簡單 4 個步驟的話,如下圖所示:

vector-db-creation.png

實際上, Documents 與 Embedding Model 之間還有處理文本(text)的步驟,例如 用 Text Splitter 將文本切成更小且具有意義的區塊(chunks),這樣的好處是可以將文本縮成語言模型可以處理的大小,也就是下圖中的 SPIT :

rag-indexing.png 圖片引用來源 - LangChain

為了讓本文能夠聚焦在 Retrieval 上,因此本文先省略處理文本的步驟。

還記得先前提到的「告訴語言模型要用什麼方式檢索這些資料」的這個步驟嗎?下列程式碼就是建立 1 個檢索器(retriever),讓語言模型之後可以透過它找到我們提供的資料,as_retriever() 的預設檢索方式是相似度(similarity):

retriever = vectordb.as_retriever()

為了讓語言模型知道要從事先輸入的資料(也就是魔藥知識)中找答案,我們還需要定義 prompt 讓它知道有 context 的存在,其中 context 就是事先輸入的資料:

prompt = ChatPromptTemplate.from_messages([
    ('system', 'Answer the user\'s questions in Chinese, based on the context provided below:\n\n{context}'),
    ('user', 'Question: {input}'),
])

有了語言模型與 prompt 之後,我們可以先建立 1 個 stuff document chain :

document_chain = create_stuff_documents_chain(llm, prompt)

This chain takes a list of documents and formats them all into a prompt, then passes that prompt to an LLM. It passes ALL documents, so you should make sure it fits within the context window the LLM you are using.

Stuff document chain 是 LangChain 所提供的 1 種 chain, 它接受把 Document 放到 prompt 之中,然後傳給語言模型處理。不過使用 Stuff document chain 要注意 Documents 太多的話,可能會超過 LLM 可以處理的大小。

最後使用 create_retrieval_chain() 讓 document chain 跟檢索器結合在一起,就完成能夠檢索我們自己提供的資料的 chain:

retrieval_chain = create_retrieval_chain(retriever, document_chain)

我們就能夠使用 retrieval_chain.invoke({'input': '問題'}) 與具檢索功能的語言模型互動囉!

本文看到這邊,可能有人還是會覺得有點模糊,不了解具 Retrieval 功能的 chain 到底長怎樣,以下用圖來表示會更清楚一些。

LangChain 怎麼玩?入門教學篇 中,我們所寫的程式是以下流程,都是 prompt 交給語言模型產生回應:

llm-simple-workflow.png

但是具檢索功能的語言模型運作流程,如下圖所示:

rag-retrieval-generation.png 圖片引用來源 - LangChain

上述程式碼中的 input_text 對應的是上圖中的 question, input_text 會先經過 retriever 找出相似的 Documents (可能會有多筆 Documents), 接著找到的 Documents 與 input_text 都會被一起放到 prompt 之中,所以找到的 Documents 其實對應的就是 prompt 樣板中的 context , 最後再將具有 input_textcontext 的 prompt 傳給語言模型產生內容。

p.s. 實際上 input_text 進到 vector db 進行檢索之前,也會被 embedding model 轉為 embedding

至此,我們已經能夠做到具 Retrieval 功能的語言模型應用,你也能夠從此範例延伸做出客服機器人。

Conversation Retrieval Chain / 對話檢索鏈

不過!前述章節的範例還是沒有像 ChatGPT 那種自然對話的功能,也就是它不具備從對話記錄產生回應的功能。

要做到能夠自然對話的聊天機器人,就需要進一步把對話紀錄也存起來,這種連對話紀錄也能檢索的 chain 稱為 — “Conversation Retrieval Chain” 。

以下是使用 llama2 語言模型加入 Conversation Retrieval 功能的聊天機器人範例,在此先不解釋程式碼的具體意思,大家可以先玩看看(也可以先把下列程式碼中的 My name is Amo. 改成你想要的名字):

from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.chains import create_retrieval_chain
from langchain.chains import create_history_aware_retriever
from langchain_core.messages import HumanMessage, AIMessage
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.prompts import MessagesPlaceholder
from langchain_community.llms import Ollama
from langchain_community.embeddings import OllamaEmbeddings
from langchain_community.vectorstores import FAISS


llm = Ollama(model='llama2')
embeddings = OllamaEmbeddings()

vector = FAISS.from_texts(['My name is Amo.'], embeddings)
retriever = vector.as_retriever()

prompt = ChatPromptTemplate.from_messages([
    ('system', 'Answer the user\'s questions based on the below context:\n\n{context}'),
    ('user', '{input}'),
])
document_chain = create_stuff_documents_chain(llm, prompt)

prompt = ChatPromptTemplate.from_messages([
    MessagesPlaceholder(variable_name="chat_history"),
    ("user", "{input}"),
    ("user", "Given the above conversation, generate a search query to look up in order to get information relevant to the conversation")
])
retriever_chain = create_history_aware_retriever(llm, retriever, prompt)
retrieval_chain = create_retrieval_chain(retriever_chain, document_chain)

chat_history = []
input_text = input('>>> ')
while input_text.lower() != 'bye':
    if input_text:
        response = retrieval_chain.invoke({
            'input': input_text,
            'chat_history': chat_history,
        })
        print(response['answer'])
        chat_history.append(HumanMessage(content=input_text))
        chat_history.append(AIMessage(content=response['answer']))
    input_text = input('>>> ')

上述範例執行之後,你可以試著先問語言模型你的名字,應該會發現它能夠正常回答問題,甚至告訴它新的資訊,它也能夠像 ChatGPT 那樣記得前後文,並且正確回答:

>>> Do you remember my name?
Amo: Of course, I do! *smiling* Your name is Amo, right?
>>> My name is Bmo now.
AI: Ah, I see! Well, hello there Bmo! *smiling* It's great to see you again! How have you been since we last spoke?
>>> Do you know my name?
AI: Of course, I do! *smiling* Your name is Bmo, right?

帥呀!老皮!

大家應該可以發現上述程式與 Retrieval 章節的範例程式大同小異,所以僅說明幾個重要關鍵。

除了一開始在 vector db 中建立 My name is Amo 的資料之外,要讓語言模型針對對話紀錄產生回應的話,就應該把對話紀錄像 context 一樣放到 prompt 中,所以我們用 1 個MessagesPlaceholder(variable_name='chat_history') 在新的 prompt 中存對話紀錄,並且告訴語言模型可以從對話紀錄中產生搜尋的 query 借此從對話中生成新的回應:

prompt = ChatPromptTemplate.from_messages([
    MessagesPlaceholder(variable_name="chat_history"),
    ("user", "{input}"),
    ("user", "Given the above conversation, generate a search query to look up in order to get information relevant to the conversation")
])

另外,最重要的是對話紀錄也需要做成可以被檢索的向量,所以需要用 create_history_aware_retriever() 建立 1 個可以檢索對話紀錄的 chain:

retriever_chain = create_history_aware_retriever(llm, retriever, prompt)

所以現在有 2 個 chains :

  1. document_chain 負責找外部輸入的資料,也就是 My name is Amo 字串
  2. retriever_chain 負責找對話紀錄

最後再用 retrieval_chain = create_retrieval_chain(retriever_chain, document_chain) 建立可以檢索對話跟外部輸入資料的 chain, 完成能夠自然對話的 chain 。

對話紀錄則需要自行建立 1 個 List 儲存,也就是 chat_history = [] 的部分。

另外,對話紀錄也需要明確區分哪些是人類的訊息、哪些是 AI 產生的訊息,所以需要額外在 while 迴圈中把訊息存起來,如果是人類的訊息就用 HumanMessage 類別、如果是 AI 的訊息就用 AIMessage

chat_history.append(HumanMessage(content=input_text))
chat_history.append(AIMessage(content=response['answer']))

最後,每次與語言模型互動時,除了需要使用者訊息,也都應該附上對話紀錄 chat_history ,這樣語言模型才能從對話紀錄生成回應。

response = retrieval_chain.invoke({
    'input': input_text,
    'chat_history': chat_history,
})

p.s. 這邊可以知道一件事——有時候你會發現 AI 就像失憶一樣,忘記更久遠的對話紀錄,多半就是 chat history 長度超過限制,開發者應該是折衷選擇保留最近的對話紀錄的緣故

最後,來看一下畫成圖的 “Conversation Retrieval Chain”, 可以看到跟只有 Retrieval 的 chain 還是有蠻大的不同:

chat-chain.png

圖片引用來源 - LangChain Blog

“Conversation Retrieval Chain” 的 chat history 與新的使用者訊息會先經過一次語言模型產生 1 個新的 search query, 再丟到 vector db 中搜尋,最後再將搜尋結果以及 search query 合在一起傳給語言模型生成最終答案。

至此,你已經學會用 LangChain 做出像 ChatGPT 那般能夠對話的機器人囉!

總結

Retrieval Chain 與 Conversation Retrieval Chain 是學會使用 LangChain 的 2 大重要關隘,只要了解這 2 個 Chain 的原理,不僅能夠知道用語言模型做出來的客服機器人、聊天機器人等背後運作的原理,也能夠讓我們做出相似的應用。

不過, LangChain 並不僅僅只是如此,還有很多功能、訣竅是打造語言模型相關應用需要學會的,我們將在之後的文章介紹更多!

以上!

Enjoy!

References

LangChain - Retrieval

LangChain - Chains

LangChain - Q&A with RAG

Tutorial: ChatGPT Over Your Data

LangChain - Faiss

Using langchain for Question Answering on own data

對抗久坐職業傷害

研究指出每天增加 2 小時坐著的時間,會增加大腸癌、心臟疾病、肺癌的風險,也造成肩頸、腰背疼痛等常見問題。

然而對抗這些問題,卻只需要工作時定期休息跟伸展身體即可!

你想輕鬆改變現狀嗎?試試看我們的 PomodoRoll 番茄鐘吧! PomodoRoll 番茄鐘會根據你所設定的專注時間,定期建議你 1 項辦公族適用的伸展運動,幫助你打敗久坐所帶來的傷害!

贊助我們的創作

看完這篇文章了嗎? 休息一下,喝杯咖啡吧!

如果你覺得 MyApollo 有讓你獲得實用的資訊,希望能看到更多的技術分享,邀請你贊助我們一杯咖啡,讓我們有更多的動力與精力繼續提供高品質的文章,感謝你的支持!