close

【碼農】RAG 應用:讓 AI 更貼近你的需求

上一篇文章中介紹了如何利用 Telegram 和 Ollama 打造一個低成本的 AI 聊天機器人。雖然使用 Ollama 驅動的 LLM(大型語言模型)來進行輕鬆對話或創意發想效果相當不錯,但它在回答較新的事件或是特定領域的問題時,表現仍不夠精確。這也促使了一項名為 RAG(Retrieval-Augmented Generation,檢索增強生成)的創新技術,逐漸在 AI 領域中嶄露頭角。 根據 GhatGPT 的說明,RAG的運作原理如下:

  • 檢索階段:當你向 RAG 提問時,它首先會分析你的問題,然後搜尋資料庫裡最相關的內容。就像我們在搜尋引擎上查詢資訊一樣,RAG 會選擇出那些最符合問題的段落或資料。
  • 生成階段:接下來,RAG 會根據它剛剛找到的資料來生成回答,這樣可以確保答案不僅是基於舊的知識,還能結合到最新的資訊。
  • 結合檢索和生成:這兩步驟的結合就是 RAG 的亮點,它不僅僅依賴已有的知識來生成內容,還能結合外部檢索到的信息,做到更加精準和動態的回答。

簡單地說,RAG 是一種結合了「資料檢索」和「生成模型」的技術。上述內容聽起來似乎很厲害,但對於不少人來說,仍然覺得有些模糊。舉個更實際的例子,現在幾乎所有的 AI 聊天服務都已經加入了網路搜尋功能,這正是 RAG 技術的一種展現。它會先將用戶的問題送到網路上進行搜尋,模擬真人用戶先用 Google 查詢一遍,再從搜尋結果中篩選出最相關的答案,交由語言模型彙整之後回覆給用戶。這樣一來,AI 聊天機器人似乎能夠不斷學習新知並不斷成長。

那麼問題來了,有不少資料是只能存放在本地端,如何在本地端實現 RAG 呢?網路上有不少相關教學,甚至有些博主信誓旦旦地說,RAG 非常適合用於部署在私人企業,作為企業內部的知識庫。然而,當我練習 RAG 的建置過程中 ,結果卻是翻車連連,難道是我技不如人嗎?還是說教學博主們也是標題流量黨?畢竟,企業的需求和個人使用標準是不同的,如果內容未能對齊企業目標,問題就可能從小事變成大事。

經過數周以來與 ChatGPT 一起多輪奮戰之後,如今總算是有了一點眉目,目前算是很接近成功吧!因此,我決定撰寫這篇文章,分享如何在本地端搭建 RAG 應用的經驗,並談談在過程中所遭遇的各種挑戰與心得。

相關代碼

先談程式碼的部分。程式碼的部分並不多,做法跟網路教學也大同小異。首先是 ollama 的部分,前一篇就聊過搭建 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 的系統題詞(system role 的 content)內,作為上下文本讓語言模型參考就行了。到了這裡可以先做兩件事:

  • rag = rag_result() 這一段需要生成什麼答案,可以先隨便手動生成幾個答案。
  • 觀察 ollama 會怎麼回應。例如 rag_result() 回應內容可以先亂胡謅「申請貸款要準備身分證、護照、帳戶資料。申請借款要準備印章、雙證件、戶口名簿」,然後輸入問題時隨便問,看看 ollama 會怎麼回應。

這裡就可以說明為何照著網路教學沒問題,自己做就翻車了。因為網路教學通常只會示範幾筆資料到資料庫,所以不論用哪一種檢索方式,一定也會找出那幾筆,RAG 檢索範圍很小,資料庫命中率百分之百,語言模型會根據 system role 題詞把不相干的答案過濾掉,所以結果一定是符合預期的。然而現實中的資料庫通常都很龐大,RAG 檢索範圍大,答案明明在資料庫中卻不一定會被 RAG 檢索到,所以問題就來了。

在此直接進入大型資料庫的 RAG 實戰環節,用台灣整套「中華民國刑法」作為範例。先將下載的 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 行的「中華民國刑法」全文文本,這時候若異想天開的把它全丟到系統題詞內,然後問 ollama 一些法律問題如「複印周杰倫演唱會的門票再拿去賣是有沒有犯罪?」時,會發現 ollama 直接不演了,放飛自我自行推理出答案,用的還是不知哪一國的法律,對提供的中華民國刑法內容(text)完全無視。原因是刑法全文丟到 system role 提詞已超出語言模型的上下文長度,本來要求它「只能根據提供的內容回答」的題詞被截斷而失效。

因此必須將整部「中國民國刑法」切割為多個文本區塊(chunk),以便於後續 RAG 根據問題找出最接近答案的文本區塊即可。至於這些區塊會如何與提問產生關聯,一切得仰賴「embedding 語言模型」的理解力,想想就覺得很玄。

接下來是文本切割,程式碼也不多。然而切割方法讓我多次翻車,最後是根據「第 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(串列),每一個文本區塊內容是刑法的某一條條文。

螢幕擷取畫面 2025-03-29 000123.png

接著是一整套 RAG 組合拳:

pip 安裝相關的套件

pip install chromadb sentence_transformers

RAG 程式碼如下,以下整段程式碼可直接存成檔案,如 RAGProc.py。

import chromadb
from sentence_transformers import SentenceTransformer

model = SentenceTransformer('intfloat/multilingual-e5-large') # 試了一大堆的模型,就這個最好用,但也最消耗記憶體

# 初始化 ChromaDB(索引儲存到本地)
chroma_client = chromadb.PersistentClient(path='./chroma_db')
collection = chroma_client.get_or_create_collection(name='rag_collection')

#取得本地 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)} 個 chunk')

# 計算 token 長度
def tokens_len(text:str):    
    tokens = model.tokenizer.encode(text)
    return len(tokens)

# 查詢最相關內容
def rag_result(query, top_k=5, 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 能否實現功能的核心重點,LLM 的回答才是其次。這裡使用 is_query 用來切換文本區塊加上 'query: ' 與 'passage: ' 前綴詞,原因是 multilingual-e5-large 模型的特有用法,與這個模型的訓練方式有關,少了前綴詞會降低 multilingual-e5-large 模型的性能表現,詳情可參考該模型的 Model card 說明。
  • process_ragdb:主要是處理 chunks 文本區塊集,也就是上面提到的切割之後的文本 list,逐一 embedding 轉換成向量資料之後,儲存在本機端 chromadb 向量資料庫內。file_path 只是作為切割之後將文本片段標記來源而已(例如若要加入兩種以上的法規),不影響結果,測試時可以省略不用。
  • tokens_len:計算每一段文字(即每一條法條)的長度。值得注意的是,get_embedding() 用的是 model.encode(),而計算長度 tokens_len() 用的是 model.tokenizer.encode(),兩者的用途與意義差別很大,可以請 ChatGPT 解釋兩者有何不同。
  • rag_result:根據提問從資料庫中找出可能的答案。top_k 是列出前幾個最有可能是答案的文本區塊,在這裡我的建議是 3 就有不錯的反應,最高 5 就行了,答案一多反而會干擾 LLM 的判斷。limit_tokens 是限制答案的長度,因為如果答案太長,就會發生上面說的放飛自我,這個數值與 ollama 使用哪一個 LLM 有關,如果該 LLM 模型能接受較長的文本,數字可以加大。個人心得是 phi4:14b 模型在 limit_tokens 超過 1,024 就放飛了,DeepSeek-r1:14b 可以再稍微大一點。

以上就是代碼的部分,組合起來再稍加改寫,如下:

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 出來啦!(修電腦的張哥

螢幕擷取畫面 2025-03-29 005730.png

AI 認為是犯了刑法 203 條,刑期最重是一年以下。與原始的刑法文本來源對照一下,確定來自於提供的內容:

【碼農】RAG 應用:讓 AI 更貼近你的需求

查了一下新聞,檢查官是以偽造文書罪、加重詐欺罪起訴,而且嫌疑犯還被請吃雞鴨飯,刑期最輕都是一年以上。可想而知屆時上了法庭,被告的辯護律師也會以其他法條爭取減刑或脫罪,到底犯了哪一條法律可能也不一定,所以 AI 的理由看看就好。

問 AI 這兩條法律,AI 也答得出來,所引用的條文也是我們提供的。至於有沒有答對,我個人已經無法分辨,得問專業法律人士。

螢幕擷取畫面 2025-03-29 012240.png

再隨便問幾個問題:

螢幕擷取畫面 2025-03-29 011339.png

上面的提問中刻意以情境描述小明或小華的實際行為,而非直接涉及法條內容,藉此觀察是否與答案產生關連,結果看起來是有的。要強調的是,法律問題還是得請真正的專家,所以 AI 的意見看看就好。其中有些看得出似乎有點道理但又怪怪的,這是因為 LLM 是查詢得到「有限的結果」再進行語意推理,因此真正關鍵在於 RAG 索引資料庫能否根據提問內容,查詢命中更精確的答案。

既然要玩就玩大一點的。再加入民法道路交通管理處罰條例,連同刑法全部加起來超過兩千條、數萬字,出幾題看看 AI 怎麼回答:

螢幕擷取畫面 2025-03-29 015311.png

螢幕擷取畫面 2025-03-29 015811.png

看得出 AI 回答內容有涵蓋兩部以上的法律(酒駕涉及刑法與交通處罰條例)。至於 AI 的回答是否正確,老實說,有些答案看起來有點牽強(可能是因為沒能找到最合適的資料,導致 LLM 推理出一些不太合理的答案),但也有些答案的邏輯似乎頗有道理。再次強調,法律還是應該交給專業人士來判斷,案例也不一定有正確答案(不然兩造就不用打官司了),但可以確定的是,AI 所引用的法律條文,確實遵守從我們提供的資料找答案的指令。

結論與心得:

RAG 方案是我第一次在重度依賴免費 ChatGPT 的項目上進行嘗試。過去遇到問題時,我習慣直接 Google 搜尋答案,然而這次如開文所言,網路教學在匯入大資料庫之後就失效翻車了,所以這次我想看看 ChatGPT 能帶我走到哪裡,除非它卡住,總是在相同問題上繞圈圈,我才會上網搜尋新的解法。上述內容是經過幾週的反覆測試後,所得到的最佳結果,過程中確實踩了不少坑。從資料切割方法到方案選擇,最後在 chromadb 方案中選擇 embedding 模型時,ChatGPT 推薦了開源的 multilingual-e5-large(在繁中 embedding 排名中不錯),最終得到了相對滿意的結果。此外,ollama 也支援 embeddings 模型說明),實測搭配 bge-m3 對中文的效果也不錯。

不過,這一切才剛剛開始,仍有許多地方可以進行優化。舉例來說,當我詢問「行駛人行道罰多少」時,總是得到錯誤的答案。正確的答案應該是「道路交通管理處罰條例 第 45 條第 1 項第六款」,調查發現,第 45 條的內容非常冗長,超出了 multilingual-e5-large 的窗口長度(512 tokens),因此無論如何都無法找到正確答案。當我手動將第 45 條切割成更小的段落,再補充相關條文進行 embedding 程序,問題最終解決了。從觀察來看,像是較高層次的母法(如民法、刑法、憲法)較少遇到這種情況,反而是其他條例或辦法中,條文內容過長的情況較多。以及每一種法律的文件架構都不太一樣(編-章-節-款-條),因此,資料切割方法仍有進一步優化的空間。當然了,法規這種容易分割的知識來練習 RAG 是相當理想的狀況,現實中各種知識文件該如何分割就是一門學問,否則只能等待支援更多 tokens 的繁中 embedding 模型出現。

隨著 AI 科技的發展,LLM 和 embedding 技術不斷進步,或許將來就能把整套六法全書納入其中,屆時這些程式碼就是妥妥的免費 AI 法律顧問,難道律師也要跟著失業了嗎?嗯~有夢最美,希望相隨...

(延伸閱讀:突破 RAG 瓶頸!Search-R1 直接把「搜尋引擎」整合到推理模型中

arrow
arrow
    創作者介紹
    創作者 benjenq 的頭像
    benjenq

    -Ben's PHOTO-

    benjenq 發表在 痞客邦 留言(0) 人氣()