向量資料庫:選型與實作
第 2 課結束後,你已經能把一段文字送進 embedding 模型,拿回一串 1,536 維的浮點數向量。很好。但現在問題來了:你有 100 份文件、1,000 個段落、每段都要變成向量——這些向量要存在哪裡?查詢時怎麼在毫秒內找出「最像這個 query 向量的前 5 筆」?
用 PostgreSQL 的普通表存向量、然後對每一筆算餘弦相似度?如果只有幾百筆還行,一旦超過 1 萬筆你就會看到查詢從 20ms 爬到幾秒。向量相似搜尋是一個特殊的計算問題,需要專為它設計的索引結構——這就是向量資料庫(Vector Database)的存在意義。
這堂學什麼
- 向量資料庫底層用的 HNSW 索引是什麼——為什麼它比暴力掃描快幾百倍
- pgvector、Chroma、Pinecone、Qdrant 四款工具的定位、自架 vs 託管選項、2026 年現行價格
- 本課選 Qdrant 的具體理由(效能、過濾、易架)
- 用 Python 完整走一遍:建 collection、批次 embed + 寫入 100 份文件、語義查詢
- 用 metadata 過濾縮小搜尋範圍——RAG 精準度的關鍵一步
觀念一:向量搜尋的底層——HNSW 是什麼
你有 10 萬個向量,query 進來要找最近的 5 個。最笨的做法:把 query 和全部 10 萬筆算一遍距離,取最小的 5 筆——這叫暴力搜尋(Brute-force),100% 準確但線性時間複雜度,資料量一大就完蛋。
向量資料庫用的主流索引叫 HNSW(Hierarchical Navigable Small World)。簡單說:它把向量組成一個多層的圖結構,搜尋時像「從高空往下縮圈」——先在稀疏的上層找到大方向,再往密集的下層精確定位。時間複雜度降到近似 O(log n),10 萬筆 → 幾毫秒。
代價是兩個:一是索引建好後不能動(插入新向量時要局部更新);二是結果是近似最近鄰,不保證 100% 完美——這在 RAG 場景幾乎無所謂,召回差那 1-2% 不影響最終回答品質。

圖:Chroma 官網(2026 年 7 月實況),來源:trychroma.com
觀念二:四款向量資料庫一次比較
2026 年的選項比 2023 年多很多,但主流就這四款——定位各不相同。

| pgvector | Chroma | Qdrant | Pinecone | |
|---|---|---|---|---|
| 類型 | PostgreSQL 擴充套件 | 獨立向量庫 | 獨立向量引擎 | 全託管雲端 |
| 自架 | 有(跟 Postgres 共存) | 有(Docker) | 有(Docker) | 無 |
| 託管 | Supabase、Neon 等 | 無官方託管 | Qdrant Cloud | Pinecone Cloud |
| 免費額度 | 依 Postgres 主機費用 | 完全免費自架 | 完全免費自架;Cloud 有免費層 | 1 個 index / 2 GB 永久免費 |
| 付費起跳 | 視 Postgres 主機 | 自架基礎設施費用 | Cloud 約 $30/月起 | Serverless 按量計費,Standard $50/月起 |
| 查詢延遲 | 25–40 ms(視索引) | ~30 ms | ~12 ms | ~10–15 ms |
| 適合規模 | < 500 萬向量 | 原型、本機開發 | 100 萬 ~ 1 億+ | 任意規模(錢夠就行) |
| metadata 過濾 | SQL WHERE | 基本過濾 | 高效能,有專屬索引 | 有,但計費較貴 |
pgvector:如果你已經有 PostgreSQL,這是零額外成本的選擇。規模小、團隊只想維護一個資料庫時很香。超過 500 萬向量後查詢速度明顯下滑,此時換專用向量庫比較實際。
Chroma:Python 生態最友善,幾行程式碼就能跑起來。拿來做原型、跑實驗首選。2026 年仍然沒有官方生產託管服務,自己上雲要解決高可用和備份問題——不建議生產環境直接用。
Pinecone:業界老牌,2024 年轉 Serverless 架構後按量計費(Write $0.0000004/WU、Read $0.00000025/RU、Storage ~$3.60/GB/月),不用預設容量。Free tier 給 1 個 index 和 2 GB 儲存空間(約 35 萬筆未壓縮向量),夠玩但不夠跑生產。完全不用自架是它最大優勢,缺點是貴、資料在對方雲端。
Qdrant:純向量引擎、開源(Apache 2.0),Rust 寫成所以效能強悍——OSS 方案裡查詢延遲最低約 12 ms。本機跑 Docker 開發,搬上 Qdrant Cloud 或自己的 VPS 都很順。metadata 過濾是強項:可以在 payload 欄位上建專屬索引,讓過濾 + 向量搜尋一起跑不互相拖累。
本課選 Qdrant 的理由
- 本機開發 0 成本:Docker 一行指令跑起來,不需要帳號也不需要信用卡。
- 過濾效能強:RAG 的現實場景幾乎都需要「只在這個使用者的文件裡搜、或只在這個分類裡搜」,pgvector 和 Chroma 在大資料量加過濾條件時效能明顯降低,Qdrant 在這塊有明確的設計。
- 本機 → 雲端路徑順暢:程式碼幾乎不改,換一行連線設定就從本機切到 Qdrant Cloud,之後要自架 VPS 也是同樣的 Docker 映像檔。
- Python client 設計成熟:型別提示完整、upsert/query 語義清楚,不容易犯錯。

手把手實戰
以下把 100 份文件入庫並能查詢,完整走一遍。
安裝依賴套件、啟動 Qdrant
pip install qdrant-client openai python-dotenv
用 Docker 在本機啟動 Qdrant,資料持久化到 ./qdrant_data:
docker run -d \
--name qdrant \
-p 6333:6333 \
-v $(pwd)/qdrant_data:/qdrant/storage \
qdrant/qdrant
啟動後用瀏覽器開 http://localhost:6333/dashboard 可以看到 Qdrant 的管理介面——Collection 列表、向量數量都在這裡查。
建立 .env:
OPENAI_API_KEY=sk-...
建立 Collection
Collection 相當於關聯式資料庫的 table,建立時必須指定兩個關鍵參數:向量維度和距離度量。
# create_collection.py
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams
qdrant = QdrantClient(host="localhost", port=6333)
COLLECTION_NAME = "my_docs"
VECTOR_SIZE = 1536 # text-embedding-3-small 輸出維度
# 如果 collection 已存在就刪掉重建(開發階段方便重跑)
if qdrant.collection_exists(COLLECTION_NAME):
qdrant.delete_collection(COLLECTION_NAME)
qdrant.create_collection(
collection_name=COLLECTION_NAME,
vectors_config=VectorParams(
size=VECTOR_SIZE,
distance=Distance.COSINE, # embedding 模型用 cosine,dot product 要正規化向量
),
)
print(f"Collection '{COLLECTION_NAME}' 建立完成")
距離度量的選法:OpenAI 和 Voyage 的 embedding 輸出向量已正規化,用 COSINE 或 DOT 效果相同——用 COSINE 最保險,不會因為向量大小差異影響相似度計算。
準備 100 份文件並批次 Embed + 寫入
這一步是整個 RAG pipeline 裡最耗時的——Embed 要呼叫 API、費用也在這裡發生。我們用 OpenAI text-embedding-3-small(每百萬 token $0.02,是 2026 年最經濟實用的選擇)。
先準備資料結構(用你自己的文件替換這個範例):
# docs 是你的 100 份文件,每份是一個 dict
# 這裡用假資料示範結構
docs = [
{
"id": i,
"content": f"這是第 {i+1} 份文件的正文內容,談到某個主題的具體說明...",
"source": f"doc_{i+1:03d}.txt",
"category": "技術文件" if i % 2 == 0 else "使用說明",
}
for i in range(100)
]
然後批次取得 embedding 並寫入 Qdrant:
# ingest.py
import os
from dotenv import load_dotenv
import openai
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct
load_dotenv()
openai_client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
qdrant = QdrantClient(host="localhost", port=6333)
COLLECTION_NAME = "my_docs"
EMBED_MODEL = "text-embedding-3-small"
BATCH_SIZE = 50 # OpenAI 單次最多 2048 筆,50 一批安全又夠快
def embed_batch(texts: list[str]) -> list[list[float]]:
"""一次送多段文字,回傳對應的向量列表"""
response = openai_client.embeddings.create(
model=EMBED_MODEL,
input=texts,
)
# response.data 是有序列表,index 對應輸入 index
return [item.embedding for item in response.data]
# 把 docs 切成每批 BATCH_SIZE 筆
all_points = []
for batch_start in range(0, len(docs), BATCH_SIZE):
batch = docs[batch_start : batch_start + BATCH_SIZE]
texts = [doc["content"] for doc in batch]
print(f"Embedding {batch_start + 1}–{batch_start + len(batch)} / {len(docs)} ...")
vectors = embed_batch(texts)
for doc, vector in zip(batch, vectors):
all_points.append(
PointStruct(
id=doc["id"], # 整數 ID,Qdrant 也接受 UUID 字串
vector=vector,
payload={ # payload 存任意 metadata,之後用來過濾
"content": doc["content"],
"source": doc["source"],
"category": doc["category"],
},
)
)
# 批次寫入 Qdrant
for batch_start in range(0, len(all_points), BATCH_SIZE):
batch = all_points[batch_start : batch_start + BATCH_SIZE]
qdrant.upsert(collection_name=COLLECTION_NAME, points=batch)
print(f"已寫入 {batch_start + len(batch)}/{len(all_points)} 筆")
print("入庫完成!")
info = qdrant.get_collection(COLLECTION_NAME)
print(f"Collection 總向量數: {info.vectors_count}")
成功執行後你會看到:
Embedding 1–50 / 100 ...
Embedding 51–100 / 100 ...
已寫入 50/100 筆
已寫入 100/100 筆
入庫完成!
Collection 總向量數: 100
費用估算:100 份文件、每份平均 500 中文字 ≈ 800 token,總計約 8 萬 token。用 text-embedding-3-small 費用是 $0.02/MTok × 0.08 MTok = 不到 $0.002 美元。可以放心實驗。
語義查詢
查詢的邏輯:把使用者的問題 embed 成向量,然後在 Collection 裡找最相近的前 N 筆。
# query.py
import os
from dotenv import load_dotenv
import openai
from qdrant_client import QdrantClient
load_dotenv()
openai_client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
qdrant = QdrantClient(host="localhost", port=6333)
COLLECTION_NAME = "my_docs"
EMBED_MODEL = "text-embedding-3-small"
def embed_query(text: str) -> list[float]:
response = openai_client.embeddings.create(model=EMBED_MODEL, input=text)
return response.data[0].embedding
query_text = "如何設定系統環境變數?"
query_vector = embed_query(query_text)
results = qdrant.query_points(
collection_name=COLLECTION_NAME,
query=query_vector,
limit=5, # 取前 5 筆
with_payload=True, # 把 metadata 也帶回來
).points
for i, r in enumerate(results, 1):
print(f"#{i} [相似度={r.score:.4f}] 來源:{r.payload['source']}")
print(f" {r.payload['content'][:120]}...")
print()
r.score 是 cosine 相似度,1.0 = 完全相同,實際上 0.75 以上通常已是高度相關。
加上 metadata 過濾
純語義搜尋有時太廣:你希望只在「技術文件」這個分類裡搜,或只搜某個使用者上傳的文件。這時要用 metadata 過濾。
重要前置動作:要讓過濾走索引而不是掃描,先幫要過濾的欄位建 payload index:
# 只需建一次,建完 Qdrant 會自動維護
from qdrant_client.models import PayloadSchemaType
qdrant.create_payload_index(
collection_name=COLLECTION_NAME,
field_name="category",
field_schema=PayloadSchemaType.KEYWORD, # 字串完全匹配用 KEYWORD
)
建好後就可以在查詢時帶過濾條件:
from qdrant_client.models import Filter, FieldCondition, MatchValue
results_filtered = qdrant.query_points(
collection_name=COLLECTION_NAME,
query=query_vector,
query_filter=Filter(
must=[
FieldCondition(
key="category",
match=MatchValue(value="技術文件"),
)
]
),
limit=5,
with_payload=True,
).points
print(f"過濾後找到 {len(results_filtered)} 筆(僅限「技術文件」分類)")
Filter 支援 must(AND)、should(OR)、must_not(NOT)的組合,也支援數值範圍過濾(例如 created_at >= 某個時間戳記)——之後第 5 課 hybrid search 會用到。

完整流程總覽

常見坑
坑 1:寫入時出現 ValueError: Vector dimension mismatch
qdrant_client.http.exceptions.UnexpectedResponse:
status_code: 400, reason: Bad Request
text: {"status":{"error":"Wrong input: Vector dimension mismatch ...
expected dim: 1536, got: 3072"},...}
原因:建 Collection 時設 size=1536,結果 embed 用的模型輸出 3,072 維(例如 text-embedding-3-large)。維度一旦建好就不能改,只能刪掉重建。解法:建 Collection 之前先確認模型輸出維度。快速確認指令:
test = openai_client.embeddings.create(model="text-embedding-3-small", input="test")
print(len(test.data[0].embedding)) # 應印出 1536
然後 Collection 的 size 填這個數字。
坑 2:查詢分數全部很低,最高才 0.3 左右
症狀:有回傳結果,但 r.score 全都在 0.2–0.4 之間,感覺毫不相關。最常見原因是距離度量設錯:建 Collection 時用了 Distance.EUCLID(歐氏距離),但 embedding 向量適合 Distance.COSINE。歐氏距離越小越相似,Qdrant 轉換成 score 時公式不同,數值沒有可比性。
另一個情況:入庫的文字語言和查詢語言不一致——例如文件都是英文、查詢用中文。Embedding 模型本身有跨語言能力,但同語言效果更好。先確認文件語言再調整。
坑 3:加了 payload 過濾後,結果筆數比預期少很多或直接回 0 筆
常見有三個子問題:
payload 欄位名打錯:Qdrant 不會報錯,只會把不符合條件的全部過濾掉。檢查方法:查一筆資料看實際 payload key:
pt = qdrant.retrieve(COLLECTION_NAME, ids=[0], with_payload=True) print(pt[0].payload) # 確認真正的 key 名稱沒建 payload index 導致過濾超慢或走暴力掃描:有建 index 的欄位過濾走 O(log n),沒建的走 O(n)。資料量小感覺不出來,超過 10 萬筆就會顯著拖慢查詢。記得對每個常用過濾欄位跑一次
create_payload_index。MatchValue 大小寫:Qdrant 的 KEYWORD 型別是大小寫敏感的完全匹配。
"技術文件"和"技術文件 "(多一個空格) 是不同的值,寫入前先做.strip()清洗。
坑 4:Docker 重啟後資料消失
如果你啟動 Qdrant 時沒掛 volume,Container 刪掉資料就不見了。確認啟動指令有 -v $(pwd)/qdrant_data:/qdrant/storage。已經踩坑的話:資料已不可恢復,只能重新執行 ingest.py。之後如果是 Qdrant Cloud 就不用擔心這個問題。
作業
- 找 100 份你自己領域的文件(txt、PDF 轉文字都行),把全文入庫。查詢時測試 5 個問題,確認結果跟預期相關。
- 在 payload 裡加一個
source_file欄位,建好 payload index 後,試著只在某一份文件裡搜尋——這是後續 RAG 裡「限制搜尋範圍給某使用者」的核心操作。 - 選做:把 Qdrant 客戶端改連到 Qdrant Cloud 的免費 Cluster(1 GB 免費),程式碼只需改一行連線:
確認資料在本機和雲端都查得到。qdrant = QdrantClient( url="https://xxxx.us-east4-0.gcp.cloud.qdrant.io", api_key=os.getenv("QDRANT_API_KEY"), )
下一課預告
現在你的 100 份文件已經躺在向量資料庫裡,理論上可以查了——但實際測試時你可能發現:有些問題明明文件裡有答案,卻查不到;或是回傳了 5 筆,但前 3 筆根本不相關。
問題出在第 2 課留下的未解之謎:你的文件是怎麼切塊的? 把一整份 PDF 塞進一個向量?還是每段切成 300 字?切塊的邊界在哪、前後要不要重疊?這些決策對 RAG 精準度的影響,比換一個更貴的 embedding 模型還大。第 4 課——切塊策略:RAG 成敗的隱形關鍵——我們把這個問題從頭挖清楚。