檢索器是問答系統的第一道關卡,它負責從大量檔案中找出與使用者問題最相關的內容。Haystack 提供了多種檢索器,包括根據詞頻的稀疏檢索器和根據語義的密集檢索器。

稀疏檢索器:根據詞頻的快速搜尋

稀疏檢索器是根據詞頻的傳統技術,如 TF-IDF(詞頻-逆檔案頻率)和 BM25(最佳比對 25)。這些方法的核心思想是:

  1. 計算詞在檔案中的頻率(TF)
  2. 計算詞在整個檔案集中的罕見程度(IDF)
  3. 結合這兩個指標評估詞的重要性

這種方法的優點是計算速度快、資源需求低,但缺點是無法理解詞的語義關係,完全依賴於詞彙重疊進行比對。

密集檢索器:理解語義的深度搜尋

密集檢索器使用神經網路(如 BERT)將文字轉換為密集向量(嵌入),這些向量能夠捕捉文字的語義訊息。透過計算問題向量與檔案向量之間的相似度,系統可以找出語義上相關的檔案,即使它們沒有分享相同的詞彙。

例如,當使用者詢問「這款相機的電池續航如何?」時,密集檢索器能夠找出包含「電量可以持續使用8小時」這樣表述的評論,即使評論中沒有出現「電池續航」這個片語。

閲讀器:從相關檔案中提取精確答案

一旦檢索器找到了相關檔案,閲讀器就會接管並從這些檔案中提取精確的答案。閲讀器通常是一個經過微調的語言模型,專門用於閲讀理解任務。它能夠理解問題的意圖,並從文字中找出最相關的片段作為答案。

實際應用中的系統整合

在實際應用中,檢索器和閲讀器透過管道(Pipeline)元件緊密整合。管道允許我們定義查詢流程,例如:

  1. 使用者提出問題
  2. 檢索器找出最相關的 5-10 個檔案
  3. 閲讀器從這些檔案中提取答案
  4. 系統對答案進行後處理和排序
  5. 將最終答案呈現給使用者

這種流程大減少了處理時間,同時保持了答案的品質。以我們之前的例子來説,如果檢索器能夠從 30 條評論中篩選出最相關的 3 條,那麼處理時間將從 3 秒減少到 300 毫秒,這對使用者經驗是一個巨大的提升。

完整問答系統的實作流程

在接下來的部分,我將帶你一步實作一個完整的問答系統,包括:

  1. 設定稀疏檢索器和密集檢索器
  2. 設定閲讀器模型
  3. 建立問答管道
  4. 評估系統效能
  5. 最佳化和調整系統

透過這個實作過程,你將深入瞭解問答系統的各個元件如何協同工作,以及如何根據具體需求調整系統設定。

Haystack 的模組化設計使我們能夠輕鬆替換或升級系統的各個元件,例如,我們可以從簡單的 TF-IDF 檢索器開始,然後逐步升級到更先進的密集檢索器,而不需要重寫整個系統。

這種靈活性使 Haystack 成為開發問答系統的理想選擇,無論是原型開發還是生產佈署。

檢索與閲讀的深度整合

檢索和閲讀這兩個元件的深度整合是現代問答系統的關鍵。透過精心設計的管道,我們可以實作複雜的查詢流程,例如:

  1. 多階段檢索:先使用快速的稀疏檢索器取得候選檔案,再使用精確的密集檢索器進行精細排序
  2. 答案合成:從多個檔案片段中合成一個連貫的答案
  3. 不確定性處理:當系統無法找到確切答案時,提供適當的回應

這些高階功能使問答系統能夠處理各種複雜的查詢場景,提供更自然、更有用的使用者經驗。

在下一部分,我們將探討如何使用 Haystack 實作這些功能,並構建一個強大的產品評論問答系統。

使用Elasticsearch建立檢索系統

在上一節中我們設定了Elasticsearch檔案儲存,現在讓我們將它與Haystack的檢索器(retriever)結合使用。Haystack的靈活性允許我們使用任何檢索器,但這裡我們將從根據BM25演算法的稀疏檢索器開始。

BM25檢索器原理解析

BM25(Best Match 25)是經典TF-IDF(詞頻-逆檔案頻率)演算法的改進版本。它將問題和上下文表示為稀疏向量,可以在Elasticsearch上高效搜尋。BM25的主要優勢在於:

  1. 它能夠快速飽和詞頻(TF)值
  2. 對檔案長度進行標準化,使短檔案比長檔案更受青睞
  3. 更精確地度量比對文字與搜尋查詢的相關性

在Haystack中,ElasticsearchRetriever預設使用BM25演算法,讓我們初始化這個類別:

from haystack.retriever.sparse import ElasticsearchRetriever
es_retriever = ElasticsearchRetriever(document_store=document_store)

這段程式碼匯入並初始化了ElasticsearchRetriever,它使用我們之前設定的document_store作為搜尋基礎。這個檢索器將負責從我們的商品評論資料集中找出與使用者查詢最相關的檔案。

針對單一產品進行查詢

對於根據評論的問答系統,限制查詢範圍至單一產品非常重要。如果不設定產品過濾器,檢索器可能會找出與使用者查詢無關的產品評論。例如,詢問「相機品質好嗎?」而不指定產品時,系統可能回傳手機評論,但使用者可能是在詢問特定筆記型電腦的相機。

讓我們使用檢索器的retrieve()方法來詢問某款Amazon Fire平板電腦電腦是否適合閲讀:

item_id = "B0074BW614"
query = "Is it good for reading?"
retrieved_docs = es_retriever.retrieve(
    query=query, 
    top_k=3, 
    filters={"item_id":[item_id], "split":["train"]}
)

在這個查詢中,我們:

  • 指定了產品ID(item_id)作為過濾條件,確保只檢索該特定產品的評論
  • 設定了top_k=3,表示我們只想要最相關的3個檔案
  • 增加了split過濾器,確保只從訓練集中檢索檔案
  • 使用了明確的問題「Is it good for reading?」(適合閲讀嗎?)

檢索器回傳的每個元素都是Haystack的Document物件,包含檔案文字、相關性分數和其他中繼資料。

檢索結果分析

讓我們看檢索到的第一個檔案:

print(retrieved_docs[0])

輸出結果包含:

  • 評論文字:描述使用者如何從Kindle轉向Fire平板電腦電腦,主要用於閲讀書籍和觀看電影
  • 相關性分數:6.243799(分數越高表示比對度越好)
  • 檔案中繼資料:包含item_id、question_id和split等訊息

Elasticsearch在底層依賴Lucene進行索引和搜尋,使用Lucene的實用評分函式。這個評分過程包含兩個主要步驟:

  1. 應用布林測試過濾候選檔案(檔案是否比對查詢?)
  2. 應用相似度指標,根據將檔案和查詢表示為向量來計算

初始化Reader元件

現在我們已經能夠檢索相關檔案,接下來需要從這些檔案中提取答案。這就是Reader元件的作用。Haystack提供了兩種主要的Reader型別:

Reader型別比較

  1. FARMReader

    • 根據deepset的FARM框架,用於微調和佈署transformer模型
    • 與使用Transformers訓練的模型相容
    • 可以直接從Hugging Face Hub載入模型
  2. TransformersReader

    • 根據Transformers函式庫的QA pipeline
    • 適合僅執行推理的場景

雖然兩種Reader處理模型權重的方式相同,但它們在預測轉換為答案的方式上存在一些差異:

  • 預測分數處理:Transformers使用softmax對每個段落的開始和結束logits進行標準化,這意味著只能比較同一段落中的答案分數。而FARM不對logits進行標準化,因此可以更容易地比較不同段落的答案。

  • 重複答案處理:TransformersReader有時會預測相同的答案兩次,但分數不同。這在長文字中可能發生,如果答案跨越兩個重疊視窗。在FARM中,這些重複項會被移除。

由於我們稍後將微調Reader,所以選擇使用FARMReader:

from haystack.reader.farm import FARMReader

model_ckpt = "deepset/minilm-uncased-squad2"
max_seq_length, doc_stride = 384, 128
reader = FARMReader(
    model_name_or_path=model_ckpt, 
    progress_bar=False,
    max_seq_len=max_seq_length, 
    doc_stride=doc_stride,
    return_no_answer=True
)

這段程式碼:

  • 匯入並初始化了FARMReader
  • 使用預訓練的MiniLM模型(在SQuAD 2.0資料集上微調過)
  • 設定了最大序列長度為384,檔案步長為128(與MiniLM論文中的值一致)
  • 啟用了return_no_answer選項,允許模型在找不到答案時回傳「無答案」

FARMReader中的滑動視窗行為由max_seq_len和doc_stride引數控制,與我們之前看到的分詞器引數相同。

測試Reader元件

讓我們測試Reader是否能正確提取答案:

print(reader.predict_on_texts(question=question, texts=[context], top_k=1))

輸出結果顯示Reader能夠從上下文中提取出「6000 hours」作為對「How much music can this hold?」問題的答案,並給出了相關的置信度分數。

整合Pipeline

Haystack提供了Pipeline抽象,允許我們將檢索器、閲讀器和其他元件組合為可輕鬆自定義的圖。對於我們的問答系統,我們將使用ExtractiveQAPipeline,它接受一個檢索器-閲讀器對作為引數:

from haystack.pipeline import ExtractiveQAPipeline
pipe = ExtractiveQAPipeline(reader, es_retriever)

這段程式碼建立了一個提取式問答pipeline,將我們之前初始化的reader和es_retriever組合在一起。這個pipeline將處理從接收使用者查詢到回傳最終答案的完整流程。

每個Pipeline都有一個run()方法,用於指定查詢流應如何執行。對於ExtractiveQAPipeline,我們需要傳入查詢、檢索檔案數量(top_k_retriever)以及從這些檔案中提取的答案數量(top_k_reader)。我們還需要使用filters引數指定產品ID過濾器:

n_answers = 3
preds = pipe.run(
    query=query, 
    top_k_retriever=3, 
    top_k_reader=n_answers,
    filters={"item_id": [item_id], "split":["train"]}
)

print(f"Question: {preds['query']} \n")

for idx in range(n_answers):
    print(f"Answer {idx+1}: {preds['answers'][idx]['answer']}")
    print(f"Review snippet: ...{preds['answers'][idx]['context']}...")
    print("\n\n")

這段程式碼:

  1. 設定我們想要的答案數量為3
  2. 執行pipeline,檢索最相關的3個檔案,並從中提取3個最佳答案
  3. 列印問題和每個答案,以及答案所在的評論片段

這樣,我們就完成了一個基本的提取式問答系統,可以從產品評論中檢索相關訊息並提取答案。系統使用了BM25演算法進行相關檔案檢索,並使用MiniLM模型從這些檔案中提取精確答案。

這種pipeline架構的優勢在於它的模組化設計,我們可以輕鬆替換或調整各個元件,比如使用不同的檢索演算法或閲讀理解模型,而不需要重寫整個系統。這種靈活性使得系統能夠根據具體應用場景進行最佳化。

開發完整問答系統:從理論到實踐

在建構問答系統的過程中,我們往往關注於如何讓系統精確回答使用者的問題。在前面的基礎上,我們已經成功建立了一個能夠處理Amazon產品評論的問答系統雛形。本文將探討如何評估與最佳化這個系統,特別是檢索器(retriever)的效能,因為它是整個問答管道的關鍵環節。

問答系統的初步成果

讓我們先回顧一下我們已經建立的問答系統。這個系統能夠從Amazon產品評論中找出相關資訊並回答使用者的問題:

# 問答系統執行範例
question = "How is the Kindle Fire for reading?"

# 執行問答管道
prediction = pipe.run(
    query=question, 
    params={"Retriever": {"top_k": 3}}
)

# 檢視檢索到的檔案片段
for i, doc in enumerate(prediction["documents"]):
    print(f"Answer {i+1}: {doc.meta['answer']}")
    print(f"Review snippet: {doc.content[:120]}...")

執行這段程式碼後,系統會回傳三個可能的答案:

  1. “I mainly use it for book reading”
  2. “the larger screen compared to the Kindle makes for easier reading”
  3. “it is great for reading books when no light is available”

從結果來看,系統確實找到了相關的評論片段,尤其是第二和第三個答案更直接回應了問題。不過,要讓系統表現更佳,我們需要量化評估檢索器和閲讀器的效能。

最佳化問答管道的關鍵指標

檢索器的重要性與評估

在問答系統中,無論閲讀理解模型有多強大,如果檢索器無法找到包含答案的檔案,整個系統都無法提供正確答案。換句話説,檢索器的效能為整個問答系統設定了上限,因此優先確保檢索器的品質至關重要。

檢索器評估指標:Recall

評估檢索器的常用指標是召回率(recall),它衡量檢索器能夠找到多少比例的相關檔案。在問答情境中,“相關"意味著檔案中包含答案。我們可以透過計算檢索器在前k個結果中包含答案的比例來計算召回率。

在Haystack框架中,有兩種方式評估檢索器:

  1. 使用檢索器內建的eval()方法:適用於開放域和封閉域問答,但不適合像SubjQA這樣每個檔案與特定產品配對的資料集。

  2. 建立自訂Pipeline,結合檢索器與EvalRetriever類別:這種方法允許實施自訂指標和查詢流程。

由於我們需要針對每個產品評估召回率,然後彙總所有產品的結果,我們選擇第二種方法。

建構評估管道

Haystack中的管道是由節點組成的圖形,每個節點代表一個接收輸入並產生輸出的類別:

class PipelineNode:
    def __init__(self):
        self.outgoing_edges = 1
        
    def run(self, **kwargs):
        # 處理輸入並生成輸出
        return (outputs, "outgoing_edge_name")

我們需要一個評估檢索器的節點,因此使用EvalRetriever類別,其run()方法會追蹤哪些檔案包含符合標準答案的答案。

from haystack.pipeline import Pipeline
from haystack.eval import EvalDocuments

class EvalRetrieverPipeline:
    def __init__(self, retriever):
        self.retriever = retriever
        self.eval_retriever = EvalDocuments()
        
        pipe = Pipeline()
        pipe.add_node(component=self.retriever, name="ESRetriever", 
                     inputs=["Query"])
        pipe.add_node(component=self.eval_retriever, name="EvalRetriever", 
                     inputs=["ESRetriever"])
        self.pipeline = pipe

# 建立評估管道
pipe = EvalRetrieverPipeline(es_retriever)

準備評估資料

要評估檢索器,我們需要傳入查詢及其對應的答案。Haystack提供了Label物件來標準化表示答案範圍及其中繼資料。我們將建立Label物件列表,遍歷測試集中的每個問題,並提取比對的答案和額外中繼資料:

from haystack import Label

labels = []
for i, row in dfs["test"].iterrows():
    # 用於檢索器過濾的中繼資料
    meta = {"item_id": row["title"], "question_id": row["id"]}
    
    # 為有答案的問題建立標籤
    if len(row["answers.text"]):
        for answer in row["answers.text"]:
            label = Label(
                question=row["question"], answer=answer, id=i, origin=row["id"],
                meta=meta, is_correct_answer=True, is_correct_document=True,
                no_answer=False)
            labels.append(label)
    # 為無答案的問題建立標籤
    else:
        label = Label(
            question=row["question"], answer="", id=i, origin=row["id"],
            meta=meta, is_correct_answer=True, is_correct_document=True,
            no_answer=True)
        labels.append(label)

這些標籤包含問答對、問題ID和產品ID等資訊。我們將這些標籤寫入Elasticsearch的標籤索引:

document_store.write_labels(labels, index="label")
print(f"載入了 {document_store.get_label_count(index='label')} 個問答對")
# 輸出: 載入了 358 個問答對

彙總標籤

接下來,我們需要建立問題ID與對應答案的對映關係。使用檔案儲存的get_all_labels_aggregated()方法,可以彙總與唯一ID相關聯的所有問答對:

labels_agg = document_store.get_all_labels_aggregated(
    index="label",
    open_domain=True,
    aggregate_by_meta=["item_id"]
)
print(len(labels_agg))  # 輸出: 330

每個彙總標籤中的multiple_answers欄位包含了與特定問題關聯的所有答案:

# 檢視其中一個彙總標籤
print(labels_agg[109])

這段程式碼展示瞭如何從檔案儲存中檢索彙總標籤。使用get_all_labels_aggregated()方法時,我們設定了三個引數:index="label"指定了要從哪個索引檢索標籤,open_domain=True表示這是開放域問答任務,而aggregate_by_meta=["item_id"]告訴系統按照產品ID進行彙總。

當印出彙總標籤時,我們可以看到它包含了問題文字、多個可能的答案、問題來源ID以及中繼資料(如產品ID)。這種結構使我們能夠輕鬆處理一個問題可能有多個正確答案的情況。

執行評估管道

有了彙總標籤,我們可以定義一個函式,將每個產品相關的問答對餵給評估管道,並在管道物件中追蹤正確檢索的情況:

def run_pipeline(pipeline, top_k_retriever=10, top_k_reader=4):
    for l in labels_agg:
        _ = pipeline.pipeline.run(
            query=l.question,
            top_k_retriever=top_k_retriever,
            top_k_reader=top_k_reader,
            top_k_eval_documents=top_k_retriever,
            labels=l,
            filters={"item_id": [l.meta["item_id"]], "split": ["test"]})

# 執行評估
run_pipeline(pipe, top_k_retriever=3)
print(f"Recall@3: {pipe.eval_retriever.recall:.2f}")
# 輸出: Recall@3: 0.95

這個函式遍歷所有彙總標籤,對每個問題執行檢索管道,並設定幾個關鍵引數:top_k_retriever指定檢索器應回傳的檔案數量,top_k_reader指定閲讀器應回傳的答案數量,top_k_eval_documents設定評估時要考慮的檔案數量,labels提供了問題的標準答案,而filters則確保只檢索與當前產品相關與屬於測試集的檔案。

當我們用top_k_retriever=3執行評估時,獲得了0.95的召回率,這表示檢索器在檢索前3個檔案時,能找到95%的問題答案,這是相當不錯的結果。

評估不同的k值

為了更全面地評估檢索器效能,我們可以測試不同的k值,並觀察召回率的變化:

def evaluate_retriever(retriever, topk_values = [1,3,5,10,20]):
    topk_results = {}
    for topk in topk_values:
        # 建立管道
        p = EvalRetrieverPipeline(retriever)
        # 遍歷測試集中的每個問答對
        run_pipeline(p, top_k_retriever=topk)
        # 取得指標
        topk_results[topk] = {"recall": p.eval_retriever.recall}
    return pd.DataFrame.from_dict(topk_results, orient="index")

# 評估BM25檢索器
es_topk_df = evaluate_retriever(es_retriever)

這個函式透過遍歷不同的k值來評估檢索器。對於每個k值,它建立一個新的評估管道,執行評估,並記錄召回率。結果以DataFrame形式回傳,方便後續分析和繪圖。

這種評估方法讓我們能夠瞭解檢索器在不同k值下的表現,從而找到效能和速度之間的最佳平衡點。一般來説,增加k值會提高召回率,但也會增加閲讀器的處理負擔,降低整個管道的速度。

視覺化評估結果

為了直觀地比較不同k值的效能,我們可以將結果繪製成

def plot_retriever_eval(dfs, retriever_names):
    fig, ax = plt.subplots()
    for df, retriever_name in zip(dfs, retriever_names):
        df.plot(y="recall", ax=ax, label=retriever_name)
    plt.xticks(df.index)
    plt.ylabel("Top-k Recall")
    plt.xlabel("k")
    plt.show()

plot_retriever_eval([es_topk_df], ["BM25"])

這個函式接收多個檢索器的評估結果和對應的名稱,然後生成一個折線圖來比較它們的召回率。X軸是k值,Y軸是召回率。這種視覺化方式讓我們能夠清楚地看到召回率如何隨著k值增加而提高,並且可以比較不同檢索器的效能。

檢索器選擇的策略思考

在實際應用中,選擇合適的k值涉及多方面的考量:

  1. 效能需求:更高的召回率通常需要更大的k值,但這也意味著閲讀器需要處理更多檔案。

  2. 速度限制:在高流量應用中,較小的k值可能更為實用,因為它減少了閲讀器的工作量,加快了回應時間。

  3. 資源限制:較大的k值需要更多的計算資源,特別是當閲讀器使用大模型語言時。

  4. 應用場景:在一些關鍵應用中,可能需要更高的召回率來確保不會錯過重要資訊,即使這意味著更慢的回應時間。

在我的實踐中,當使用BM25這類別傳統檢索器時,k值設為3-5通常能在效能和速度之間取得不錯的平衡。如果使用更先進的密集檢索器(如根據BERT的模型),則可能可以使用更小的k值達到相同的召回率。

問答系統的進階最佳化方向

除了調整k值外,還有幾種方式可以進一步最佳化問答系統:

  1. 使用密集檢索器:相比傳統的稀疏檢索方法(如BM25),根據神經網路的密集檢索器通常能更好地捕捉語義關係,提高檢索效能。

  2. 混合檢索策略:結合稀疏和密集檢索的優點,可以在保持高召回率的同時提高效率。

  3. 查詢擴充套件:透過增加相關詞彙或使用同義詞擴充套件原始查詢,提高檢索相關檔案的機會。

  4. 檔案預處理最佳化:改進檔案切分和索引策略,確保重要資訊不會在切分過程中丟失。

  5. 閲讀器最佳化:使用更先進的閲讀理解模型,或者針對特定領域微調現有模型。

  6. 後處理排序:在檢索器回傳結果後,使用額外的排序機制進一步提高最終答案的品質。

建立完整問答系統的實用建議

根據我在開發問答系統的經驗,以下是一些實用建議:

  1. 先最佳化檢索器:檢索器是整個系統的瓶頸,優先確保它能找到包含答案的檔案。

  2. 使用多種評估指標:除了召回率,也考慮平均精確度(mAP)等指標,它們能反映檢索器將正確答案排在前列的能力。

  3. 針對領域進行最佳化:不同領域(如產品評論、學術文獻、法律檔案)可能需要不同的檢索策略和引數設定。

  4. 考慮使用者經驗:在實際應用中,回應速度和答案品質同樣重要,找到適合使用者需求的平衡點。

  5. 持續監控與改進:問答系統不是一勞永逸的,需要根據使用者反饋和新資料持續最佳化。

透過這些策略和技術,我們可以建立一個既高效又精準的問答系統,為使用者提供有價值的資訊支援。

問答系統的建構是一個不斷最佳化的過程。從檢索器的選擇與調整,到評估指標的應用,每一步都對最終系統的效能產生重要影響。透過深入理解每個元件的作用及其評估方法,我們能夠更有針對性地改進系統,開發出真正實用的問答解決方案。

從稀疏檢索到密集向量:問答系統檢索技術的演進

在構建高效問答系統的過程中,檢索器的效能直接影響整體系統的表現。從前面的分析可以看出,當稀疏檢索器回傳約10個檔案時,我們幾乎能獲得完美的召回率。然而,這種方法還有最佳化空間,特別是在減少需要處理的檔案數量方面。如果能在較小的k值下獲得較高的召回率,就能將更少的檔案傳遞給讀取器,從而降低整個問答流程的延遲。

稀疏檢索的侷限與密集向量的機遇

傳統的稀疏檢索方法(如BM25)的一個著名限制是:當使用者查詢包含的術語與檔案中的術語不完全比對時,相關檔案可能無法被檢索到。這種根據詞比對的方法難以捕捉語義關係,導致在某些情況下召回率受限。

密集向量檢索技術提供了一種有前景的替代方案。這種技術使用向量表示問題和檔案,其中當前最先進的架構被稱為密集段落檢索(Dense Passage Retrieval,DPR)。

密集段落檢索的工作原理

DPR的核心思想是使用兩個BERT模型作為問題和段落的編碼器。這些編碼器將輸入文字對映到d維向量空間中,具體來説是使用[CLS]標記的向量表示。這種雙編碼器架構能夠計算檔案與查詢之間的相關性,從而實作更精確的檢索。

DPR架構的核心在於使用兩個獨立的BERT編碼器分別處理問題和檔案。這種設計允許系統分別最佳化問題理解和檔案理解,然後透過計算兩個向量的相似度來確設定檔案與問題的相關性。與傳統的詞比對方法不同,這種方式能夠捕捉語義層面的關聯,即使問題和檔案使用不同的詞彙表達相同的概念。

在Haystack中實作DPR檢索器

在Haystack框架中,我們可以類別似於BM25的方式初始化DPR檢索器。除了指設定檔案儲存之外,還需要選擇問題和段落的BERT編碼器。這些編碼器透過提供相關(正面)和不相關(負面)的問題-段落對進行訓練,目標是學習使相關問題-段落對具有更高的相似度。

以下是在Haystack中初始化DPR檢索器的程式碼:

from haystack.retriever.dense import DensePassageRetriever

dpr_retriever = DensePassageRetriever(
    document_store=document_store,
    query_embedding_model="facebook/dpr-question_encoder-single-nq-base",
    passage_embedding_model="facebook/dpr-ctx_encoder-single-nq-base",
    embed_title=False
)

這段程式碼初始化了一個DPR檢索器,使用了Facebook預訓練的兩個編碼器模型:一個用於問題編碼,另一個用於段落編碼。這些編碼器已在Natural Questions(NQ)語料函式庫上進行了微調,使其能更好地處理問答任務。embed_title=False引數表示不將檔案標題(即item_id)與內容連線起來,因為在這個特定場景中,標題不提供額外訊息(我們已經按產品進行過濾)。

初始化檢索器後,需要更新Elasticsearch索引中所有檔案的嵌入表示:

document_store.update_embeddings(retriever=dpr_retriever)

這行程式碼會遍歷檔案儲存中的所有檔案,使用DPR的段落編碼器為每個檔案生成向量表示,並將這些向量儲存在檔案儲存中。這個過程可能比較耗時,但只需執行一次。之後,所有查詢都可以使用這些預計算的嵌入進行快速相似度搜尋。

評估DPR檢索器效能

我們可以使用與BM25相同的方式評估DPR檢索器,比較不同k值下的召回率:

dpr_topk_df = evaluate_retriever(dpr_retriever)
plot_retriever_eval([es_topk_df, dpr_topk_df], ["BM25", "DPR"])

從評估結果來看,DPR並沒有比BM25提供更高的召回率,在k=3左右就達到了飽和。這一結果有些出人意料,因為在許多基準測試中,DPR通常優於BM25。這可能與我們的特定資料集特性有關,或者預訓練的DPR模型與我們的領域不夠比對。

這個評估結果表明,在這個特定任務中,根據詞比對的BM25和根據語義的DPR表現相當。這可能是因為我們的問題和檔案在詞彙上已經有較高重疊,使得BM25能夠有效工作。而DPR雖然能捕捉語義關係,但若未在目標領域微調,其優勢可能不明顯。

DPR效能最佳化技巧

值得注意的是,可以透過以下方式提高DPR檢索器的效能:

  1. 使用Facebook的FAISS函式庫作為檔案儲存,加速嵌入的相似度搜尋
  2. 在目標領域資料上微調DPR模型,提高領域適應性

如果對如何微調DPR感興趣,可以參考Haystack提供的教程。

讀取器評估:深入理解問答系統效能

在探索完檢索器的評估後,讓我們轉向讀取器的評估。在抽取式問答中,主要使用兩種指標評估讀取器效能:

精確比對(Exact Match,EM)

EM是一個二元指標:

  • 如果預測答案與標準答案的字元完全比對,則EM=1
  • 否則EM=0
  • 如果預期沒有答案,但模型預測了任何文字,則EM=0

F1分數

F1分數測量精確率和召回率的調和平均值。在問答系統中,F1分數在標記級別計算:

  • 將預測和標準答案標準化(去除標點、修復空格、轉為小寫)
  • 將標準化字元串標記為詞袋
  • 在標記級別計算F1分數

讓我們透過一個簡單的例子來看這些指標如何工作:

from farm.evaluation.squad_evaluation import compute_f1, compute_exact

pred = "about 6000 hours"
label = "6000 hours"
print(f"EM: {compute_exact(label, pred)}")
print(f"F1: {compute_f1(label, pred)}")

輸出:

EM: 0
F1: 0.8

這個例子顯示了EM和F1指標的差異。預測答案"about 6000 hours"與標準答案"6000 hours"相比,多了一個"about"詞。由於不是完全比對,EM為0;但F1分數為0.8,表明預測答案與標準答案有較高的重疊。這説明EM是一個非常嚴格的指標,而F1更為寬容,能反映部分正確的情況。

從這個簡單的例子可以看出,EM是一個比F1分數嚴格得多的指標:向預測增加一個標記就會使EM變為零。另一方面,F1分數可能無法捕捉真正不正確的答案。例如,如果我們的預測答案是"about 6000 dollars”:

pred = "about 6000 dollars"
print(f"EM: {compute_exact(label, pred)}")
print(f"F1: {compute_f1(label, pred)}")

輸出:

EM: 0
F1: 0.4

這個例子進一步説明瞭兩個指標的特性。預測"about 6000 dollars"與標準答案"6000 hours"在語義上完全不同(一個是時間,一個是金錢),但F1分數仍為0.4,因為它們分享"6000"這個標記。這表明僅依靠F1分數可能會誤導評估,因為它可能高估模型效能,特別是當預測答案包含正確標記但語義不同時。

因此,僅依靠F1分數可能會產生誤導,同時跟蹤兩個指標是平衡低估(EM)和高估(F1分數)模型效能的好策略。

多答案評估方法

在實際應用中,每個問題通常有多個有效答案。因此,這些指標會為評估集中的每個問題-答案對計算,並選擇所有可能答案中的最佳分數。然後透過平均每個問題-答案對的個別分數來獲得模型的整體EM和F1分數。

讀取器評估實作

為了評估讀取器,我們將建立一個具有兩個節點的新管道:一個讀取器節點和一個評估讀取器的節點。我們將使用EvalReader類別,該類別接收讀取器的預測並計算相應的EM和F1分數。為了與SQuAD評估進行比較,我們將使用top_1_em和top_1_f1指標:

from haystack.eval import EvalAnswers

def evaluate_reader(reader):
    score_keys = ['top_1_em', 'top_1_f1']
    eval_reader = EvalAnswers(skip_incorrect_retrieval=False)
    pipe = Pipeline()
    pipe.add_node(component=reader, name="QAReader", inputs=["Query"])
    pipe.add_node(component=eval_reader, name="EvalReader", inputs=["QAReader"])
    
    for l in labels_agg:
        doc = document_store.query(l.question, filters={"question_id":[l.origin]})
        _ = pipe.run(query=l.question, documents=doc, labels=l)
    
    return {k:v for k,v in eval_reader.__dict__.items() if k in score_keys}

reader_eval = {}
reader_eval["Fine-tune on SQuAD"] = evaluate_reader(reader)

這段程式碼定義了一個函式來評估讀取器效能。它建立了一個包含讀取器和評估元件的管道,然後對每個標記的問題執行此管道。skip_incorrect_retrieval=False引數確保檢索器始終將上下文傳遞給讀取器(類別似SQuAD評估方式)。函式回傳讀取器的EM和F1分數。

執行每個問題後,我們可以繪製分數:

def plot_reader_eval(reader_eval):
    fig, ax = plt.subplots()
    df = pd.DataFrame.from_dict(reader_eval)
    df.plot(kind="bar", ylabel="Score", rot=0, ax=ax)
    ax.set_xticklabels(["EM", "F1"])
    plt.legend(loc='upper left')
    plt.show()

plot_reader_eval(reader_eval)

評估結果分析

從評估結果可以看出,在SubjQA資料集上微調的模型效能明顯低於在SQuAD 2.0上的表現。在SQuAD 2.0上,MiniLM模型能達到76.1的EM和79.5的F1分數,但在我們的資料集上表現較差。

這種效能下降有幾個主要原因:

  1. 客戶評論與SQuAD 2.0資料集生成的Wikipedia文章差異較大,使用的語言往往更加非正式
  2. 我們資料集的主觀性較強,問題和答案與Wikipedia中的事實訊息不同

領域適應:提升特定場景下的問答系統效能

雖然在SQuAD上微調的模型通常能很好地泛化到其他領域,但我們看到對於SubjQA,模型的EM和F1分數比SQuAD差很多。這種泛化失敗在其他抽取式問答資料集中也有觀察到,被理解為Transformer模型特別善於過度擬合SQuAD的證據。

領域適應方法

改進讀取器的最直接方法是在SubjQA訓練集上進一步微調MiniLM模型。FARMReader有一個專為此目的設計的train()方法,該方法期望資料採用SQuAD JSON格式,其中包含所有問題-答案對。

這種領域適應技術能夠顯著提高模型在特定領域的表現。透過在目標領域資料上微調,模型可以學習該領域特有的語言模式、術語和知識結構,從而更好地回答該領域的問題。

領域適應的重要性

領域適應在實際應用中尤為重要,因為預訓練模型通常在通用語料函式庫上訓練,可能無法很好地處理特定領域的語言和知識。例如:

  1. 醫療領域:醫學術語和概念與日常語言差異較大
  2. 法律領域:法律文字有特定的結構和表達方式
  3. 技術支援:產品特定術語和使用者描述問題的方式可能非常獨特

在這些情況下,即使是在SQuAD等大型資料集上表現出色的模型,如果沒有進行領域適應,在特定領域的表現也可能不盡人意。

領域適應的實施策略

實施領域適應時,有幾種策略可以考慮:

  1. 直接微調:在目標領域資料上直接微調預訓練模型
  2. 漸進式微調:先在大型通用資料集(如SQuAD)上微調,再在目標領域資料上微調
  3. 混合訓練:將目標領域資料與通用資料集混合,一起進行微調
  4. 資料增強:使用生成技術建立更多目標領域的訓練資料

根據目標領域資料的可用性和特性,可以選擇最適合的策略。

問答系統效能最佳化的綜合思考

在構建高效問答系統時,我們需要平衡多個因素:

  1. 檢索技術選擇:稀疏檢索(如BM25)簡單高效,適用於詞比對度高的場景;密集向量檢索(如DPR)能捕捉語義關係,但需要更多計算資源和領域適應

  2. 評估指標理解:EM和F1分數提供了不同角度的效能評估,需要根據應用場景權衡哪個更重要

  3. 領域適應重要性:在特定領域應用時,領域適應往往是提升效能的關鍵步驟

  4. 系統整體最佳化:檢索器和讀取器的效能相互影響,需要整體考慮和最佳化

對於實際應用,我建議採用混合策略:先使用高效的稀疏檢索方法取得候選檔案,然後在特定領域資料上微調讀取器模型。這種方法既保證了系統的回應速度,又能提供高品質的答案。

在問答系統不斷發展的今天,瞭解各種技術的優缺點並根據具體應用場景做出適當選擇變得尤為重要。無論是選擇檢索技術還是評估模型效能,都應該以實際應用需求為導向,而非僅追求某個特定指標的最高分數。

理解與轉換問答資料格式

問答系統的核心在於正確處理資料結構。在建立高品質問答系統時,我首先會關注資料格式的轉換與處理。SQuAD(Stanford Question Answering Dataset)格式是當前問答系統的標準之一,它使用特定的JSON結構組織問題、答案和上下文。

SQuAD格式解析

在處理問答資料時,瞭解SQuAD格式的結構非常重要。它主要包含以下層級:

  • 頂層的data陣列,包含多個文章
  • 每篇文章包含titleparagraphs
  • paragraphs中每個元素包含context文字和qas問答對陣列
  • 每個問答對包含問題ID、問題文字、答案陣列和是否可回答的標記

這種結構雖然複雜,但能有效組織大量問答資料,尤其適合處理產品評論等領域特定內容。

將自定義資料轉換為SQuAD格式

要將自定義資料轉換為SQuAD格式,需要構建正確的JSON結構。以下是我設計的一個函式,用於建立SQuAD格式中的paragraphs陣列:

def create_paragraphs(df):
    paragraphs = []
    id2context = dict(zip(df["review_id"], df["context"]))
    for review_id, review in id2context.items():
        qas = []
        # 篩選特定上下文的所有問答對
        review_df = df.query(f"review_id == '{review_id}'")
        id2question = dict(zip(review_df["id"], review_df["question"]))
        
        # 構建qas陣列
        for qid, question in id2question.items():
            # 篩選單個問題ID
            question_df = df.query(f"id == '{qid}'").to_dict(orient="list")
            ans_start_idxs = question_df["answers.answer_start"][0].tolist()
            ans_text = question_df["answers.text"][0].tolist()
            
            # 填充可回答問題
            if len(ans_start_idxs):
                answers = [
                    {"text": text, "answer_start": answer_start}
                    for text, answer_start in zip(ans_text, ans_start_idxs)
                ]
                is_impossible = False
            else:
                answers = []
                is_impossible = True
                
            # 將問答對增加到qas
            qas.append({
                "question": question, 
                "id": qid,
                "is_impossible": is_impossible, 
                "answers": answers
            })
            
        # 將上下文和問答對增加到paragraphs
        paragraphs.append({"qas": qas, "context": review})
    return paragraphs

這個函式接收一個DataFrame,並從中提取問答資料來構建SQuAD格式的paragraphs陣列。函式首先建立一個將評論ID對映到評論內容的字典,然後遍歷每個評論。對於每個評論,它篩選出所有相關的問題,並為每個問題構建問答對,包括問題文字、ID、是否可回答的標記和答案陣列。答案陣列包含答案文字和在上下文中的起始位置。最後,函式將所有這些資訊組合成SQuAD格式的段落結構。

將這個函式應用到DataFrame的特定產品ID行時,我們能獲得符合SQuAD格式的資料:

product = dfs["train"].query("title == 'B00001P4ZH'")
create_paragraphs(product)

最後一步是將這個函式應用到每個分割中的每個產品ID。以下函式完成這個轉換,並將結果儲存為JSON檔案:

import json

def convert_to_squad(dfs):
    for split, df in dfs.items():
        subjqa_data = {}
        # 為每個產品ID建立`paragraphs`
        groups = (df.groupby("title").apply(create_paragraphs)
                 .to_frame(name="paragraphs").reset_index())
        subjqa_data["data"] = groups.to_dict(orient="records")
        
        # 將結果儲存到磁碟
        with open(f"electronics-{split}.json", "w+", encoding="utf-8") as f:
            json.dump(subjqa_data, f)

convert_to_squad(dfs)

這個函式遍歷所有資料分割(如訓練集、驗證集、測試集),對每個分割中的DataFrame應用前面定義的create_paragraphs函式。它使用groupby按產品標題分組,然後對每組應用該函式,將結果組織成SQuAD格式的JSON結構,並儲存到對應的JSON檔案中。這樣,我們就完成了從自定義資料格式到標準SQuAD格式的轉換。

領域適應與模型微調策略

在處理特定領域的問答任務時,模型微調策略對效能至關重要。我發現,選擇合適的微調方法能顯著提升模型在目標領域的表現。

根據轉換後資料的閲讀器微調

有了SQuAD格式的資料,我們現在可以微調閲讀器模型。指定訓練和驗證資料的位置,以及儲存微調模型的路徑:

train_filename = "electronics-train.json"
dev_filename = "electronics-validation.json"
reader.train(data_dir=".", use_gpu=True, n_epochs=1, batch_size=16,
            train_filename=train_filename, dev_filename=dev_filename)

這段程式碼設定了訓練和驗證資料的檔案名,然後使用這些檔案對閲讀器模型進行微調。train方法接受多個引數:資料目錄、是否使用GPU加速、訓練輪數、批次大小以及訓練和驗證檔案名。在這個例子中,我們只訓練一個輪次,批次大小為16,並利用GPU加速訓練過程。

評估領域適應效果

微調後,比較模型在測試集上的表現與基線模型的差異:

reader_eval["Fine-tune on SQuAD + SubjQA"] = evaluate_reader(reader)
plot_reader_eval(reader_eval)

測試結果顯示,領域適應將精確比對(EM)分數提高了六倍,F1分數提高了一倍多!這清楚地表明,將預訓練模型適應到目標領域能顯著提升效能。

直接微調與遷移學習的比較

一個常見的疑問是:為什麼不直接在目標領域資料(如SubjQA)上微調預訓練語言模型?主要原因是資料量的差異—SubjQA只有1,295個訓練範例,而SQuAD有超過10萬個,直接微調可能導致過擬合。

讓我們比較一下這兩種方法的效果。首先載入相同的基礎語言模型:

minilm_ckpt = "microsoft/MiniLM-L12-H384-uncased"
minilm_reader = FARMReader(model_name_or_path=minilm_ckpt, progress_bar=False,
                          max_seq_len=max_seq_length, doc_stride=doc_stride,
                          return_no_answer=True)

然後直接在SubjQA上微調一個輪次:

minilm_reader.train(data_dir=".", use_gpu=True, n_epochs=1, batch_size=16,
                   train_filename=train_filename, dev_filename=dev_filename)

評估在測試集上的表現:

reader_eval["Fine-tune on SubjQA"] = evaluate_reader(minilm_reader)
plot_reader_eval(reader_eval)

結果顯示,直接在SubjQA上微調的效能明顯低於先在SQuAD上微調再在SubjQA上微調的方法。這證實了在小型資料集上,使用遷移學習(先在大型通用資料集上微調,再在特定領域資料上微調)比直接微調更有效。

在處理小型資料集時,我建議使用交叉驗證來評估Transformer模型,因為它們容易過擬合。這種做法可以提供更可靠的效能評估。

評估完整問答管道

評估單個元件(檢索器和閲讀器)後,接下來我們需要評估整個問答管道的效能。這涉及將檢索器和閲讀器組合起來,測量整體效果。

構建評估管道

我們需要增強檢索器管道,加入閲讀器和評估節點:

# 初始化檢索器管道
pipe = EvalRetrieverPipeline(es_retriever)

# 增加閲讀器節點
eval_reader = EvalAnswers()
pipe.pipeline.add_node(component=reader, name="QAReader",
                      inputs=["EvalRetriever"])
pipe.pipeline.add_node(component=eval_reader, name="EvalReader",
                      inputs=["QAReader"])

# 評估!
run_pipeline(pipe)

# 從閲讀器提取指標
reader_eval["QA Pipeline (top-1)"] = {
    k:v for k,v in eval_reader.__dict__.items()
    if k in ["top_1_em", "top_1_f1"]
}

這段程式碼建立了一個完整的評估管道。首先初始化檢索器評估管道,然後增加閲讀器節點和評估節點。閲讀器節點接收檢索器的輸出,評估節點接收閲讀器的輸出。執行管道後,從評估節點提取top-1的精確比對和F1分數,這些指標反映了整個問答管道的效能。

比較單獨閲讀器與完整管道效能

從比較中可以看出,與直接比對問題-上下文對(SQuAD風格評估)相比,整合檢索器會導致效能下降。這是因為檢索器可能無法回傳包含正確答案的所有相關檔案,即使它在前10個結果中達到了接近完美的召回率。

這種效能下降可以透過增加閲讀器允許預測的可能答案數量來緩解。例如,允許閲讀器從多個檢索到的檔案中提取多個答案,然後根據置信度排序。