如何客製化企業 RAG 知識庫?— 從資料庫到知識整合的實戰技術
|

用OpenVINO和LangChain打造你專屬的RAG問答系統

   

作者:楊亦誠

隨著生成式AI的興起,和大語言模型對話聊天的應用變得非常熱門,但這類應用往往只能簡單地和你「聊聊家常」,並不能針對某些特定產業提供非常專業和精準的答案,這是大語言模型(以下簡稱LLM)在時效性和專業性上的侷限所導致:現在市面上大部分開源的LLM幾乎都只是使用某一個時間點前的公開資料進行訓練,因此無法學習到這個時間點之後的知識,並且也無法保證在專業領域上知識的準確性。

那有沒有辦法讓你的模型學習到新的知識呢?當然有,這裡一般有2種方案:

  1. 微調(Fine-tuning):微調是透過對特定領域資料庫進行廣泛的訓練來調整模型,這樣可以內化專業技能和知識。然後,微調也需要大量的資料、大量的運算資源和定期的重新訓練以保持時效性。
  2. 檢索增強生成(RAG):RAG的全稱是Retrieval-Augmented Generation,它的原理是透過檢索外部知識提供上下文回應,在無需對模型進行重新訓練的情況,保持模型對於特定領域的專業性,同時透過更新資料查詢庫,可以實現快速地知識更新。但RAG在構建以及檢索知識庫時,會佔用更多額外的記憶體資源,其回應延遲也取決於知識庫的大小。

從以上比較可以看出,在沒有足夠GPU運算資源對模型進行重新訓練的情況下,RAG方式對普通使用者來說更為友好。本文就要來探討如何利用OpenVINO以及LangChain工具構建屬於你的RAG問答系統。

RAG流程

RAG可以幫助LLM「學習」到新的知識並提供更可靠的答案,它的實現流程其實並不複雜,主要可以分為以下兩個部分:

  1. 構建知識庫檢索
構建知識庫流程

構建知識庫流程

  • 載入(Load):讀取並解析使用者提供的非結構化資訊,這裡的非結構化資訊可以是例如PDF或者Markdown這樣的檔案形式。
  • 分割(Split):將檔案中段落按標點符號或是特殊格式進行拆分,輸出若干片語或句子,如果拆分後的單句太長,會不便於後期LLM理解以及抽取答案;如果太短又無法保證語義的連貫性。因此我們需要限制拆分長度(chunk size);此外為了保證chunk之間文字語義的連貫性,相鄰chunk會有一定的重疊,在LangChain中我可以透過定義Chunk overlap來控制這個重疊區域的大小。
Chunk size和Chunk overlap範例。

Chunk size和Chunk overlap範例。

  • 向量化(Embedding):使用深度學習模型將拆分後的句子向量化,把一段文字根據語義在一個多維空間的座標系裡面表示出來,以便知識庫儲存以及檢索;語義相近的兩句話所對應的向量相似度會相對較大,反之則較小,以此方式我們可以在檢索時,判斷知識庫裡句子是否可能為問題的答案。
  • 儲存(Store):構建知識庫,將文字以向量的形式儲存,用於後期檢索。
  1. 檢索和答案生成
答案生成流程。

答案生成流程。

  • 檢索(Retrieve):當使用者問題輸入後,首先會利用embedding模型將其向量化,然後在知識庫中檢索與之相似度較高的若干段落,並對這些段落的相關性進行排序。
  • 生成(Generate):將這個可能包含答案,且相關性最高的Top K個檢索結果,包裝為Prompt輸入,導入LLM中,據此來生成問題所對應的答案。

關鍵步驟

在利用OpenVINO構建RAG系統過程中,有以下一些關鍵步驟:

1. 封裝Embedding模型類

由於在LangChain的chain pipeline會呼叫embedding模型類中的embed_documents和embed_query來分別對知識庫檔案和問題進行向量化,而他們最終都會呼叫encode函數來實現每個chunk具體的向量化實現,因此在自訂的embedding模型類中也需要實現這樣幾個關鍵方法,並透過OpenVINO進行推論任務的加速。

embedding模型推論示意。

embedding模型推論示意。

由於在RAG系統中的各個chunk之間的向量化任務往往沒有依賴關係,因此我們可以透過OpenVINO的AsyncInferQueue介面將這部分任務平行化,以提升整個embedding任務的輸送量。


for i, sentence in enumerate(sentences_sorted):
            inputs = {}
            features = self.tokenizer(
                sentence, padding=True, truncation=True, return_tensors='np')
            for key in features:
                inputs[key] = features[key]
            infer_queue.start_async(inputs, i)
        infer_queue.wait_all()
        all_embeddings = np.asarray(all_embeddings)

此外,從HuggingFace Transfomers資源庫匯出的embedding模型是不包含mean_pooling和正規化操作的,因此我們需要在取得模型推論結果後,再實現這部分的後處理任務,並將其作為callback function與AsyncInferQueue進行綁定。


def postprocess(request, userdata):
            embeddings = request.get_output_tensor(0).data
            embeddings = np.mean(embeddings, axis=1)
            if self.do_norm:
                embeddings = normalize(embeddings, 'l2')
            all_embeddings.extend(embeddings)
 
 
        infer_queue.set_callback(postprocess)

2. 封裝LLM模型類

由於LangChain已經可以支援HuggingFace的pipeline作為其LLM物件,因此這裡我們只要將OpenVINO的LLM推論任務封裝成一個HF的text generation pipeline即可。此外為了串流輸出答案(逐字列印),需要透過TextIteratorStreamer物件定義一個串流生成器。


streamer = TextIteratorStreamer(
    tok, timeout=30.0, skip_prompt=True, skip_special_tokens=True
)
generate_kwargs = dict(
    model=ov_model,
    tokenizer=tok,
    max_new_tokens=256,
    streamer=streamer,
    # temperature=1,
    # do_sample=True,
    # top_p=0.8,
    # top_k=20,
    # repetition_penalty=1.1,
)
if stop_tokens is not None:
    generate_kwargs["stopping_criteria"] = StoppingCriteriaList(stop_tokens)
   
pipe = pipeline("text-generation", **generate_kwargs)
llm = HuggingFacePipeline(pipeline=pipe)

3. 設計RAG prompt template

當完成檢索後,RAG會將相似度最高的檢索結果包裝為Prompt,讓LLM進行篩選與重構,因此我們需要為每個LLM設計一個RAG prompt template,用於在Prompt中區分這些檢索結果,而這部分的提示資訊我們又可以稱之為context上下文,以供LLM在生成答案時進行參考。以ChatGLM3為例,它的RAG prompt template可以是這樣的:


"prompt_template": f"""<|system|>
        {DEFAULT_RAG_PROMPT_CHINESE }"""
        + """
        <|user|>
        問題: {question}
        已知內容: {context}
        回答:
        <|assistant|>""",

其中:

  • {DEFAULT_RAG_PROMPT_CHINESE}為我們事先根據任務要求定義的系統提示詞。
  • {question}為使用者的問題。
  • {context}為Retriever檢索到的、可能包含問題答案的段落。

例如,假設我們的問題是「飛槳的四大優勢是什麼?」,對應從飛槳檔案中取得的Prompt輸入就是:

<|system|>

基於以下已知資訊,請簡潔並專業地回答用戶的問題。如果無法從中得到答案,請說 「根據已知資訊無法回答該問題」 或「沒有提供足夠的相關資訊」。不允許在答案中添加編造成分。另外,答案請使用中文。

<|user|>

問題: 飛槳的四大領先技術是什麼?

已知內容: ## 安裝

PaddlePaddle最新版本: v2.5

跟進PaddlePaddle最新特性請參考我們的版本說明

四大領先技術

開發便捷的產業級深度學習框架

飛槳深度學習框架採用基於程式設計邏輯的組網範式,對於普通開發者而言更容易上手,符合他們的開發習慣。同時支持聲明式和命令式程式設計,兼具開發的靈活性和高性能。網路結構自動設計,模型效果超越人類專家。

支援超大規模深度學習模型的訓練

飛槳突破了超大規模深度學習模型訓練技術,實現了支援千億特徵、萬億參數、數百節點的開源大規模訓練平臺,攻克了超大規模深度學習模型的線上學習難題,實現了萬億規模參數模型的即時更新。

查看詳情

支援多端多平台的高性能推論部署工具

<|assistant|>

4.創建RetrievalQA檢索

在文字分割這個任務中,LangChain支援了多種分割方式,例如按字元數的CharacterTextSplitter,針對Markdown檔案的MarkdownTextSplitter,以及利用遞迴方法的RecursiveCharacterTextSplitter,當然你也可以透過繼承TextSplitter父類來實現自訂的split_text方法,例如在中文檔案中,我們可以採用按每句話中的標點符號進行分割。


class ChineseTextSplitter(CharacterTextSplitter):
    def __init__(self, pdf: bool = False, **kwargs):
        super().__init__(**kwargs)
        self.pdf = pdf
 
 
    def split_text(self, text: str) -&gt; List[str]:
        if self.pdf:
            text = re.sub(r"\n{3,}", "\n", text)
            text = text.replace("\n\n", "")
        sent_sep_pattern = re.compile(
            '([﹒﹔﹖﹗.。!?]["’”」』]{0,2}|(?=["‘“「『]{1,2}|$))')  # del :;
        sent_list = []
        for ele in sent_sep_pattern.split(text):
            if sent_sep_pattern.match(ele) and sent_list:
                sent_list[-1] += ele
            elif ele:
                sent_list.append(ele)
        return sent_list

接下來我們需要載入預先設定的好的prompt template,創建rag_chain。

Chroma引擎檢索流程。

Chroma引擎檢索流程。

這裡我們使用Chroma作為搜尋引擎,在LangChain中,Chroma預設使用cosine distance作為向量相似度的評估方法, 同時可以透過調整db.as_retriever(search_type= “similarity_score_threshold”),或是db.as_retriever(search_type= “mmr”)來更改預設檢索策略,前者為帶閾值的相似度搜尋,後者為max_marginal_relevance演算法。當然Chroma也可以被替換為FAISS檢索引擎,使用方式也是相似的。

此外透過定義as_retriever 函數中的{“k”: vector_search_top_k},我們還可以改變檢索結果的回覆數量,有助於LLM取得更多有效資訊,但也會因此增加Prompt的長度,提高推論延遲,因此不建議將該數值設定太高。創建rag_chain的完整程式碼如下:


documents = load_single_document(doc.name)
 
 
    text_splitter = TEXT_SPLITERS[spliter_name](
        chunk_size=chunk_size, chunk_overlap=chunk_overlap
    )
 
 
    texts = text_splitter.split_documents(documents)
 
 
    db = Chroma.from_documents(texts, embedding)
    retriever = db.as_retriever(search_kwargs={"k": vector_search_top_k})
 
 
    global rag_chain
    prompt = PromptTemplate.from_template(
        llm_model_configuration["prompt_template"])
    chain_type_kwargs = {"prompt": prompt}
    rag_chain = RetrievalQA.from_chain_type(
        llm=llm,
        chain_type="stuff",
        retriever=retriever,
        chain_type_kwargs=chain_type_kwargs,
    )

5. 答案生成

創建以後的rag_chain物件可以透過rag_chain.run(question)來回應使用者的問題。將它和執行緒函數綁定後,就可以從LLM物件的streamer中獲取串流的文字輸出。


def infer(question):
        rag_chain.run(question)
        stream_complete.set()
 
 
    t1 = Thread(target=infer, args=(history[-1][0],))
    t1.start()
    partial_text = ""
    for new_text in streamer:
        partial_text = text_processor(partial_text, new_text)
        history[-1][1] = partial_text
        yield history

本範例的完整程式碼網址如下:https://github.com/openvinotoolkit/openvino_notebooks/blob/main/notebooks/254-llm-chatbot/254-rag-chatbot.ipyn

最終效果

最終效果如下圖所示,當使用者上傳了自己的檔案後,點擊Build Retriever便可以創建知識檢索資料庫,同時也可以根據自己檔案的特性,透過調整檢索資料庫的配置參數來實現更高效率的搜尋。當完成檢索資料庫創建後,就可以在對話方塊中與LLM進行問答互動了。

以RAG為基礎的問答系統效果。

以RAG為基礎的問答系統效果。

總結

在醫療、工業等領域,產業知識庫的構建已經成為了一個普遍需求,透過LLM與OpenVINO的加持,我們可以讓使用者對於知識庫的查詢變得更精準、高效率,帶來更友善的互動體驗。

 

參考資料

 

訂閱MakerPRO知識充電報

與40000位開發者一同掌握科技創新的技術資訊!

Author: 楊亦誠

現任英特爾 AI 軟體工程師

Share This Post On
468 ad

Submit a Comment

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