EN

5.3 實現檢索器

建立索引,並寫一個 retrieve 函式:問題進去,最相關的幾段資料出來。

RAG下一篇 →

檢索器的工作是:輸入問題,輸出最相關的幾段資料。要做到這件事,需要先把資料建成索引,再寫一個查詢的函式。

設定

先把會調整的參數放在最上面,方便之後修改:

EMBED_MODEL = "paraphrase-multilingual-MiniLM-L12-v2"
TOP_K = 3
MAX_DISTANCE = 0.5  # 餘弦距離超過這個值,就當作「找不到相關資料」

建立索引

索引是事先做好的,流程就是第 3 章講的:分塊、轉向量、存進向量庫。

embedder = SentenceTransformer(EMBED_MODEL)
collection = chromadb.Client().create_collection("knowledge-base", metadata={"hnsw:space": "cosine"})

ids, texts, metas, to_embed = [], [], [], []
for source, content in DOCS.items():
    for i, chunk in enumerate(heading_chunks(content)):
        ids.append(f"{source}-{i}")
        texts.append(chunk["text"])
        metas.append({"source": source, "headings": chunk["headings"]})
        to_embed.append(f"{chunk['headings']}\n{chunk['text']}")  # 標題放進內容再嵌入

collection.add(ids=ids, embeddings=embedder.encode(to_embed).tolist(), documents=texts, metadatas=metas)

幾個重點:

  • id 用「來源檔名加第幾塊」,穩定且不會重複(3.6 講過)。
  • metadata 記下 source 和 headings,之後用來標出處。
  • to_embed 是「標題加內文」,用來產生向量;但存進去的 documents 是純內文。這是 3.5 的技巧:讓向量更能代表這一塊的主題,同時交給模型的文字不會重複標題。
  • 集合名稱:Chroma 要求名稱至少 3 個字元,所以用 knowledge-base,不能叫 kb。我第一次跑就在這裡出錯了。

檢索函式

def retrieve(question, k=TOP_K):
    result = collection.query(query_embeddings=embedder.encode([question]).tolist(), n_results=k)
    hits = []
    for text, meta, dist in zip(result["documents"][0], result["metadatas"][0], result["distances"][0]):
        if dist <= MAX_DISTANCE:
            hits.append({"text": text, "distance": dist, **meta})
    return hits

做了三件事:

  1. 把問題轉成向量(一定要用和建索引時同一個模型)。
  2. 向 Chroma 查詢最近的 k 筆。
  3. 過濾掉距離太大的,只保留夠相關的。

回傳的每一筆是一個字典,包含內文、距離、來源、標題,後面組提示詞和標出處都會用到。

距離,不是相似度

要特別注意:Chroma 回傳的是距離(distance),不是相似度。用餘弦時,距離 = 1 − 餘弦相似度,所以:

  • 越小越相似(0 代表完全一樣)。
  • 過濾時是「距離小於等於門檻才留下」。

這和第 2 章的「相似度,越大越像」是相反的,很容易搞混。

門檻值不能憑空設

MAX_DISTANCE 要設多少?沒有通用的答案,因為它取決於:

  • 用的嵌入模型:不同模型的分數分布不一樣。
  • 知識庫的內容和大小。
  • 問題的類型。

我一開始設 0.8,結果發現沒什麼用:連完全無關的問題,最近的資料距離也只有 0.58 左右,全都被放行了。把真實的距離印出來看過後,才調成 0.5。5.6 會看到這個過程。

做法是:先把距離印出來,拿幾個「該找到」和「不該找到」的問題比較,再決定門檻。 更有系統的做法在第 6 章。

如果要依條件過濾

向量資料庫可以在搜尋時加上 metadata 的條件:

result = collection.query(
    query_embeddings=embedder.encode([question]).tolist(),
    n_results=k,
    where={"source": "報帳規定.md"},   # 只在這份文件裡找
)

這在多份文件、多個使用者的情境很有用,也是做權限控制的入口(3.5)。

測試檢索器

建議先單獨測試檢索器,確認它找對了,再接上生成器。用幾個你知道答案在哪裡的問題,直接看 retrieve() 回傳什麼:

for hit in retrieve("出差住宿費有上限嗎?"):
    print(f"{hit['distance']:.3f}|{hit['source']} > {hit['headings']}")

檢索階段就找錯了,後面再怎麼調提示詞都沒用。

← 全部文章