做一個務實的RAG (Pragmatic RAG)

不要跟我五四三,回點有用的話來

這篇文章是專門為了繁體中文的 RAG 寫的。

你有覺得聊天機器人一直在打高空嗎?
你有覺得聊天機器人總是瞎掰胡謅嗎?
你有覺得聊天機器人經常答非所問嗎?

如果你使用的 prompt 已經很精準了,卻還常有上述感受,那很有可能你實作的 RAG 出了根本上的問題。

要讓 RAG 務實有以下幾個重點:

  1. 必須是要懂繁體中文的 embedding
  2. 要有中文語意的分片器
  3. 一定要是用繁體中文訓練的 LLM
  4. 精準的 prompt 連同 system instruction
  5. 沒做 rerank 的效果都差到不行,但 rerank 要懂繁體中文

能夠做到上述五點,那麼你就可以得到一個穩重且有參考價值的 RAG。

核心概覽

以下我會提供一個使用 langchain 實作的繁體中文萬用套餐,最重要的是:本地運行,完全免費。

既免費又萬用的核心元素是下列:

  1. 懂中文的 embedding:BGE-M3
  2. 中文語意的分片器:RecursiveCharacterTextSplitter
  3. 繁體中文訓練的 LLM:TAIDE
  4. 懂繁體中文的 rerank:ms-marco-MultiBERT-L-12

上述都是免費的,搭配本地運行的框架:

  1. Ollama
  2. Langchain

就可以實作我們要的務實 RAG。

具體實踐

把 Ollama 裝好,有 Python 後就開始吧。

以下我會用一個 Confluence 的爬蟲做示範,但整個腳本可以套用在任何繁體中文的資料源。

1
2
3
4
5
6
7
8
# Langchain 的核心套件  
pip install langchain langchain-community langchain-core langchain-huggingface
# Confluence 爬蟲需要的套件 (根據資料源可替換)
pip install atlassian-python-api lxml pytesseract docx2txt
# Rerank 用
pip install flashrank
# 向量存儲
pip install faiss-cpu

Python 套件都安裝完成後的第一步,我們要先準備給 RAG 使用的資料源,以本例來說是 Confluence。

1
2
3
4
5
6
7
8
9
10
11
12
from langchain_community.document_loaders import ConfluenceLoader  

CONFLUENCE_TOKEN = '<your token>'
USER = '<your account>'
ROOT_URL = '<your domain>'
SPACE_KEY = '<your space>'
PAGE_ID = '<your page>'
loader = ConfluenceLoader(
url=ROOT_URL, username=USER, api_key=CONFLUENCE_TOKEN
)
documents = loader.load(space_key=SPACE_KEY,
page_id=PAGE_ID, include_attachments=True, limit=50)

到此,Confluence 的資料就全部抓下來並存進 documents 了。

若你要處理的是別的資料源,就直接從這裡往下,重點是所有資料要放進 documents。接著我們要來做分段,許多英文的教學會使用 token 來做分段,但中文與 token 並沒有很好的對應,效果會非常差。因此,我們依然採用標點符號的分段方式,並且讓上下兩片段是有重疊的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from langchain.text_splitter import RecursiveCharacterTextSplitter  

text_splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=400,
length_function=len,
is_separator_regex=False,
separators=[
"\n\n",
"\n",
" ",
".",
",",
"\u200b", # Zero-width space
"\uff0c", # Fullwidth comma
"\u3001", # Ideographic comma
"\uff0e", # Fullwidth full stop
"\u3002", # Ideographic full stop
"",
],
)
texts = text_splitter.split_documents(documents)

上面程式碼可以看到,我們對中文的全型逗號和句號做了額外處理,想要加入更多字元也行。

另一個關鍵是我們的片段長度是 800 且重疊 400 字,這是我實測下來最理想的長度了,當然也可以根據需求調整。

接著我們準備開始做 embedding,務必記得,embedding 的模型一定要懂繁體中文。現階段,既免費效果又好的當屬 BAAI/bge-m3

1
2
3
4
5
6
7
8
9
10
from langchain_huggingface import HuggingFaceEmbeddings  
from langchain_community.vectorstores import FAISS

HF_EMBEDDING_MODEL = 'BAAI/bge-m3'
hf_embeddings = HuggingFaceEmbeddings(
model_name=HF_EMBEDDING_MODEL,
model_kwargs={'device': 'cpu'},
encode_kwargs={'normalize_embeddings': False}
)
vectordb = FAISS.from_documents(texts, hf_embeddings)

現在我們已經有一份完整的向量放在記憶體內了,至於是否需要落地,也是根據需求決定。

最後就是聊天對話了,讓我們用 langchain 的內建功能快速搭建起來。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from langchain.prompts import PromptTemplate  
from langchain.chains import RetrievalQA
from langchain.retrievers.document_compressors import FlashrankRerank
from langchain.retrievers import ContextualCompressionRetriever
from langchain_community.llms import Ollama

LLM_MODEL = 'cwchang/llama3-taide-lx-8b-chat-alpha1'
RERANK_MODEL = 'ms-marco-MultiBERT-L-12'

llm = Ollama(model=LLM_MODEL)
custom_prompt_template = """
<your system instruction>
{context}
Question: {question}
Helpful Answer:"""
CUSTOMPROMPT = PromptTemplate(
template=custom_prompt_template, input_variables=["context", "question"]
)
retriever = vectordb.as_retriever(search_type="similarity",
search_kwargs={"k": 100}) # K1, Top100 Snippets
compressor = FlashrankRerank(model=RERANK_MODEL, top_n=5) # K2, Top5 Answers
compression_retriever = ContextualCompressionRetriever(
base_compressor=compressor, base_retriever=retriever
)
qa = RetrievalQA.from_chain_type(llm=llm, chain_type="stuff",
retriever=compression_retriever, return_source_documents=True)
## Inject custom prompt
qa.combine_documents_chain.llm_chain.prompt = CUSTOMPROMPT
question = "<your question>"
answer = qa({"query": question})
print(answer)

上面這段 code 有幾個重點:

  1. 採用 Ollama Hub 內有的 TAIDE 模型 (整合好的比較方便)
  2. Rerank 模型則是 langchain 整合的 ms-marco-MultiBERT-L-12
  3. 先用向量的相似性比對找出前 100 名,接著透過 rerank 找出最佳的 5 個
  4. 雖然 answer 只會有一個答案,但 return_source_documents=True 所以可以看到參考了哪些結果

結論

上面那整段 code 都只需要本地運行,硬碟大概需要 10 GB 左右,但因為不需要 GPU ,所以一般手上的電腦都可以跑。

雖然是以 Confluence 爬蟲作為例子,但相信你們都可以發現,langchain 這個框架其實整合的相當好,大部分的工作都可以簡單幾行 code 就處理掉。即使是需要一定 know-how 的 rerank,也已經有內建的整合可以直接使用。

至於腳本內沒提到的 prompt 和 system instruction,那個必須根據你的用途和語料來動態調整,所以如果使用這個腳本還碰到不夠精準的回答,那多半是這兩點沒調校好。

Originally published on Medium