把一整篇文件直接丟給 embedding 模型通常行不通:模型能吃的長度有限(早期小模型甚至只有 512 tokens),就算硬塞得下,效果也差——問題如果只跟文件裡一小段有關,卻要模型把整篇的意思壓成一個向量,關鍵細節很容易被稀釋掉。
做法是先把長文字切成一小段一小段,每一段叫一個 chunk,各自轉成向量存起來。使用者發問時,系統只要找出「哪幾個 chunk 跟問題最相關」,把那幾段內容交給 LLM,不用整篇文件都塞進去。
Chunk 要切多大
常見的切法是照字數、token 數或句數:
字數:約 200~500 字一塊
Token 數:約 200~500 tokens 一塊(GPT 系列習慣用 token 計)
句數:每 3~5 句一塊
大小抓不好,兩邊都有代價:切太短,每個 chunk 資訊量不夠,模型答題時常常沒有足夠上下文;切太長,可能超過模型能吃的長度,就算沒超過,向量也會被裡面太多不同主題稀釋,搜尋時比對不準。
實務上會抓一個「最大長度」和「最小長度」,不同用途落點也不一樣:
| 目標 | Chunk 大小建議 |
|---|---|
| RAG 問答 | 200~400 tokens |
| 文件摘要、總結 | 400~800 tokens |
| 用大 context window 的模型(GPT-4 Turbo、Claude 3 這類) | 500~1,000 tokens 也可以 |
為什麼要重疊(overlap)
Chunk 之間通常會留一點重疊,比例大概 10%~30%:上一塊的結尾,會出現在下一塊的開頭。原因很直接——如果切點剛好落在一句話中間,答案需要的資訊可能被硬生生拆到兩個 chunk,兩邊都不完整。留一小段重疊,可以讓交界處的內容至少完整出現在同一個 chunk 裡一次。
兩種切法:固定長度 vs 照句子切
固定長度切割最簡單:設定 chunk_size 和 overlap,直接照字數位置切,不管切點落在哪裡。
def chunk_text(text, chunk_size=300, overlap=50):
"""把 text 切成多個 chunk,每個約 chunk_size 字,並保留 overlap 重疊。"""
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunks.append(text[start:end])
start = start + chunk_size - overlap
return chunks
缺點是切點可能落在一句話正中間,把語意切斷。改良版是先照標點符號斷句,再把句子湊到接近 chunk_size 的長度,盡量不把一句話拆成兩半:
import re
def smart_chunk_text(text, chunk_size=300, overlap=50):
"""先斷句,再把句子湊到接近 chunk_size,避免切斷句子。"""
sentences = re.split(r'(?<=[。!?\n])', text)
chunks = []
current_chunk = ''
for sentence in sentences:
if len(current_chunk) + len(sentence) <= chunk_size:
current_chunk += sentence
else:
chunks.append(current_chunk)
current_chunk = current_chunk[-overlap:] + sentence
if current_chunk:
chunks.append(current_chunk)
return chunks
| 方法 | 優點 | 缺點 |
|---|---|---|
| 固定長度切 | 簡單、速度快 | 可能切到一半斷句 |
| 照句子切(智能分段) | 語意完整,查詢準確率較高 | 要多做斷句處理,稍慢一點 |
Token 是什麼,為什麼要管它
Token 不是一個字,也不是一個詞,是模型讀文字時切出來的最小單位。中文大致「一個字 ≈ 一個 token」,英文則常常一個單字算一個 token,有些長單字甚至會拆成兩個以上。
早期不少小模型一次只能讀 512 tokens 以下,超過的部分會被砍掉,甚至直接報錯。所以「chunk 都小於 512 tokens」這句話,意思就是每塊切出來的文字長度都要控制在模型讀得完的範圍內。
以下是 2025 年中的參考數字,模型上下文長度更新很快,實際設計時要查當時的官方文件,不要照抄:
| 模型 | 最大 Context 長度 | 大約中文字數 |
|---|---|---|
| GPT-3.5 Turbo(2023 版) | 4,096 tokens | 約 3,000~3,500 字 |
| GPT-3.5 Turbo-1106 | 16,385 tokens | 約 12,000 字 |
| GPT-4(原版) | 8,192 tokens | 約 6,000 字 |
| GPT-4 Turbo | 128,000 tokens | 約 10 萬字,接近一本書 |
| Claude 2 | 100,000 tokens | 適合長文整理 |
| Claude 3 Opus | 200,000 tokens | 可放很長的知識庫 |
| Gemini 1.5 Pro | 1,000,000 tokens(理論值) | 實際使用仍會受限制 |
即使模型能吃的長度變大了,chunk 也不會因此不用切:context 越長,回答時要塞進去的無關內容也越多,成本更高,模型也更容易「迷失焦點」。切 chunk 真正在做的事,是先把跟這個問題無關的資訊濾掉,不只是為了塞得進去。
完整流程:Chunk 之後接什麼
建立索引(事前,只做一次):文件切成 chunk → 每個 chunk 做 embedding → 連同原文一起存進向量資料庫(FAISS、Pinecone、Weaviate、Chroma 都是常見選擇)。
回答問題(使用者每問一次,跑一次):問題也做 embedding → 拿去向量資料庫做相似度搜尋,取出最相關的前 K 個 chunk → 把這些 chunk 當作 context,交給 LLM 生成回答。
KBQA 也要切 chunk 嗎
要。原因跟一般 RAG 一樣:知識庫通常很大,不可能整包塞給模型;有切 chunk 才能用向量搜尋快速鎖定跟問題相關的那一小段,回答也比較不容易失焦。不過怎麼切要看資料本身的結構:
| 知識庫類型 | 怎麼切 |
|---|---|
| 結構化(三元組,例如 Wikidata、RDF) | 一條三元組一個 chunk,例如「小明-發現-魔法水晶」 |
| 半結構化(FAQ、產品說明) | 一條 Q&A 一個 chunk,不用再拆 |
| 長文型(法律條文、書籍、技術文件) | 跟一般文件一樣,按 300~500 tokens、語意斷句、留 10~20% 重疊 |
範例:拿 Excel 問答表做比對
假設資料是一個 Excel 檔,兩欄「問題」「答案」,使用者輸入問題後要找出最相近的一列並回傳答案。這種情況不用切長文件,每一列本身就是一個 chunk。
流程:讀進 Excel → 把「問題」欄位做 embedding、建向量索引 → 使用者輸入新問題 → 一樣做 embedding → 向量搜尋找出最相近的原問題 → 回傳對應的答案。
import faiss
import pandas as pd
import numpy as np
from openai import OpenAI
client = OpenAI()
df = pd.read_excel("你的檔案路徑.xlsx") # 要有「問題」「答案」欄位
questions = df["問題"].tolist()
answers = df["答案"].tolist()
def get_embedding(text):
response = client.embeddings.create(model="text-embedding-3-small", input=text)
return np.array(response.data[0].embedding)
dimension = len(get_embedding("測試問題"))
index = faiss.IndexFlatL2(dimension)
embeddings = np.vstack([get_embedding(q) for q in questions])
index.add(embeddings)
def search_answer(query, top_k=1):
query_emb = get_embedding(query)
_, indices = index.search(np.array([query_emb]), top_k)
return [(questions[i], answers[i]) for i in indices[0]]
if __name__ == "__main__":
user_query = input("請輸入你的問題:")
for q, a in search_answer(user_query):
print(f"匹配問題:{q}")
print(f"回答內容:{a}")
這個版本有個明顯的坑:問題問得再模糊,也一定會回傳「最相近的那一條」,即使相似度其實很低。實務上會再加兩件事:
相似度門檻(threshold):低於門檻就不直接回傳答案,代表資料庫裡可能根本沒有這題。
Fallback 到 LLM:找不到夠相近的問題時,把整個知識庫當 context 丟給 LLM,讓它照著資料回答,同時明確要求「沒有資料就老實說沒有」,避免亂編。
def search_and_answer(user_query, threshold=0.8, top_k=3):
query_emb = get_embedding(user_query)
distances, indices = index.search(np.array([query_emb]), top_k)
# FAISS 回傳的是 L2 距離,這裡粗略轉成相似度分數,僅供參考
similarities = 1 - distances[0] / 4
for idx, sim in zip(indices[0], similarities):
if sim >= threshold:
print(f"匹配到問題:{questions[idx]}(相似度:{sim:.2f})")
print(f"回答內容:{answers[idx]}")
return
print("找不到夠相近的問答,改由 LLM 根據知識庫回答:")
knowledge = "\n\n".join(f"Q: {q}\nA: {a}" for q, a in zip(questions, answers))
prompt = f"""你是根據已知資料回答問題的 AI 助理。
以下是知識庫資料:
---
{knowledge}
---
現在有一個問題:{user_query}
請嚴格根據以上資料回答,如果資料中找不到相關資訊,請回答「資料中無此資訊」。"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
print(response.choices[0].message.content)
1 - distances[0] / 4 這個轉換只是抓 OpenAI embedding 向量的 L2 範數量級估出來的粗略估計,換一個 embedding 模型或算法,量級不一定一樣,門檻值都要重新試。
之後可以再往下做的
換更準的 embedding 模型,或找一個對中文效果更好的模型
多輪對話:把前一輪問答記下來,讓新問題可以帶上下文
資料量大時換用 Pinecone、Milvus 這類專業向量資料庫,而不是把全部向量塞進記憶體裡的 FAISS
包成小型服務(Streamlit 或 FastAPI),不只是本機腳本