所有入門單元免費開放 · 課程內容持續更新中

向量資料庫與相似度搜尋

有了切好的塊和 embedding,就能做出真正的語意搜尋。這堂課先用十幾行 numpy 親手寫出「找最近的 k 塊」,看懂原理後,再換成向量資料庫 Chroma,把索引存起來重複使用。

第 12.3 堂約 15 分鐘

前兩堂你準備好了兩樣材料:會把文字變成座標的 embedding 模型,和切好、帶著出處的文件塊。這堂課要把它們組起來,做出一個依照意思搜尋的小搜尋引擎。

我們會分兩步走:先用最樸素的方法親手寫一次,確定你知道裡面在做什麼;再換成專門的工具,讓它更實用。

整體流程:兩個階段

流程圖:第一階段建索引,把 docs 文件切塊、算 embedding、存進向量資料庫;第二階段查詢,把問題用同一個模型算 embedding,到資料庫找最近的 k 塊,回傳原文、分數與出處

建索引像替圖書館的每本書編目,只要做一次;查詢則是每位讀者來問時做。

  • 建索引(indexing):把所有塊都算好 embedding,存起來。文件有新增或修改時才需要重做。
  • 查詢(query):把使用者的問題也變成 embedding,找出最接近的幾塊。

有一個重點一定要記得:查詢和建索引必須用同一個 embedding 模型。第一堂說過,不同模型是不同的地圖,混著用會完全找不到東西。

第一版:用 numpy 親手寫

建立 search_numpy.py,和上一堂的 chunking.py 放在同一個資料夾:

import numpy as np
from sentence_transformers import SentenceTransformer
from chunking import load_chunks

model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")

# 建索引:所有塊一次算好
chunks = load_chunks("docs")
vectors = model.encode([c["text"] for c in chunks], normalize_embeddings=True)
print(f"已建立索引:{len(chunks)} 塊")


def search(query: str, k: int = 3):
    q = model.encode(query, normalize_embeddings=True)
    scores = vectors @ q                # 問題和每一塊的相似度
    top = np.argsort(-scores)[:k]       # 分數由高到低排序,取前 k 個
    return [(float(scores[i]), chunks[i]) for i in top]


while True:
    query = input("\n問題(直接按 Enter 結束):").strip()
    if not query:
        break
    for score, chunk in search(query):
        preview = chunk["text"].replace("\n", " ")[:40]
        print(f"{score:.2f}  {chunk['id']}  {preview}…")

執行後試著問「計程車錢可以拿回來嗎?」:

python search_numpy.py

搜尋結果排行:expense.md#0 分數最高,expense.md#1 第二,remote.md#1 第三,k 等於 3 的虛線以下是 leave.md#2

k 是「要拿回幾塊」。前幾名分數高、後面明顯掉下來,是很健康的搜尋結果。

整個搜尋只有三行:把問題變成向量、和所有塊相乘得到分數、排序取前 k 個。這就是相似度搜尋的全部原理。

k 要設多少?

  • 太小(例如 1):答案剛好分散在兩塊時,會漏掉一半。
  • 太大(例如 20):送給模型的內容變多,又慢又貴,不相關的內容也可能干擾回答。

一般從 3 到 5 開始試。第五堂課會教你用測試題找出適合你資料的數字。

為什麼還需要向量資料庫?

numpy 版本很好懂,但有兩個問題:

  • 每次執行都要重算所有 embedding。三份手冊沒感覺,三千份文件就要等很久。
  • 資料都在記憶體裡,程式一關就不見了,出處等資訊也要自己管理。

向量資料庫(vector database)就是專門解決這些事的工具:把向量、原文、出處一起存在硬碟上,查詢時直接幫你找出最近的幾筆。資料量很大時,它還會用特殊的索引方法加速,不必每次都和每一筆比對。

常見的選擇有 Chroma、Qdrant、pgvector(PostgreSQL 的擴充功能)、Pinecone 等。我們用 Chroma,因為它安裝簡單,資料就存在專案資料夾裡,不用另外架伺服器。

pip install chromadb

第二版:換成 Chroma

建立 store.py。之後的課程都會從這裡匯入 search:

import chromadb
from sentence_transformers import SentenceTransformer
from chunking import load_chunks

EMBED_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"

embedder = SentenceTransformer(EMBED_MODEL)
db = chromadb.PersistentClient(path="chroma_db")  # 資料存在 chroma_db 資料夾
collection = db.get_or_create_collection(name="handbook")


def embed(texts: list[str]) -> list[list[float]]:
    return embedder.encode(texts, normalize_embeddings=True).tolist()


def build_index(folder: str = "docs") -> int:
    chunks = load_chunks(folder)
    collection.upsert(  # upsert:有同樣 id 就更新,沒有就新增
        ids=[c["id"] for c in chunks],
        documents=[c["text"] for c in chunks],
        metadatas=[{"source": c["source"], "title": c["title"]} for c in chunks],
        embeddings=embed([c["text"] for c in chunks]),
    )
    return len(chunks)


def search(query: str, k: int = 3) -> list[dict]:
    result = collection.query(query_embeddings=embed([query]), n_results=k)
    hits = []
    for i in range(len(result["ids"][0])):
        distance = result["distances"][0][i]
        hits.append({
            "id": result["ids"][0][i],
            "text": result["documents"][0][i],
            "source": result["metadatas"][0][i]["source"],
            "score": 1 - distance / 2,  # 把距離換回餘弦相似度,見下方說明
        })
    return hits


if __name__ == "__main__":
    print("已建立索引:", build_index(), "塊")
    while True:
        query = input("\n問題(直接按 Enter 結束):").strip()
        if not query:
            break
        for hit in search(query):
            preview = hit["text"].replace("\n", " ")[:40]
            print(f"{hit['score']:.2f}  {hit['id']}  {preview}…")

執行:

python store.py

結果應該和 numpy 版本差不多。不同的是,專案裡多了一個 chroma_db 資料夾,索引已經存起來了,之後其他程式匯入 search 時可以直接查詢。

幾個值得注意的地方

  • query 回傳的是「清單的清單」:因為 Chroma 允許一次查詢好幾個問題,所以結果要用 [0] 取出第一個問題的答案。
  • Chroma 給的是「距離」,不是相似度:距離越小越像。它預設用的是平方歐氏距離,因為我們的向量都標準化過,距離和餘弦相似度剛好可以互換:相似度 = 1 減去「距離除以 2」。換回相似度,數字就和第一堂學的一致,比較好理解。
  • metadatas 就是出處:下一堂讓模型附上引用時,就靠這裡的 source。
可以這樣問 AI

這是我的 store.py,用 Chroma 存文件塊。請幫我在 search 加上一個 source 參數:如果有指定,例如 source=“leave.md”,就只在那份文件的塊裡搜尋。請用 Chroma 的 where 篩選功能實作,並附上一個使用範例。

行動練習
  1. 執行 search_numpy.py,問三個問題,看前三名是不是你預期的那幾塊。
  2. 安裝 chromadb,執行 store.py,確認專案裡出現 chroma_db 資料夾,結果和 numpy 版本相近。
  3. 把 k 改成 1 和 6,各問同一個問題,觀察結果的差別。
  4. 問一個手冊裡沒有的問題,例如「公司有健身房嗎?」,記下最高分。和第一堂練習記下的「不相關分數」比比看。
  5. 在 docs 加一份新文件,重新建索引,確認問相關問題時找得到它。

讀完了嗎?

標記完成後,單元與專案的進度會自動更新(只存在這台裝置的瀏覽器)。

卡住了?讓 AI 當你的家教複製這段提問範本,貼到 Claude 或其他 AI 工具,再補上你卡住的地方。

留言與討論

卡關了、有心得,或發現內容需要更新?都歡迎留言。留言使用 GitHub 帳號登入。

留言區即將開放。現在可以先到 GitHub 討論區 發問,或看看 交流頁 的其他管道。