5.3 實現檢索器
建立索引,並寫一個 retrieve 函式:問題進去,最相關的幾段資料出來。
檢索器的工作是:輸入問題,輸出最相關的幾段資料。要做到這件事,需要先把資料建成索引,再寫一個查詢的函式。
設定
先把會調整的參數放在最上面,方便之後修改:
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
做了三件事:
- 把問題轉成向量(一定要用和建索引時同一個模型)。
- 向 Chroma 查詢最近的 k 筆。
- 過濾掉距離太大的,只保留夠相關的。
回傳的每一筆是一個字典,包含內文、距離、來源、標題,後面組提示詞和標出處都會用到。
距離,不是相似度
要特別注意: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']}")
檢索階段就找錯了,後面再怎麼調提示詞都沒用。