上一篇講了 RAG 的概念,這篇來動手——文件怎麼切、向量怎麼算、資料庫怎麼選。
先講結論
RAG pipeline 有四個步驟:解析文件 → 切段落 → 算向量 → 存資料庫。
其中 Chunking(切段落) 是品質瓶頸。很多人花大量時間選 Vector DB,卻用最簡單的切法。這就像買了頂級音響卻用 128kbps 的 MP3——浪費。
Step 1:文件解析——垃圾進、垃圾出
不同格式的處理難度差很多:
| 格式 | 難度 | 工具 | 注意 |
|---|---|---|---|
| Markdown | 低 | 直接讀 | 保留標題當 metadata |
| HTML | 中 | BeautifulSoup | 去掉 nav、footer 等噪音 |
| 高 | PyMuPDF, Unstructured | 表格和多欄排版是大坑 |
PDF 是最頭痛的。掃描的 PDF 需要 OCR,表格可能被拆成一堆散落的文字。如果你的知識庫主要是 PDF,建議在解析上多花點心力。
別忘了提取 metadata——來源檔案、頁碼、章節標題。這些後面做搜尋過濾和來源引用時超重要。
document = {
"content": "退貨期限為 14 個工作天...",
"metadata": {
"source": "company-policy.pdf",
"page": 5,
"section": "退貨政策",
}
}Step 2:Chunking——RAG 成敗的關鍵
為什麼切法很重要?
假設你有一段差旅政策:
員工出差可搭乘高鐵或飛機。國內出差以高鐵為主,航程超過 4 小時可搭乘飛機。搭乘飛機限經濟艙,主管級以上可搭乘商務艙。
如果你用 Fixed Size(每 50 字硬切),「經濟艙」可能被切成兩半。使用者問「出差可以坐商務艙嗎」,搜尋到的段落可能只有後半句,缺乏上下文。
四種切法比較
| 方法 | 優點 | 缺點 | 適合 |
|---|---|---|---|
| Fixed Size | 簡單 | 切斷語意 | 快速原型 |
| Sentence | 保留句子結構 | 大小不均 | 短文件 |
| Recursive(推薦) | 平衡語意與大小 | 需調參數 | 通用場景 |
| Semantic | 最佳語意品質 | 慢、需額外模型 | 高品質需求 |
Recursive Chunking 實作
先用段落分隔,不行再用換行,再不行用句號,最後才硬切:
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=200,
separators=["\n\n", "\n", "。", ",", " ", ""]
)
chunks = splitter.split_text(document_text)Chunk Size 怎麼選
- 太小(< 200 tokens):失去上下文,搜尋到也沒用
- Sweet spot(500-1000 tokens):大部分場景最佳
- 太大(> 2000 tokens):不相關的內容太多,稀釋相關性
Overlap 很重要:沒有 overlap 的話,跨段落的概念會被切斷。一般設 chunk_size 的 20-25%。
我的經驗值:
- 一般文件:
chunk_size=800, overlap=200 - 技術文件:
chunk_size=1000, overlap=200 - FAQ:
chunk_size=300, overlap=50
Step 3:Embedding——文字變向量
把每個 chunk 轉成向量。語意相近的 chunk,向量距離也相近。
選型建議
| 模型 | 維度 | 成本 | 中文表現 | 推薦場景 |
|---|---|---|---|---|
| OpenAI text-embedding-3-small | 1536 | $0.02/1M tokens | 好 | 快速起步 |
| OpenAI text-embedding-3-large | 3072 | $0.13/1M tokens | 好 | 需要最佳品質 |
| BGE-M3(本地) | 1024 | 免費 | 優秀 | 隱私需求、中文場景 |
我的建議:原型用 OpenAI 的 small,有隱私需求用 BGE-M3。 不需要一開始就用 large,差距沒有想像中大。
from openai import OpenAI
client = OpenAI()
def get_embeddings(texts: list[str]) -> list[list[float]]:
response = client.embeddings.create(
input=texts,
model="text-embedding-3-small"
)
return [item.embedding for item in response.data]成本很低
10 萬個 chunk × 平均 500 tokens = 5000 萬 tokens。用 OpenAI small 的話:
50M tokens × $0.02 / 1M = $1.00
一塊美金。建索引的 embedding 成本基本上可以忽略。終於有一個 AI 的東西不貴了。
Step 4:Vector DB——存放向量的地方
選型指南
| 資料庫 | 類型 | 最適合 |
|---|---|---|
| ChromaDB | 本地 | 原型開發、學習 |
| pgvector | PostgreSQL 擴充 | 已經在用 PostgreSQL |
| Pinecone | 全託管雲端 | 不想管 infra |
| Qdrant | 開源 | 高效能、進階過濾 |
| Milvus | 開源 | 超大規模(億級向量) |
我的推薦路線:
- 原型 → ChromaDB(零設定,pip install 就能用)
- 上線 → pgvector(如果你本來就用 PostgreSQL)或 Pinecone(不想管 DB)
- 大規模 → Qdrant 或 Milvus
pgvector 實作
如果你已經有 PostgreSQL,加一個擴充就搞定:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
content TEXT NOT NULL,
metadata JSONB DEFAULT '{}',
embedding vector(1536)
);
-- 建索引(重要!沒索引的話大量資料會很慢)
CREATE INDEX idx_docs_embedding ON documents
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
-- 搜尋最相似的 5 筆
SELECT content, metadata,
1 - (embedding <=> $1::vector) AS similarity
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 5;不用學新工具、不用新的 infra、連線方式跟你平常用 PostgreSQL 一模一樣。
完整流程串起來
# 完整的 indexing pipeline
from langchain.text_splitter import RecursiveCharacterTextSplitter
from openai import OpenAI
import chromadb
client = OpenAI()
db = chromadb.PersistentClient(path="./rag_db")
collection = db.get_or_create_collection("knowledge")
# 1. 讀文件
with open("company-policy.md") as f:
text = f.read()
# 2. 切段落
splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=200)
chunks = splitter.split_text(text)
# 3. 算向量 + 存進 DB
collection.add(
documents=chunks,
ids=[f"chunk-{i}" for i in range(len(chunks))],
)
print(f"Indexed {len(chunks)} chunks")就這樣。四個步驟,不到 20 行 code。先跑起來,有問題再調。
下一篇
文件切好、向量存好了,但搜尋品質怎麼調?怎麼知道你的 RAG 到底好不好?
Chunking 就像切菜——刀工好不好,直接決定炒出來的菜好不好吃。