上一篇選定了 multilingual-e5-small,當時的評測是把 17 個 Chunk 的向量放進一個矩陣,跟查詢向量做一次乘法——暴力、精確、三行程式。那麼一個誠實的問題是:我們真的需要向量資料庫嗎?答案同樣要誠實:以現在的規模,不需要。但暴力計算前方有四個斷點,而且其中兩個比「規模」來得更早。
第一個斷點是記憶體與常駐。向量矩陣必須隨時在記憶體裡,百萬個 384 維的 float32 向量約 1.5GB,再加上原文與 Metadata、再乘上服務的副本數,這筆帳會越來越難記在「一個 numpy 陣列」上。
第二個是速度。全掃描是 O(N),語料百萬級、查詢流量上來之後就撐不住,需要近似最近鄰(ANN)索引把單次查詢壓到次線性。
但真正會先遇到的是第三個:資料的生命週期。知識庫不是一次建好就不動的——文件會新增、修訂、刪除。暴力矩陣的做法是「有變動就整批重算重建」,向量資料庫的做法是對單筆資料 upsert 與 delete。Day 4 設計 Metadata 時就強調過知識庫要能更新,儲存層得跟上這個承諾。
第四個是過濾。「只在 spring-security 主題裡搜尋」這種需求,暴力做法得在矩陣外面自己組布林邏輯;向量資料庫把它做成查詢條件的一部分。等一下會看到,過濾不只是行政功能——它能救回 Day 12 輸掉的那一題。
本系列選 Qdrant 作為向量資料庫。它的資料模型可以用四個概念說完:
Collection 是同一套向量設定(維度、距離函數)的容器,角色類似關聯式資料庫的資料表。我們建一個 384 維、Cosine 距離的 Collection 存放所有 Chunk。
Point 是一筆資料,由三個部分組成:一個 ID、一條向量,以及一包 Payload。
Payload 是掛在 Point 上的任意 JSON——這是 Day 4 那套 Metadata 的新家。文件識別碼、標題、主題、來源路徑,全部塞進 Payload,搜尋結果從此自帶身分證,可追溯性從檔案系統一路延伸到向量庫。
索引有兩種。向量索引預設是 HNSW:一種把向量組織成多層圖的近似最近鄰結構,查詢時沿著圖跳躍逼近目標,用極小的召回損失換取次線性的速度——不過在我們 17 個 Point 的規模,實際上就是精確搜尋。Payload 索引則用來加速過濾條件,語料大了之後才需要。
Qdrant 常見的部署方式是 Docker 起一個伺服器,但它的 Python client 提供內嵌模式——指定一個本機路徑,資料直接落在磁碟上,不需要任何額外服務。本系列選內嵌模式,延續「筆電上可重現」的原則;換成正式伺服器時只需改連線參數。
python -m pip install qdrant-client
建立 scripts/build_vector_store.py,把 17 個 Chunk 編碼後寫進 Collection:
from pathlib import Path
from uuid import NAMESPACE_URL, uuid5
from qdrant_client import QdrantClient, models
from sentence_transformers import SentenceTransformer
from chunk_documents import structured_chunks
from load_documents import load_documents
MODEL_ID = "intfloat/multilingual-e5-small"
COLLECTION = "knowledge_chunks"
STORAGE_PATH = "qdrant_data"
if __name__ == "__main__":
documents = load_documents(Path("knowledge-base"))
chunks = [chunk for document in documents for chunk in structured_chunks(document)]
documents_by_id = {document.id: document for document in documents}
model = SentenceTransformer(MODEL_ID)
vectors = model.encode(
["passage: " + chunk.text for chunk in chunks], normalize_embeddings=True
)
client = QdrantClient(path=STORAGE_PATH)
if client.collection_exists(COLLECTION):
client.delete_collection(COLLECTION)
client.create_collection(
COLLECTION,
vectors_config=models.VectorParams(
size=vectors.shape[1], distance=models.Distance.COSINE
),
)
points = []
for chunk, vector in zip(chunks, vectors):
document = documents_by_id[chunk.document_id]
points.append(
models.PointStruct(
id=str(uuid5(NAMESPACE_URL, chunk.id)),
vector=vector.tolist(),
payload={
"chunk_id": chunk.id,
"document_id": chunk.document_id,
"title": document.title,
"topic": document.metadata.get("topic"),
"source_path": document.path,
"text": chunk.text,
},
)
)
client.upsert(COLLECTION, points=points)
info = client.get_collection(COLLECTION)
print(f"Collection「{COLLECTION}」建立完成,共 {info.points_count} 個 Point")
兩個實作細節值得說明。第一,Qdrant 的 Point ID 只接受整數或 UUID,我們的 Chunk 識別碼是 nlp-token#002 這種字串——解法是用 uuid5 從 Chunk 識別碼確定性地產生 UUID:同一個 Chunk 永遠得到同一個 ID,重跑建庫時 upsert 會覆蓋而不是重複累積,建庫因此是冪等的;原本的字串識別碼則放進 Payload,追溯鏈不斷。第二,passage: 前綴照 Day 12 的規矩加在文件端——模型的使用條款跟著資料一起搬家。
執行後,qdrant_data/ 資料夾就是向量的家:
Collection「knowledge_chunks」建立完成,共 17 個 Point
scripts/search_vector_store.py 是另一個獨立程序——它不重建任何東西,直接打開磁碟上的 Collection 查詢,這本身就在示範持久化。查詢刻意選 Day 12 e5 輸掉的 q12「登入一直失敗,該從哪裡開始查?」:
print("=== 不加過濾 ===")
response = client.query_points(COLLECTION, query=query_vector.tolist(), limit=3)
print("=== 過濾 topic=spring-security ===")
response = client.query_points(
COLLECTION,
query=query_vector.tolist(),
limit=3,
query_filter=models.Filter(
must=[
models.FieldCondition(
key="topic", match=models.MatchValue(value="spring-security")
)
]
),
)
=== 不加過濾 ===
1. nlp-word-segmentation#001 score=0.846
中文斷詞:為什麼中文搜尋需要它(topic=nlp)
2. rag-retrieval-augmented-generation#000 score=0.844
什麼是 Retrieval-Augmented Generation(topic=rag)
3. spring-security-authentication#000 score=0.842
Spring Security 的認證流程(topic=spring-security)
=== 過濾 topic=spring-security ===
1. spring-security-authentication#000 score=0.842
Spring Security 的認證流程(topic=spring-security)
2. spring-security-authentication#005 score=0.836
Spring Security 的認證流程(topic=spring-security)
3. spring-security-authentication#002 score=0.835
Spring Security 的認證流程(topic=spring-security)
不加過濾時,Day 12 的失敗原樣重現:斷詞文件排第一。但這次值得盯著分數看——0.846、0.844、0.842,前三名擠在 0.004 的區間裡。模型對這條查詢其實沒什麼把握,排名幾乎是擲硬幣;「語意分數不能裸用」又多了一條證據,而這種「前幾名難分高下」的局面,正是 Day 17 Reranker 要處理的問題,先記下。
加上 topic=spring-security 過濾後,認證文件包辦前三名,正解登頂。注意這裡發生的事:救回這一題的不是更強的模型,而是 Day 4 就設計好的 Metadata——它一路從 Front Matter 走到 Payload,第一次直接改變了檢索結果。當然也要誠實補一句:過濾的前提是「有人知道該過濾什麼」。這次是我們手動指定 topic,實際系統裡這個決定要嘛交給使用者(Day 29 的 API 會留這個參數),要嘛靠系統從問題推斷(Day 23 問題改寫的守備範圍)。
搬家最怕搬丟東西。搜尋腳本最後做了一個一致性檢查:同一條查詢,Qdrant 的排序和 Day 12 的暴力矩陣計算必須完全一致:
=== 一致性檢查 ===
暴力計算 Top-3:['nlp-word-segmentation#001', 'rag-retrieval-augmented-generation#000', 'spring-security-authentication#000']
Qdrant Top-3:['nlp-word-segmentation#001', 'rag-retrieval-augmented-generation#000', 'spring-security-authentication#000']
結果一致 ✓
這個檢查看起來多餘——在 17 個 Point 的精確搜尋下它理應通過——但這種「換基礎設施前後結果對照」的習慣,在之後每一次搬家(HNSW 參數調整、換部署模式、加 Reranker)都會再用到。基礎設施的改動不該悄悄改變檢索結果,改變了就要能被抓到。
今天回答了「為什麼需要向量資料庫」:不是因為 17 個 Chunk 算不動,而是為了資料的生命週期、Metadata 過濾,以及替未來的規模鋪路。Qdrant 的 Collection、Point、Payload 對應到我們既有的 Chunk 與 Metadata 體系,uuid5 讓建庫冪等,內嵌模式讓一切留在筆電上可重現;過濾實驗則展示了 Day 4 的 Metadata 第一次直接救回一題檢索。
下一篇把這些零件組裝成正式的元件:以 Qdrant 為儲存層的完整語意搜尋,接上 Day 10 的評測集重新計分,並開始與 BM25 並排比較——第一版向量搜尋要正式上線了。