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

     

    【碼農】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 非常適合用於部署在私人企業,作為企業內部的知識庫。然而,當我依照這些教學操作時 ,結果卻是翻車連連,難道是我技不如人嗎?還是說教學博主們也是標題流量黨?畢竟,企業的需求和個人使用標準是不同的,如果內容未能對齊企業目標,問題就可能從小事變成大事。

    經過數周以來與 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(串列),每一個文本區塊內容是刑法的某一條條文。

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

    接著是 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 出來啦!

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

    AI 認為是犯了刑法 203 條,刑期最重是一年以下。與原始的刑法文本來源對照一下:

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

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

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

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

    再隨便問幾個問題:

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

    要強調的是,法律問題還是得請真正的專家,所以 AI 的意見看看就好。其中有些看得出似乎有點道理但又怪怪的,這是因為 LLM 是查詢得到「有限的結果」再進行語意推理,因此真正關鍵在於索引資料庫能否根據提問內容,查詢得到更精確的答案。

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

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

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

    至於 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 法律顧問,律師也要跟著失業了嗎?不過,嗯~有夢最美,希望相隨…

    發佈留言

    發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *