在上一篇文章中介紹了如何利用 Telegram 和 Ollama 打造一個低成本的 AI 聊天機器人。雖然使用 Ollama 驅動的 LLM(大型語言模型)來進行輕鬆對話或創意發想效果相當不錯,但它在回答較新的事件或是特定領域的問題時,仍顯得不夠精確。這也促使了一項名為 RAG(Retrieval-Augmented Generation,檢索增強生成)的創新技術,逐漸在 AI 領域中嶄露頭角。 根據 GhatGPT 的說明,RAG的運作原理如下:
- 檢索階段:當你向 RAG 提問時,它首先會分析你的問題,然後搜尋資料庫裡最相關的內容。就像我們在搜尋引擎上查詢資訊一樣,RAG 會選擇出那些最符合問題的段落或資料。
- 生成階段:接下來,RAG 會根據它剛剛找到的資料來生成回答,這樣可以確保答案不僅是基於舊的知識,還能結合到最新的資訊。
- 結合檢索和生成:這兩步驟的結合就是 RAG 的亮點,它不僅僅依賴已有的知識來生成內容,還能結合外部檢索到的信息,做到更加精準和動態的回答。
簡單地說,RAG 是一種結合了「資料檢索」和「生成模型」的技術。上述內容聽起來似乎很厲害,但對於不少人來說,仍然覺得有些模糊。舉個更實際的例子,現在幾乎所有的 AI 聊天服務都已經加入了網路搜尋功能,這正是 RAG 技術的一種應用。它會先將用戶的問題送到網路上進行搜尋,模擬真人用戶先用 Google 查詢一遍,再從搜尋結果中篩選出最相關的答案,回覆給用戶。這樣一來,AI 聊天機器人似乎能夠不斷學習新知並不斷成長。
那麼問題來了, 如何在本地端實現 RAG 呢?網路上有不少相關教學,甚至有些博主信誓旦旦地說,RAG 非常適合用於部署在私人企業,作為企業內部的知識庫。然而,當我依照這些教學操作時 ,結果卻是翻車連連,難道是我技不如人嗎?還是說教學博主們也是標題流量黨?畢竟,企業的需求和個人使用標準是不同的,如果內容未能對齊企業目標,問題就可能從小事變成大事。
經過數周以來與 ChatGPT 一起多輪奮戰之後,如今總算是有了一點眉目,目前算是很接近成功吧!因此,我決定撰寫這篇文章,分享如何在本地端搭建 RAG 應用的經驗,並談談在過程中所遭遇的各種挑戰與心得。
相關代碼
先談程式碼的部分。程式碼的部分並不多,做法跟網路教學也大同小異。首先是 ollama 的部分,前一篇就聊過搭建 Ollama 的方法與套件,這邊就不贅述了。要讓 LLM 說出想要的知識庫答案,聊天結合 RAG 檢索的程式碼就這樣:
q = input('輸入提問:')
# 進行使用者查詢
rag = rag_result(f'{q}') # 知識庫的 RAG 檢索結果
# 使用 Ollama 回答
from ollama import chat
stream = chat(
model='phi4:14b', # RTX-3060 12G 跑得動,VRAM 不夠大可以選擇其他 7b/8b 或更小的。
messages=[{'role': 'system', 'content': f'請根據提供的資料回答。如果資料庫內顯示「沒有答案」,\
或不能確定是用戶要的答案,就說「抱歉,我不知道。」,不要自行推理。提供的資料:\n {rag}'},
{'role': 'user', 'content': f'{q}'}],
stream=True,
keep_alive=1
)
for chunk in stream:
print(chunk['message']['content'], end='', flush=True)
從以上程式碼就能看出 RAG 搭配 LLM 到底是怎麼一回事了。從知識庫中得到的答案 rag 放入 LLM 的 Prompts(提詞)內,作為上下文讓語言模型作參考就行了。到了這裡可以先做兩件事:
- rag_result() 這一段需要生成什麼答案,可以先隨便手動生成幾個答案。
- 觀察 ollama 會怎麼回應。
這裡就可以說明為何照著網路教學沒問題,自己做就翻車了。因為網路教學總是先 Demo 幾筆知識庫,所以 RAG 檢索範圍很小,永遠只會找出那幾筆,語言模型會根據 system role 題詞把不相干的知識拿掉,所以答案一定是對的。然而現實中知識庫內容通常都很龐大,所以問題就來了。
直接進入大知識庫的實戰環節,這裡我們用台灣的「中華民國刑法」作為知識庫。先將下載的 pdf 轉換成文字檔:
安裝 pdfplumber 套件
pip install pdfplumber
PDF 轉文字檔的程式碼:
import pdfplumber
def read_pdf(file_path:str):
'''讀取 PDF 文件內容'''
with pdfplumber.open(file_path) as pdf:
text = ''
for page in pdf.pages:
text += page.extract_text()
return text
text = read_pdf('中華民國刑法.pdf')
text 內容是一段 1,171 行的「中華民國刑法」全文文本,這時候若異想天開的把它全當成 RAG 檢索內容(即第一段代碼中的 rag 內容),然後問 ollama 一些法律問題如「複印周杰倫演唱會的門票再拿去賣是有沒有犯罪?如果有的話是犯什麼罪?」時,會發現 ollama 會放飛自我,自行推理出答案,用的還是不知哪一國的法律(通常與訓練開源 LLM 模型的公司的國家有關),對提供的中華民國刑法內容(text)完全無視。原因是 system role 已超出語言模型的上下文文本長度,導致本來要求它「只能根據提供的內容回答」的要求失效。
因此必須將龐大的「中國民國刑法」文本進行切割並建立索引,並希望切割後的內容也要能與提問產生關聯性。這件事仔細想想就覺得很玄。
接下來是文本切割,程式碼也不多。切割方法讓我多次翻車,最後是根據「第 xxx 條」內容進行切割(感謝 ChatGPT 提供寫法),原因是切割之後必須確保每一段內容的語意完整,在 embedding 向量化之後才能正常發揮作用。
def chunk_content(text:str):
'''以 '第 xx 條' 為分割點切割文本
---
text: str 要切割的文本全文\n
return: List[str] # 切割後的文本列表
'''
# 使用正則表達式匹配 '第 xx 條' 的模式作為分割點
pattern = r'(第\s*\d+[-\d]*\s*條)' # 這個正則表達式會匹配 '第 1 條'、'第 3-1 條' 等模式
# 使用 re.split 進行分割
texts = re.split(pattern, text)
result = []
for text in texts: #切割後,第xxx條也會自成一個分割段,所以要先加入======== 再合併後,再次以 ======== 切割
if re.match(pattern, text): # 檢查是否是分割點('第 xx 條')
result.append('========\n') #加入新的切割標示
result.append(text)
# 將結果合併成字符串,內容是第 xx 條之前都會有 ========\n 標示
output_text = ''.join(result)
pattern2 = r'========\n' # 再針對 '========\n' 切割
chunks = re.split(pattern2, output_text)
return chunks
chunk_content(text) 結果會得到一個文本區塊的 list(串列),每一個文本區塊內容是刑法的某一條條文。
接著是 RAG 的組合拳重點精華:
pip 安裝相關的套件
pip install chromadb sentence_transformers tiktoken
RAG 程式碼如下(這整段可以存成檔案,如 RAGProc.py)
import chromadb
from sentence_transformers import SentenceTransformer
import tiktoken #計算 tokens 長度
model = SentenceTransformer('intfloat/multilingual-e5-large') # 試了一大堆的模型,就這個最好用,但也最消耗記憶體
# 初始化 ChromaDB(索引儲存到本地)
chroma_client = chromadb.PersistentClient(path='./chroma_db')
collection = chroma_client.get_or_create_collection(name='rag_collection')
# 計算 token 長度,以免答案過長影響回答,
tokenizer = tiktoken.get_encoding("cl100k_base")
#取得本地 Embedding 向量(加上 "passage:" 前綴)
def get_embedding(text, is_query=False):
if is_query:
text = 'query: ' + text
else:
text = 'passage: ' + text
result = model.encode(text,normalize_embeddings=True).tolist() #normalize_embeddings=True 的意思是將生成的語句嵌入向量進行歸一化處理。
return result
# 處理文件並存入 ChromaDB
def process_ragdb(chunks,file_path):
print(f'建立 [{file_path}] RAG 索引...')
for idx, chunk in enumerate(chunks):
embedding = get_embedding(chunk, is_query=False) # 文件內容加 "passage:"
collection.add(
ids=[f'{file_path}_{idx}'],
embeddings=[embedding],
metadatas=[{'source': file_path, 'chunk_index': idx, 'text': f'{chunk}'}],
documents=[f'{chunk}']
)
print(f'成功存入 {len(chunks)} 個 clean_chunk')
# 計算 token 長度
def tokens_len(text:str):
tokens = tokenizer.encode(text)
return len(tokens)
# 查詢最相關內容
def rag_result(query, top_k=6, limit_tokens = 1024):
query_embedding = get_embedding(query, is_query=True) # 查詢加 "query:"
results = collection.query(
query_embeddings=[query_embedding],
n_results=top_k
)
db = ''
ans_tokens_lens = 0
for document in results['documents'][0]:
document_toekn_lens = tokens_len(str(document))
if ans_tokens_lens + document_toekn_lens > limit_tokens:
#print('答案過長,後面忽略')
continue
db = f"{db}{document}\n"
ans_tokens_lens += document_toekn_lens
if db == '':
db = '沒有答案'
return db.strip()
說明如下:
- get_embedding:使用 multilingual-e5-large 模型,將文本區塊轉換為向量數據。轉換之後,語言模型才能以量化計算語意的相似度,問答之間的關聯度等計算,所以 embedding 量化是 RAG 能否實現的重點。這裡使用 is_query 用來切換文本區塊加上 ‘query: ‘ 與 ‘passage: ‘ 前綴詞,原因是 multilingual-e5-large 模型的特有用法,與這個模型的訓練方式有關,用來區分文字區塊是提問屬性或是一般文本屬性,詳情可參考該模型的 Model card 說明。
- process_ragdb:主要是處理 chunks 文本區塊集,也就是上面提到的切割之後的文本 list,逐一 embedding 轉換成向量資料之後儲存在本機端 chroma 資料庫內。file_path 只是作為切割之後將文本片段標記來源而已(例如若要加入兩種以上的法規),不影響結果,可以省略不用。
- tokens_len:這裡用到 openai 提供的 token 計算工具,用來大略算一下每一段文字(即每一條法條)的長度。
- rag_result:將用戶的提問也轉換成向量之後,從向量資料庫中搜尋比對,找出可能的答案。top_k 是列出前幾個最可能的答案。limit_tokens 是限制答案的長度,如果答案太長,會發生上面說的放飛自我的情況。數值與 ollama 使用哪一個 LLM 有關,如果該 LLM 模型能接受較長的文本,數字可以加大,個人心得是 phi4:14b 模型,limit_tokens 超過兩千就放飛了。
以上就是代碼的部分,組合起來再稍加改寫,如下:
import pdfplumber
import re
def read_pdf(file_path:str):
#為了節省版面,這裡填入上面 read_pdf 內容
def chunk_content(text:str):
#為了節省版面,這裡填入上面 chunk_content 內容
from RAGProc import * #匯入上面的 RAGProc.py
# 第一次執行時 is_Indexed 請改為 False 才能建立向量索引,在同目錄下會產生chroma_db 目錄。第二次以後改為 True。
# 如果要重建索引,刪除 chroma_db 目錄,並將 is_Indexed 改為 False。
is_Indexed = True #假設已經建過向量索引
if not is_Indexed:
file_path = '中華民國刑法.pdf'
text = read_pdf(file_path)
texts = chunk_content(text) #切割文本取得文本 list
process_ragdb(texts,file_path) #將文本 list 轉為向量數據並存入資料庫中
exit(0) #直接跳離結束程式
if __name__ == "__main__":
while True:
q = input('輸入提問:')
# 進行使用者查詢
rag = rag_result(f'{q}') # 知識庫的檢索結果
# 使用 Ollama 回答
from ollama import chat
stream = chat(
model='phi4:14b', # 模型名,可用 ollama list 查詢
messages=[{'role': 'system', 'content': f'請根據提供的資料回答。如果資料庫內顯示「沒有答案」,或不能確定是用戶要的答案,就說「抱歉,我不知道。」,不要自行推理。提供的資料:\n {rag}'},
{'role': 'user', 'content': f'{q}'}],
stream=True,
keep_alive=1
)
for chunk in stream:
print(chunk['message']['content'], end='', flush=True)
print('\n')
最終成果
接下來要驗收結果了,兄弟們坐穩啦!激動的心,顫抖的手,開機!⋯B2⋯99⋯華碩的 Logo 出來啦!
AI 認為是犯了刑法 203 條,刑期最重是一年以下。與原始的刑法文本來源對照一下:
查了一下新聞,檢查官是以偽造文書罪、加重詐欺罪起訴,而且嫌疑犯還被請吃雞鴨飯,刑期最輕都是一年以上。可想而知屆時上了法庭,被告的辯護律師也會以其他理由減輕刑期或脫罪,所以 AI 的理由看看就好。
問 AI 這兩種罪的刑期,AI 也答得出來,所引用的法條的確是我們提供的。至於有沒有答對,我個人已經無法分辨,得問專業法律人士。
再隨便問幾個問題:
要強調的是,法律問題還是得請真正的專家,所以 AI 的意見看看就好。其中有些看得出似乎有點道理但又怪怪的,這是因為 LLM 是查詢得到「有限的結果」再進行語意推理,因此真正關鍵在於索引資料庫能否根據提問內容,查詢得到更精確的答案。
既然要玩就玩大一點的。再加入民法與道路交通管理處罰條例,連同刑法全部加起來超過兩千條,出個題看看 AI 怎麼回答:
至於 AI 的回答是否正確,老實說,有些答案看起來有點牽強(可能是因為沒能找到最合適的資料,導致 LLM 推理出一些不太合理的答案),但也有些答案聽起來頗為有道理。因此,法律問題還是應該交給專業人士來判斷。不過,無論 AI 的回答是否正確,可以確定的是,所引用的法律條文確實來自我們提供的資料。
結論與心得:
RAG 方案是我第一次在重度依賴免費 ChatGPT 的項目上進行嘗試。過去遇到問題時,我習慣直接 Google 搜尋答案,但這次我想看看 ChatGPT 能帶我走到哪裡,除非它卡住,總是在相同問題上繞圈圈,我才會上網搜尋新的解法。上述內容是經過幾週的反覆測試後,所得到的最佳結果,過程中確實踩了不少坑。從資料切割方法到方案選擇,最後在 chromadb 方案中選擇 embedding 模型時,ChatGPT 推薦了開源的 multilingual-e5-large(在繁中 embedding 排名中不錯),最終得到了相對滿意的結果。
不過,這一切才剛剛開始,仍有許多地方可以進行優化。舉例來說,當我詢問「行駛人行道罰多少」時,總是得到錯誤的答案。正確的答案應該是「道路交通管理處罰條例 第 45 條第 1 項第六款」,查閱發現,第 45 條的內容非常冗長,超出了 multilingual-e5-large 的窗口長度(512 tokens),因此無論如何都無法找到正確答案。當我將第 45 條切割成更小的段落後,再補充相關條文進行 embedding,問題最終解決了。從觀察來看,像是較高層次的母法(如民法、刑法、憲法)較少遇到這種情況,反而是其他條例或辦法中,條文內容過長的情況較多。因此,資料切割方法仍有進一步優化的空間,否則只能等待支援更多 tokens 的繁中 embedding 模型出現。
也許隨著 AI 科技的發展,LLM 和 embedding 技術不斷進步,將來或許能把整本六法全書納入其中,這不就是妥妥的 AI 法律顧問,律師也要跟著失業了嗎?不過,嗯~有夢最美,希望相隨…