精華筆記

· @aihub.tw

RAG 系統實作

向量資料庫:選型與實作

向量資料庫:選型與實作

第 2 課結束後,你已經能把一段文字送進 embedding 模型,拿回一串 1,536 維的浮點數向量。很好。但現在問題來了:你有 100 份文件、1,000 個段落、每段都要變成向量——這些向量要存在哪裡?查詢時怎麼在毫秒內找出「最像這個 query 向量的前 5 筆」?

用 PostgreSQL 的普通表存向量、然後對每一筆算餘弦相似度?如果只有幾百筆還行,一旦超過 1 萬筆你就會看到查詢從 20ms 爬到幾秒。向量相似搜尋是一個特殊的計算問題,需要專為它設計的索引結構——這就是向量資料庫(Vector Database)的存在意義。

這堂課適合誰 適合:想從零建立 RAG 系統的工程師或進階開發者(本課程屬工程師專區)。需要基礎:能看懂 Python、裝過 Docker、用過 pip 安裝套件、懂 `.env` 環境變數的概念。前置課:第 2 課(Embedding:把語意變成向量),需理解向量維度與相似度的基本概念。

這堂學什麼

  • 向量資料庫底層用的 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% 不影響最終回答品質。

HNSW 索引多層圖結構示意

Chroma 官網(2026 年 7 月實況) 圖: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 的理由

  1. 本機開發 0 成本:Docker 一行指令跑起來,不需要帳號也不需要信用卡。
  2. 過濾效能強:RAG 的現實場景幾乎都需要「只在這個使用者的文件裡搜、或只在這個分類裡搜」,pgvector 和 Chroma 在大資料量加過濾條件時效能明顯降低,Qdrant 在這塊有明確的設計。
  3. 本機 → 雲端路徑順暢:程式碼幾乎不改,換一行連線設定就從本機切到 Qdrant Cloud,之後要自架 VPS 也是同樣的 Docker 映像檔。
  4. Python client 設計成熟:型別提示完整、upsert/query 語義清楚,不容易犯錯。

Qdrant 本機到雲端路徑示意

手把手實戰

以下把 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 輸出向量已正規化,用 COSINEDOT 效果相同——用 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 會用到。

metadata 過濾 + 向量搜尋合併查詢流程

完整流程總覽

第 3 課完整 pipeline 示意

常見坑

坑 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 筆

常見有三個子問題:

  1. payload 欄位名打錯:Qdrant 不會報錯,只會把不符合條件的全部過濾掉。檢查方法:查一筆資料看實際 payload key:

    pt = qdrant.retrieve(COLLECTION_NAME, ids=[0], with_payload=True)
    print(pt[0].payload)  # 確認真正的 key 名稱
    
  2. 沒建 payload index 導致過濾超慢或走暴力掃描:有建 index 的欄位過濾走 O(log n),沒建的走 O(n)。資料量小感覺不出來,超過 10 萬筆就會顯著拖慢查詢。記得對每個常用過濾欄位跑一次 create_payload_index

  3. MatchValue 大小寫:Qdrant 的 KEYWORD 型別是大小寫敏感的完全匹配。"技術文件""技術文件 " (多一個空格) 是不同的值,寫入前先做 .strip() 清洗。


坑 4:Docker 重啟後資料消失

如果你啟動 Qdrant 時沒掛 volume,Container 刪掉資料就不見了。確認啟動指令有 -v $(pwd)/qdrant_data:/qdrant/storage。已經踩坑的話:資料已不可恢復,只能重新執行 ingest.py。之後如果是 Qdrant Cloud 就不用擔心這個問題。

作業

  1. 找 100 份你自己領域的文件(txt、PDF 轉文字都行),把全文入庫。查詢時測試 5 個問題,確認結果跟預期相關。
  2. 在 payload 裡加一個 source_file 欄位,建好 payload index 後,試著只在某一份文件裡搜尋——這是後續 RAG 裡「限制搜尋範圍給某使用者」的核心操作。
  3. 選做:把 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 成敗的隱形關鍵——我們把這個問題從頭挖清楚。

#RAG#向量資料庫#Qdrant#pgvector#Embedding

← 回所有文章