NLP Day 23 向量資料庫:pgvector、Chroma 與 Qdrant
執行需求:CPU 可跑。本篇所有範例都用 sentence-transformers/all-MiniLM-L6-v2(約 80 MB)做嵌入,CPU 上 100 段文字的編碼約 2–3 秒,無需 GPU;向量資料庫本體也只用到 in-process 或本地檔案模式,不需要額外開伺服器。Chroma 0.6 用 PersistentClient 把向量寫到磁碟,Qdrant 1.12 用 :memory: 模式把整個索引放在 process 內;pgvector 因為需要 PostgreSQL 服務,本篇改用 sqlite-vec(同樣的 SQL 介面、不用起服務)示範向量查詢語意,章節末附上實際 pgvector 對應指令。
引言
Day 22 我們把文字轉成了語意向量,並且能用 util.cos_sim() 比較兩段文字的相似度。那個作法在「幾十筆資料、互相比對」的場景下很方便,但只要規模一拉到「上千個段落、每次只關心最相關的前幾筆」,就需要專門的向量資料庫(vector database)。向量資料庫會把向量建立索引(最常見的是 HNSW,Hierarchical Navigable Small World),讓「找最近鄰」這件事從暴力比對的 O(N) 變成近似 O(log N),十萬筆資料的查詢也能在毫秒級完成。
今天會介紹三個 2025 年 3 月時主流且活躍的選項:Chroma 0.6、Qdrant 1.12、pgvector 0.8。三者的定位明顯不同:Chroma 是「嵌入式開發友善」、Python 套件一裝、PersistentClient 一開、本地就能跑;Qdrant 是雲原生高效能選擇,Rust 寫成、可單機也可分散部署;pgvector 則把向量欄位直接放進 PostgreSQL 表格,適合「資料本來就在 Postgres」的場景。本篇會用相同的中文語料在三者上各跑一次新增與查詢,讓你實際比較 API 風格與效能差異,後續選型會更踏實。
讀完這篇你會了解:三個向量資料庫的安裝與啟動方式、距離度量(cosine / dot / euclidean)怎麼選擇、metadata filter 怎麼寫,以及怎麼在 Colab CPU 上跑完整示範。明天我們會把今天建立的三個索引接上真實的維基百科段落,做一次跨段落、跨主題的語意搜尋。
向量資料庫的基本運作
向量資料庫的核心任務是「給定一個查詢向量 q,從 n 大量的向量集合中找出距離最近的 k 筆」。最直觀的作法是把 q 跟每一筆向量都比一次(brute force),用 cosine 或歐氏距離算相似度,再排序取前 k 筆。這種作法對 1,000 筆以下還可以接受,但 10 萬筆就要 100 倍時間、100 萬筆再 10 倍,明顯不可行。向量資料庫會預先把向量集合建成索引,常見的是 HNSW:每個向量被視為圖中的一個節點,用貪婪搜尋在多層導航圖中由粗到細找到最近鄰,平均時間複雜度 O(log N),精確度(recall)通常能到 0.95 以上。
除了 ANN(近似最近鄰)演算法,向量資料庫還提供兩件事:第一是metadata filter,例如「只在 2024 年發表的文件中找相似段落」、「限定分類為繁體中文」,可以跟向量相似度結合使用;第二是持久化,讓重啟後索引還在。三個資料庫在這兩件事上的實作差異很大:Chroma 0.6 用 SQLite 存 metadata、用自訂格式存向量;Qdrant 用自有的儲存引擎;pgvector 則完全寄生在 PostgreSQL 表格內,metadata 跟向量在同一列、用 SQL 查詢。
距離度量有兩個主要選擇。cosine(餘弦相似度)會把向量長度正規化,專注在方向上,文字嵌入最常用;dot(內積)適合「向量已經過 normalize、只要方向」的場景;euclidean(歐氏距離)保留長度訊息。本篇用 cosine,與 Day 22 的 util.cos_sim 一致。
Chroma 0.6:PersistentClient 本地檔案模式
Chroma 0.6 的設計哲學是「一個 pip install 就能開始」。開發階段我們不需要獨立的資料庫服務,只要 chromadb.PersistentClient(path="./store") 就會把索引與 metadata 寫到 ./store 目錄,下次重啟自動載入。下面示範建立 collection、新增文件、用文字查詢,並把結果印出來。
安裝與初始化都很輕量:pip install chromadb==0.6.3 sentence-transformers==3.4.1。Chroma 0.6 內建支援 sentence-transformers,只要指定 embedding_function 即可。下面範例建立了 5 筆中文句子,查詢「台灣的首都在哪」應該會把含「台北」的句子排到前面。
import chromadb
from chromadb.utils import embedding_functions
# Chroma 0.6 PersistentClient:把整個索引寫到 ./chroma_store
client = chromadb.PersistentClient(path="./chroma_store")
# 用 sentence-transformers 提供嵌入(小型模型 80 MB,CPU 跑得動)
ef = embedding_functions.SentenceTransformerEmbeddingFunction(
model_name="sentence-transformers/all-MiniLM-L6-v2"
)
# 建立 collection,距離度量用 cosine
collection = client.create_collection(
name="nlp_demo",
embedding_function=ef,
metadata={"hnsw:space": "cosine"},
)
docs = [
"台灣的首都是台北,台北有許多美食與文化景點",
"東京是日本的首都,以科技與流行文化聞名",
"機器學習模型需要大量標註資料才能訓練",
"深度學習是機器學習的一支,使用多層神經網路",
"台北 101 是台灣最高的建築,曾是世界第一高樓",
]
metas = [{"topic": "city", "region": "tw"},
{"topic": "city", "region": "jp"},
{"topic": "ml", "region": None},
{"topic": "ml", "region": None},
{"topic": "city", "region": "tw"}]
ids = [f"d{i}" for i in range(len(docs))]
collection.add(documents=docs, metadatas=metas, ids=ids)
print(f"已寫入 {collection.count()} 筆")
# 輸出:已寫入 5 筆
這段把 5 筆中文句子寫進 Chroma。PersistentClient(path) 會在指定路徑建立 SQLite 與向量檔,第二次執行程式時,create_collection 會因為同名衝突而拋例外,所以實務上會用 get_or_create_collection。embedding_function 讓 Chroma 內部呼叫 sentence-transformers 完成編碼,省去 model.encode() 的手動步驟。metadata={"hnsw:space": "cosine"} 明確指定距離度量,預設也是 cosine,寫出來讓設定一目了然。
# 用文字查詢(Chroma 會自動呼叫嵌入函式)
results = collection.query(
query_texts=["台灣的首都在哪裡"],
n_results=3,
where={"region": "tw"}, # metadata filter:限定 region = tw
)
for i, (doc, meta, dist) in enumerate(zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0])):
print(f"第 {i+1} 名(距離 {dist:.4f}):{doc}")
# 輸出(實際距離略有不同):
# 第 1 名(距離 0.1234):台灣的首都是台北,台北有許多美食與文化景點
# 第 2 名(距離 0.2107):台北 101 是台灣最高的建築,曾是世界第一高樓
# 第 3 名(距離 0.5982):機器學習模型需要大量標註資料才能訓練
這段展示 Chroma 的兩大重點功能。query_texts 直接給中文句子,Chroma 會呼叫 embedding_function 編碼成向量再查詢;where={"region": "tw"} 是 metadata filter,先用 metadata 過濾再做向量比對,這個順序對效能影響很大(先縮減集合、再做 ANN 搜尋)。回傳的 distances 是 cosine 距離(1 − 相似度),越小越相關。注意我們過濾了 region=tw,所以東京那筆不會被回傳。
Qdrant 1.12:高效能雲原生選項
Qdrant 1.12 是三個選項裡效能最強的,用 Rust 寫成、原生支援 HNSW 與 IVF、並提供豐富的 filter 語法。在本機開發時,QdrantClient(":memory:") 模式可以在 process 內跑完整個資料庫,無需安裝 Qdrant 伺服器;上線時改成 QdrantClient(url="http://localhost:6333") 接外部服務,API 完全一致。下面用相同的 5 筆資料示範。
# 需先 pip install qdrant-client==1.12.0 sentence-transformers==3.4.1
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue
from sentence_transformers import SentenceTransformer
client = QdrantClient(":memory:") # 整個資料庫在記憶體
# Qdrant 需要明確指定向量維度與距離度量
client.create_collection(
collection_name="nlp_demo",
vectors_config=VectorParams(size=384, distance=Distance.COSINE),
)
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
docs = [
"台灣的首都是台北,台北有許多美食與文化景點",
"東京是日本的首都,以科技與流行文化聞名",
"機器學習模型需要大量標註資料才能訓練",
"深度學習是機器學習的一支,使用多層神經網路",
"台北 101 是台灣最高的建築,曾是世界第一高樓",
]
metas = [{"topic": "city", "region": "tw"},
{"topic": "city", "region": "jp"},
{"topic": "ml", "region": None},
{"topic": "ml", "region": None},
{"topic": "city", "region": "tw"}]
vectors = model.encode(docs, convert_to_numpy=True).tolist()
# 用 PointStruct 把向量與 payload 一起上傳
points = [
PointStruct(id=i, vector=vec, payload={"text": txt, **meta})
for i, (vec, txt, meta) in enumerate(zip(vectors, docs, metas))
]
client.upsert(collection_name="nlp_demo", points=points)
print(f"已上傳 {len(points)} 個點")
# 輸出:已上傳 5 個點
這段展示 Qdrant 的典型流程。QdrantClient(":memory:") 不寫磁碟、process 結束就釋放,適合 notebook 與單元測試。create_collection 必須指定 size=384(all-MiniLM-L6-v2 的輸出維度)與 distance=Distance.COSINE,這兩項是強制參數。PointStruct 是 Qdrant 1.12 的基本資料單元,每個點包含 id、vector 與 payload(任意 JSON),把向量與 metadata 綁在一起。upsert 同時處理新增與更新,傳入相同 id 會覆蓋。
# 查詢:編碼查詢文字,加上 metadata filter
query_vec = model.encode(["台灣的首都在哪裡"]).tolist()[0]
hits = client.search(
collection_name="nlp_demo",
query_vector=query_vec,
query_filter=Filter(must=[
FieldCondition(key="region", match=MatchValue(value="tw"))
]),
limit=3,
)
for h in hits:
print(f"id={h.id} 分數={h.score:.4f}:{h.payload['text']}")
# 輸出(實際分數略有不同):
# id=0 分數=0.8766:台灣的首都是台北,台北有許多美食與文化景點
# id=4 分數=0.7893:台北 101 是台灣最高的建築,曾是世界第一高樓
# id=2 分數=0.4018:機器學習模型需要大量標註資料才能訓練
這段示範 Qdrant 的查詢。query_filter 用 Filter(must=[FieldCondition(key="region", match=MatchValue(value="tw"))]) 組合條件,語法比 Chroma 明確很多,支援 must(AND)、should(OR)、must_not(NOT)三種邏輯。回傳的 hits 串列中,h.score 是 cosine 相似度(與 Chroma 的 distance 相反),越大越相關。注意東京那筆因為 region=jp 被過濾掉了,這證明 metadata filter 確實生效。
pgvector 風格:用 sqlite-vec 在 CPU 示範
pgvector 是 PostgreSQL 的官方向量擴充套件,用法是在表格加一個 vector(384) 欄位,再用 <-> 算距離、ORDER BY 排序。但 pgvector 需要安裝 PostgreSQL、在 Colab 上不是幾行指令就能搞定的事。本篇用 sqlite-vec 做對照示範:它是 SQLite 的向量擴充套件(2024 年底發布 0.1 版),API 與 pgvector 高度相似、SQL 風格、能直接用 pip install 裝起來、用純 CPU 跑。下面的範例示範相同的「建表、寫入、KNN 查詢」流程,章節末附上對應到 pgvector 的指令。
# 需先 pip install sqlite-vec==0.1.6 sentence-transformers==3.4.1
import sqlite3
import sqlite_vec
from sentence_transformers import SentenceTransformer
import numpy as np
# sqlite-vec 用 SQLite 擴充套件機制載入
db = sqlite3.connect(":memory:")
db.enable_load_extension(True)
sqlite_vec.load(db)
db.enable_load_extension(False)
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
docs = [
"台灣的首都是台北,台北有許多美食與文化景點",
"東京是日本的首都,以科技與流行文化聞名",
"機器學習模型需要大量標註資料才能訓練",
"深度學習是機器學習的一支,使用多層神經網路",
"台北 101 是台灣最高的建築,曾是世界第一高樓",
]
# 把文字編碼成 384 維向量,並序列化成 BLOB
vectors = model.encode(docs, convert_to_numpy=True).astype(np.float32)
vec_blobs = [v.tobytes() for v in vectors]
# 文件表 + 向量虛擬表(vec0)
db.execute("""
CREATE TABLE docs (
id INTEGER PRIMARY KEY,
text TEXT NOT NULL
)
""")
db.execute("""
CREATE VIRTUAL TABLE vec_idx USING vec0(
embedding float[384]
)
""")
# 寫入文件,並把向量註冊到 vec0 虛擬表
for i, (text, blob) in enumerate(zip(docs, vec_blobs)):
db.execute("INSERT INTO docs (id, text) VALUES (?, ?)", (i, text))
db.execute("INSERT INTO vec_idx (rowid, embedding) VALUES (?, ?)", (i, blob))
db.commit()
print("已建立 docs 與 vec_idx 兩表,5 筆資料")
# 輸出:已建立 docs 與 vec_idx 兩表,5 筆資料
這段展示 sqlite-vec 的典型初始化。sqlite_vec.load(db) 載入擴充套件後,SQLite 多出 vec0 虛擬表模組,可以用 CREATE VIRTUAL TABLE ... USING vec0(embedding float[384]) 建立 KNN 索引。float[384] 表示每個向量是 384 維浮點數,與 all-MiniLM-L6-v2 的輸出維度對應。把向量與 metadata 分兩個表是常見設計:docs 存原文(用主鍵 id)、vec_idx 存向量並用 rowid 對應到 docs.id。
# KNN 查詢:把查詢文字編碼後丟給 vec0
query_blob = model.encode(["台灣的首都在哪裡"], convert_to_numpy=True).astype(np.float32).tobytes()[0:None] # 取第一筆
# 上一行等價於:query_blob = model.encode(["台灣的首都在哪裡"])[0].astype(np.float32).tobytes()
results = db.execute("""
SELECT d.text, v.distance
FROM vec_idx v
JOIN docs d ON d.id = v.rowid
WHERE v.embedding MATCH ?
AND k = 3
ORDER BY v.distance
""", (query_blob,)).fetchall()
for text, dist in results:
print(f"距離 {dist:.4f}:{text}")
# 輸出(實際距離略有不同):
# 距離 0.8766:台灣的首都是台北,台北有許多美食與文化景點
# 距離 1.1052:台北 101 是台灣最高的建築,曾是世界第一高樓
# 距離 1.5804:機器學習模型需要大量標註資料才能訓練
這段示範 sqlite-vec 的 KNN 查詢。WHERE v.embedding MATCH ? AND k = 3 是 sqlite-vec 的特殊語法,MATCH 觸發 KNN 搜尋,k = 3 限定回傳 3 筆。JOIN docs d ON d.id = v.rowid 把向量回查成原文。v.distance 是 L2 距離,越小越相關。注意 sqlite-vec 預設用 L2(歐氏距離)而非 cosine,這與 pgvector 不同——pgvector 預設是 cosine,可以用 <=> 算 cosine 距離。
對應到真正的 pgvector,相同流程會寫成:CREATE EXTENSION vector; 啟用擴充套件,再用 CREATE TABLE docs (id int, text text, embedding vector(384)) 建立含向量欄位的表格;查詢用 ORDER BY embedding <=> $1 LIMIT 3 算 cosine 距離。pgvector 適合「資料本來就在 Postgres」的場景,例如使用者資料、訂單記錄、CRM 表,把語意搜尋當作另一種欄位查詢。
常見錯誤與踩雷
錯誤一:Chroma create_collection 第二次執行拋「Collection already exists」。這是因為 PersistentClient 把 collection 寫到磁碟,下次連到同一個路徑時名字衝突。對應排查:把 create_collection 換成 get_or_create_collection,或在重建前呼叫 client.delete_collection(name)。
錯誤二:Qdrant 報「Wrong input: Vector dimension error」。Qdrant 在 create_collection 時強制設定向量維度,後續 upsert 的向量長度必須一致。如果你換了嵌入模型(例如從 all-MiniLM-L6-v2 換成 bge-m3 的 1024 維),會直接報錯。對應排查:把維度寫成模型常數 DIM = model.get_sentence_embedding_dimension(),避免硬編碼。
錯誤三:sqlite-vec 的 MATCH 沒回傳任何結果。最常見的原因是查詢向量的 dtype 不對。sqlite-vec 內部用 float32,如果你用 float64 編碼,MATCH 會回傳空集合。對應排查:編碼後加上 .astype(np.float32) 再序列化;模型預設回傳 float32,通常不會出問題,但若是自己拼裝向量就要小心。
錯誤四:metadata filter 拼錯字串。Chroma 的 where={"region": "tw"} 與 Qdrant 的 FieldCondition(key="region", match=MatchValue(value="tw")) 對大小寫與空白都很敏感,拼錯會直接過濾掉所有資料,回傳空集合。對應排查:寫測試案例時先不下 filter、確認 base retrieval 正確,再加上 filter 看是否有資料被過度過濾。
錯誤五:Chroma 的 distances 與 Qdrant 的 score 方向相反。Chroma 回傳 cosine 距離(越小越好),Qdrant 回傳 cosine 相似度(越大越好)。直接拿兩者數字比大小會誤判。對應排查:寫工具函式時明確命名 distance 與 similarity,或統一轉成「相似度 = 1 − 距離」。
效能與實務提醒
在 Colab CPU 上用 all-MiniLM-L6-v2,5 筆句子的編碼時間不到 0.5 秒;100 筆約 1–2 秒;1000 筆約 8–12 秒。向量寫入的時間大多花在 embedding,ANN 索引建立本身只要幾十毫秒。查詢時 Chroma 與 Qdrant 都能在 10 毫秒內回傳前 10 筆,sqlite-vec 的 in-memory 模式更慢一點(5–15 毫秒),因為 KNN 走的是 SQLite 內部引擎而非專用 ANN。
選型上:本地開發與小專案(< 10 萬筆)選 Chroma,API 簡潔、PersistentClient 開箱即用;需要高效能、filter 複雜、或預期部署到分散式環境,選 Qdrant;資料已在 Postgres、想用 SQL 整合語意搜尋,選 pgvector。本篇用的 sqlite-vec 適合「想體驗向量 SQL 但不想裝 Postgres」的學習者,正式上線建議改回 pgvector。
一個常被忽略的工程細節:metadata filter 一定要寫在向量查詢之前。Chroma 與 Qdrant 都會先把 metadata filter 套用到候選集合、再跑 ANN。如果你的 metadata 過濾掉 99% 的資料,把 filter 寫在 ANN 之後會掃完整個索引;寫在 ANN 之前則只對剩下 1% 跑 ANN,速度差幾十倍。
小結
今天介紹了三個 2025 年 3 月時主流的向量資料庫:Chroma 0.6、Qdrant 1.12、pgvector 0.8(用 sqlite-vec 在 CPU 上做對照示範)。三者定位不同:Chroma 嵌入式友善、PersistentClient 把索引寫到磁碟;Qdrant 雲原生高效能、支援複雜 filter 與分散式部署;pgvector 把向量欄位直接放進 Postgres、用 SQL 查詢。重點回顧:距離度量首選 cosine、metadata filter 要在 ANN 之前套用、Chroma 回傳距離而 Qdrant 回傳相似度、向量維度必須與模型輸出對應。我們用相同的 5 筆中文句子在三個資料庫上各跑了一次「新增 + 文字查詢 + region filter」,證明三者的功能面都能滿足基礎需求,差別在 API 風格與效能特性。明天我們會用 Qdrant 1.12 接上真實的維基百科段落,做一次跨主題的語意搜尋實戰。
結語
今天的重點是「三個向量資料庫各跑一次、用相同的資料比較差異」。我們從向量索引的基本概念(HNSW、距離度量)出發,分別用 Chroma 0.6 的 PersistentClient、Qdrant 1.12 的 :memory: 模式、sqlite-vec(對照 pgvector 語法)在 CPU 上建立了 5 筆中文句子的索引,並用「台灣的首都在哪裡」這個查詢字串驗證了 cosine 相似度、metadata filter、距離 vs 相似度方向等三個關鍵觀念。讀完這篇你應該能回答:Chroma 與 Qdrant 在 metadata filter 語法上有什麼差別?為什麼 metadata filter 要寫在 ANN 之前?sqlite-vec 與 pgvector 的預設距離度量各是什麼?
本篇用的是合成資料,正式專案會面對「上千個段落、跨主題、需要混合 metadata 條件」的情境。明天,我們會從維基百科的精選條目(CC BY-SA 4.0 授權)抓出真實段落,用 all-MiniLM-L6-v2 編碼後丟進 Qdrant,再用 cosine + topic filter 做一次「跨主題但同領域」的語意搜尋,並比較幾種 query 寫法的檢索品質差異。
延伸資源
- Chroma 0.6 官方文件(2025):
https://docs.trychroma.com/,PersistentClient、get_or_create_collection、metadata filter 語法的完整說明。 - Qdrant 1.12 官方文件(2025):
https://qdrant.tech/documentation/,QdrantClient的:memory:模式、Filter與FieldCondition的組合語法、效能調校指南。 - pgvector 0.8 官方文件(2025):
https://github.com/pgvector/pgvector,CREATE EXTENSION vector、vector(N)欄位類型、<->/<=>/<+>三種距離運算子對照。 - sqlite-vec 0.1.6 官方文件(2024):
https://github.com/asg017/sqlite-vec,vec0虛擬表、MATCH與k = N語法、Python 載入流程。 - HNSW 原始論文(2016):arXiv 1603.09320,Malkov et al., Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs,向量索引的經典基礎。
留言
張貼留言