上一篇講了 RAG 的概念,這篇來動手——文件怎麼切、向量怎麼算、資料庫怎麼選。

先講結論

RAG pipeline 有四個步驟:解析文件 → 切段落 → 算向量 → 存資料庫。

其中 Chunking(切段落) 是品質瓶頸。很多人花大量時間選 Vector DB,卻用最簡單的切法。這就像買了頂級音響卻用 128kbps 的 MP3——浪費。


Step 1:文件解析——垃圾進、垃圾出

不同格式的處理難度差很多:

格式難度工具注意
Markdown直接讀保留標題當 metadata
HTMLBeautifulSoup去掉 nav、footer 等噪音
PDFPyMuPDF, 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-small1536$0.02/1M tokens快速起步
OpenAI text-embedding-3-large3072$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本地原型開發、學習
pgvectorPostgreSQL 擴充已經在用 PostgreSQL
Pinecone全託管雲端不想管 infra
Qdrant開源高效能、進階過濾
Milvus開源超大規模(億級向量)

我的推薦路線:

  1. 原型 → ChromaDB(零設定,pip install 就能用)
  2. 上線 → pgvector(如果你本來就用 PostgreSQL)或 Pinecone(不想管 DB)
  3. 大規模 → 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 就像切菜——刀工好不好,直接決定炒出來的菜好不好吃。