首頁/人工智慧

RAG 怎麼切 Chunk:大小、重疊、兩種切法

2025年07月18日 人工智慧 RAG Embedding Chunking 向量搜尋 KBQA

把一整篇文件直接丟給 embedding 模型通常行不通:模型能吃的長度有限(早期小模型甚至只有 512 tokens),就算硬塞得下,效果也差——問題如果只跟文件裡一小段有關,卻要模型把整篇的意思壓成一個向量,關鍵細節很容易被稀釋掉。

做法是先把長文字切成一小段一小段,每一段叫一個 chunk,各自轉成向量存起來。使用者發問時,系統只要找出「哪幾個 chunk 跟問題最相關」,把那幾段內容交給 LLM,不用整篇文件都塞進去。

原始文本 從前從前,有一個小村莊……(一路到文章結尾) Chunk 1 Chunk 2 Chunk 3 重疊區 重疊區 每段結尾留一小截給下一段開頭,關鍵句子才不會卡在切點兩側被拆散

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
原文:小明每天都會去森林裡探險,他喜歡聽鳥兒唱歌。有一天他發現了山洞。 固定長度切法(每 20 字硬切) 小明每天都會去森林裡探險,他喜歡聽鳥兒唱 歌。有一天他發現了山洞。 ✕ 切在「唱歌」中間 語意斷句切法(在句號、問號處切) 小明每天都會去森林裡探險,他喜歡聽鳥兒唱歌。 有一天他發現了山洞。 ✓ 切在句號後面
方法 優點 缺點
固定長度切 簡單、速度快 可能切到一半斷句
照句子切(智能分段) 語意完整,查詢準確率較高 要多做斷句處理,稍慢一點

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 Embedding 寫入 Vector Database (chunk 原文+向量) 使用者問題 Embedding 向量相似度搜尋 前 K 個相關 Chunk LLM 根據 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),不只是本機腳本