昨天我們把文件切成了 chunks。今天要讓這些 chunks 變得「可搜尋」:用 bge-m3 把每個 chunk 轉成向量,存進 Chroma 向量資料庫,並做第一次相似度搜尋。做完之後,你就能輸入一個問題,看到系統從知識庫撈出最相關的段落,這正是 RAG 裡「R(Retrieval)」的部分。
bge-m3 產生向量,並確認 Ollama 運作正常先確認 Ollama 正在執行(可以開啟 http://localhost:11434 檢查),再在 build_rag.py 加入設定與測試函式:
import os
from langchain_ollama import OllamaEmbeddings
from langchain_chroma import Chroma
EMBED_MODEL = "bge-m3"
OLLAMA_URL = "http://127.0.0.1:11434"
PERSIST_DIR = "chroma_db"
COLLECTION_NAME = "rag-tutorial"
def get_embeddings():
return OllamaEmbeddings(model=EMBED_MODEL, base_url=OLLAMA_URL)
def check_embeddings():
vector = get_embeddings().embed_query("什麼是 Git?")
print(f"向量維度:{len(vector)}")
print(f"前 5 個數值:{vector[:5]}")
暫時把 __main__ 區塊改成:
if __name__ == "__main__":
check_embeddings()
執行後,你會看到一串 1024 維的向量(bge-m3 的輸出維度)。第一次呼叫需要先把模型載入記憶體,會比較慢,之後就快了。
如果出現連線錯誤,通常是 Ollama 沒有啟動,或 bge-m3 還沒下載,用 ollama list 確認一下。
繼續在 build_rag.py 加入建庫的函式:
def get_vectorstore():
return Chroma(
collection_name=COLLECTION_NAME,
embedding_function=get_embeddings(),
persist_directory=PERSIST_DIR,
collection_configuration={"hnsw": {"space": "cosine"}},
)
def build_vectorstore(chunks):
# 先清掉舊的 collection,避免重複執行時資料重複寫入
get_vectorstore().delete_collection()
vectorstore = get_vectorstore()
vectorstore.add_documents(chunks)
return vectorstore
幾個重點:
| 項目 | 說明 |
|---|---|
collection_name |
向量資料庫裡的「資料表」名稱,一個專案可以有多個 collection |
embedding_function |
指定用哪個模型產生向量,建庫與查詢必須使用同一個模型,否則向量空間不同,距離沒有意義 |
persist_directory |
資料存放的資料夾,DAY23 已經把 chroma_db/ 加進 .gitignore |
{"hnsw": {"space": "cosine"}} |
指定用餘弦距離計算相似度。Chroma 預設是 L2 距離,文字 Embedding 通常搭配餘弦距離,所以我們明確指定 |
另外一個容易踩到的坑:add_documents 每次執行都會「新增」資料。如果不先清除,重複執行幾次,同一個 chunk 就會在庫裡出現好幾份,檢索結果會被重複內容佔滿。所以 build_vectorstore 一開始先刪除舊的 collection。
把 __main__ 改成:
if __name__ == "__main__":
docs = load_documents()
chunks = split_documents(docs)
vectorstore = build_vectorstore(chunks)
print(f"已建立向量庫,共 {len(chunks)} 個 chunks")
執行後,專案裡會出現 chroma_db/ 資料夾,向量已經存進硬碟。
加入一個搜尋函式,把結果整理成容易閱讀的格式:
def search(vectorstore, query: str, k: int = 3):
results = vectorstore.similarity_search_with_score(query, k=k)
print(f"\n問題:{query}")
for rank, (doc, score) in enumerate(results, 1):
source = os.path.basename(doc.metadata["source"])
preview = doc.page_content.replace("\n", " ")[:60]
print(f" [{rank}] 距離 {score:.4f} | {source} | {preview}...")
similarity_search_with_score 回傳的分數是距離,不是相似度。使用餘弦距離時,它等於「1 減去餘弦相似度」,所以數字越小代表越相似。
把 __main__ 改成:
if __name__ == "__main__":
docs = load_documents()
chunks = split_documents(docs)
vectorstore = build_vectorstore(chunks)
# 下述需針對 data 資料夾下的文件進行提問
search(vectorstore, "201 和 200 狀態碼有什麼差別?")
search(vectorstore, "git revert 和 git reset 有什麼不同?")
search(vectorstore, "為什麼索引會拖慢寫入速度?")
search(vectorstore, "容器和虛擬機有什麼差異?")
你應該會看到,每個問題排名第一的結果,都來自對應主題的文件(HTTP、Git、資料庫、Docker),這代表 Embedding 確實抓到了語意。請特別觀察三件事:
每次執行都重新計算所有 chunks 的向量,既慢又沒必要。向量已經存在 chroma_db/ 裡了,之後只要直接載入即可。把 __main__ 改成最終版本:
REBUILD = False # 修改文件內容或切割參數後,才需要改成 True
if __name__ == "__main__":
if REBUILD or not os.path.exists(PERSIST_DIR):
docs = load_documents()
chunks = split_documents(docs)
vectorstore = build_vectorstore(chunks)
print(f"已建立向量庫,共 {len(chunks)} 個 chunks")
else:
vectorstore = get_vectorstore()
count = len(vectorstore.get()["ids"])
print(f"已載入既有向量庫,共 {count} 筆")
search(vectorstore, "201 和 200 狀態碼有什麼差別?")
第一次執行會建庫,之後再執行就會直接載入,速度明顯變快。
最後做一個小實驗,試試看問知識庫完全沒有的內容:
search(vectorstore, "今天台中的天氣如何?")
你會發現,系統依然回傳了 3 筆結果,只是距離會比前面的問題大。向量搜尋永遠會回傳「最接近的 k 筆」,不管它們到底相不相關。
今天完成了 RAG 的檢索環節,明天要把檢索接上語言模型:建立 retriever,用 qwen3:8b 根據檢索到的內容生成答案,完成第一版完整的 RAG。