昨天挑完 embedding:授權先看、max_seq_length 是硬上限、榜首不是你的榜首。今天換個更土的問題——到底要餵它多長的一段文字?
網路上的答案從 200 到 1,000 tokens 都有,每篇都講得很有信心。但中文有幾個坑,那些文章通常一句都沒提。
我一開始也以為,中文切不好只是因為句子之間沒有空白。解法看起來很簡單:把全形標點補進 separators。
我補了。
很好,句尾命中率變成 0%。
第一版的數字甚至更戲劇化:20% 掉到 0%。可惜那個 20% 根本不能用——分母只有 15 刀,連「句尾」的定義都放得太鬆。
所以我把語料擴到 30 篇,句尾只認句號、問號、驚嘆號和分號,後面可以再接收尾引號。重跑之後:
預設切法 531 刀,只有 15 刀落在句尾,命中率 2.8%;補上中文標點後,236 刀一刀都沒中。
(兩組的切法不同、分母也不同,這不是同一批邊界的成對前後比,只是兩種設定各自在這批語料上的結果。)
原本的 20% 消失了,方向倒是沒變。預設切法本來就沒那麼好;只補標點,不只沒救,還把標點留錯邊。
至於為什麼會掉到 0%,答案藏在一個我以前根本沒看過的參數裡。
chunk 這顆旋鈕其實有三層:用什麼尺量、刀落在哪,最後才是切多大。 大部分人卻直接從第三層開始轉。
先問一個聽起來很無聊、但決定後面一切的問題:你說的「200 tokens」,是誰數的 200?
我拿本系列 Day 10 那篇的散文當語料,抽出裡面 1,999 個漢字,餵給四個 tokenizer 數。

圖 1:granite-r2 與 bge-m3 在這份純中文樣本上的計數只差 1.3%,所以兩條幾乎一樣長。
cl100k_base 數出來每個漢字要 1.47 個 token,而圖上那兩顆地端 embedding 模型約是 0.76~0.77。兩端最多差 1.94 倍。(圖上那條 o200k_base 是 OpenAI 比較新的那把尺,放進來是想看看「同一家換代」差多少——0.95,剛好卡在中間。)
這裡順手更正一個很多人(包括我)記錯的事:很多 OpenAI 相關範例會指定用 cl100k_base,但它不是 LangChain 不帶參數時的預設。from_tiktoken_encoder() 不帶參數,用的是 gpt2。而 RecursiveCharacterTextSplitter 完全不帶參數時是 4000/200,那個長度還是拿 len() 算的——算字元,不是算 token。
這不是模型突然變笨。是你換了一把尺,還以為刻度沒變。
實務上最容易踩的是換算這一步:同一段純中文,用 cl100k_base 數是 512 tokens,換 bge-m3 的 tokenizer 重數約為 264。 內容沒變。變的是尺。所以你以為自己在調 512 那一格,對這顆 embedder 來說其實是 264 附近。
所以原則很簡單:切 chunk 的尺,要用你那顆 embedder 自己的 tokenizer,而且結果要寫明是哪一顆數的。不寫,別人重現不了,你自己換模型那天也對不上。
不過這裡其實有兩把尺,很容易混成一把:
兩顆模型的 tokenizer 不同,同一段文字的長度就不同。後面算 parent 預算時用的是第二把。
有個好消息。昨天那張表裡,成熟基準 bge-m3 和現役候選 granite-embedding-311m-multilingual-r2,在這份純中文樣本上的計數只差 1.3%。換過去可以先沿用原本的設定。
但別把「數字接近」當成「同一把尺」。兩者的 tokenizer 並不同源:granite-r2 用的是 ModernBERT 架構,搭配約 262K、由 Gemma 3 延伸而來的 tokenizer;bge-m3 則是 XLM-RoBERTa 系。逐字切法也不一樣。
差別在這份中文散文裡被平均掉了,換成型號就現形——排除模型自動加的特殊 token 後,PROD-SKU-7842X 在兩者分別是 11 個與 7 個 token。
所以正式語料混進程式碼、英文與型號之後,上線前還是自己重數一次。
今天的實測都是用
bge-m3這把尺跑的。選它不是選型結論——昨天那張表已經把它放在「2024 世代的成熟基準、不是優先候選」那一格。選它是因為它一顆同時吐 dense、sparse 與 ColBERT,而明天講 hybrid 要接那條 sparse。要挑一顆上線,回昨天那張表。
尺對了,接著是開頭那個 2.8%。
先講清楚我量的是什麼,因為這個指標很容易被說得太滿。判定式就這一行:
[。!?;] 後面可以再接 」』)》】 之類的收尾引號,然後就是這一塊的結尾
所以它叫句尾命中率,不叫「邊界正確率」。它沒有判斷語意完不完整、標題有沒有跟著內文走,也沒有處理表格和清單——只是一個很粗、但你可以自己重跑的代理指標。
而且它對兩種情況很嚴格:一段話以「條件如下:」結尾、後面接一張清單;還有句尾剛好被 markdown 粗體包住(。**)。兩種在我這個判定裡都算沒命中,但其實都是合理的切點。所以下面第四、五種切法的數字,是被低估的。
語料是本系列另外 30 篇文章的散文(排除今天這篇,不然就是拿自己量自己),把空白跟換行全部抽掉,變成連續中文——模擬從 PDF 直接抽出來的那一大團字。切 400 tokens,不重疊,每篇最後一塊不計。
先說清楚它的極限:這 30 篇是同一個作者、同一個系列、文體相近的散文。 它只能說明這條 pipeline 在這批語料上的行為,不代表所有中文 PDF 都會得到 2.8%。它夠用來推翻我那個六刀樣本,不夠用來當通則。

圖 2:分母差很多,因為不同切法切出來的刀數本來就不一樣。
照預設切是 15/531。補上中文標點是 0/236——不是趨近於零,是一刀都沒有。
標點是補了。可惜補到下一塊去了。
真凶是一個沒什麼人看的參數:keep_separator。RecursiveCharacterTextSplitter 把它預設成 True,意思是「分隔符留給下一塊的開頭」。
這不是推測,直接看切出來的文字就知道。同樣這 30 篇、同樣案例②的切法,排除每篇的第一塊之後(它沒有前一塊,本來就不可能),剩下的 206 塊全部以標點開頭——206/206。被選作切分點的那個標點,會被留在下一塊的開頭。
那英文為什麼沒人被咬到?因為 LangChain 預設的分隔符是換行與空白,而 strip_whitespace 也預設 True——被推到下一塊開頭的空白,在合併時直接被 strip 掉了。
我拿同一批語料驗過:用預設分隔符切,keep_separator=True 與 "end" 切出來的塊逐字完全相同,268 塊裡也沒有任何一塊以空白開頭。在這種以換行為主要邊界的情況下,這個參數根本看不出差別。
標點不是空白,strip 不掉。 所以你一把它加進分隔符,它就現形了——任何語言都一樣。
把它改成 keep_separator="end",標點留在原本那一句的尾巴——232/232,一刀都沒漏。
from transformers import AutoTokenizer
from langchain_text_splitters import RecursiveCharacterTextSplitter
tok = AutoTokenizer.from_pretrained("BAAI/bge-m3") # 用你自己那顆 embedder 的尺
NL = chr(10)
SEPS = [NL * 2, NL, "。", "!", "?", ";", ",", ""]
sp = RecursiveCharacterTextSplitter.from_huggingface_tokenizer(
tok, chunk_size=400, chunk_overlap=0,
separators=SEPS,
keep_separator="end") # ← 少這一行,上面整組白補
但圖 2 最值得看的其實是最下面那條,而且它不是接在第三步後面的下一步,是另一條路。
splitter 完全不改,只要別把換行洗掉,就有 163/268(60.8%)。
同一份文字,保留 markdown 的段落換行,用英文預設的分隔符去切,\n\n 就會在段落邊界上把它斷開。分隔符清單裡的空白項根本輪不到。
所以我一開始的診斷是錯的。問題不在中文沒有空白,在我自己先把換行洗掉了。 那個慘不忍睹的 2.8%,是我把一份有結構的文件壓成一團字之後的結果。
那「兩件都做」會不會更好?我本來想直接寫「當然會」,但那是推論不是實測,所以補跑了第五組:保留換行 + 補中文標點 + keep_separator="end"。
結果是 163/268,跟只保留換行一模一樣,一刀都沒變。
原因是分隔符清單依序試,\n\n 排在最前面。在這 30 篇裡,1,562 個段落只有 2 個超過 400 tokens——幾乎每段都在換行那一層就切完了,所以後面的標點一刀都沒改到。
但這不是通則:如果你的單一段落本身就超過 chunk 上限,splitter 還是會遞迴下去,那時候標點與 "end" 就會生效。
所以正確的講法是:在這批語料裡,keep_separator="end" 主要是結構遺失之後的救援;保留換行時,它沒有再改善任何一刀。
那沒命中的那 105 刀又是什麼?我原本想寫「很多是冒號結尾」,但那是猜的,所以逐刀分類了一次:
\n\n 剛好切在標題後面,把「小結」「今天的實驗需要什麼」這種標題孤零零留在塊尾,跟它要標的內容拆開了。。**)14 刀(13%):這 43 刀是判定式與語料格式造成的假陰性。所以④那個 60.8% 確實被低估了,但最大的一桶不是誤判——是標題被切走了。而那是換行分隔符自己造成的,不是判定式的問題。
保留換行只是第一關,不代表結構就切對了。 標題不該自己留在上一塊。實務上兩條路:把 heading 跟它後面的第一段綁在一起再送進 splitter,或是把完整的 section path 寫進每個 child 的 metadata。句尾命中率變好,不等於邊界已經正確。
掃描件、表格和多欄排版還有更前面的一關:解析。別拿檔案大小當分類器;先看逐頁字數、空白頁比例與閱讀順序。解析都錯了,後面切幾刀沒有意義。(本系列賽內沒有專篇,排在加碼篇。)
前面兩層都對了,才輪到大家最愛調的 chunk size。刀落在哪都還沒看,就先別急著轉這顆旋鈕。
這裡採用一組條件與原始資料都攤得比較清楚的公開實驗,Chroma 做的。條件要先攤開,它比數字本身重要:Chroma 自己改過的 LangChain 遞迴 splitter(RecursiveTokenChunker)、text-embedding-3-large、每次固定取 5 塊、5 個英文語料庫共 472 題的平均。
那個「改編」要講清楚,因為它剛好踩在今天的前兩層上。Chroma 明說 LangChain 的預設分隔符「常常切出很短的塊」,所以把 . ? ! 加了進去,長度也改成用 cl100k token 算、不是字元。第一層的尺,他們修了。
但他們沒有把 keep_separator 改成 "end"。 我把那支 chunker 抓下來跑過:keep_separator 仍是預設的 True,分隔符照樣被接到下一塊的開頭。
換句話說,連這組公開實驗都補了標點,卻一樣把標點留在下一塊——今天的標題在這裡又應驗了一次。
這不會讓它公布的 recall/precision 失效,那就是那份實作實際跑出來的成績。但解讀時要知道:它測的不是案例③那種「標點留在前一句尾端」的切法。(另外它的分隔符是半形 . ? !,對中文的 。!? 不會命中。)
還有一件事得先講,不然兩頭都會害到你——它量的不是「有沒有撈到正確那一塊」:
所以看到 precision 7%,正確的讀法是「塞給 LLM 的 context 有 93% 不在標註的答案 excerpt 裡」。這跟「93% 是雜訊」不一樣——標註的 excerpt 不見得窮盡了所有有用的內容,原報告自己也把「可能漏標其他相關段落」列進限制。
反過來也一樣:看到 recall 88% 就以為「88% 會答對」,那是把 token 覆蓋率當答對率——中間還隔著 Day 22 那七層裡的組裝跟生成兩層。

圖 3:第一欄那兩行小字是關鍵——200 和 400 是上限,實際切出來只有約 137 和 276。
chunk_size 是上限不是實際長度,這件事在自己的語料上也看得到:我用同一支 splitter 切,上限 200 的實際平均是 165、上限 400 是 375,各佔上限的八到九成。Chroma 那組英文語料落差更大,報告自己在表上標了 ~137 和 ~276。
原報告沒有公布每一題實際撈回多少 token。暫時拿全庫平均長度當代表值:137 × 5 塊 ≈ 685,276 × 5 塊 ≈ 1,380。再乘上平均 precision,落在標註 excerpt 裡的 token 大約從 48 變成 50。
context 粗估翻了一倍,命中標註答案的 token 只多了約兩個。
(這四個數字都是「平均 × 平均」的量級估算。retriever 有可能偏好撈長的或短的 chunk,那會讓實際的 context 跟這個粗估有出入。)
至於 recall,200 與 400 兩組的平均只差 1.4 個百分點,而 precision 從 7.0 掉到 3.6。表上那個 ± 是跨 query 的標準差,不是信賴區間;原報告在這張表也沒有給成對的顯著性檢定。所以這裡只描述平均差距,不宣稱顯著,也不宣稱不顯著。
這就是主論點,而且要講得比直覺精確一點:把 chunk 開大,最確定買到的是更多 context;能不能換到 recall,要看你的資料。
至於第三根 800/400,它有兩層要拆開。這組參數剛好也是 OpenAI Vector Stores 目前的預設值;但 Chroma 測的是它自己改編過的 splitter(分隔符與長度單位都動過),參數設 800/400,不是 OpenAI 內部的實作。
所以只能說「同一組參數在這個實驗上得到 precision 1.5」,不能拿來當 OpenAI File Search 的成績單。而且它帶 400 的 overlap,跟前兩組條件不同,本來就不能用來隔離 chunk size 的效果。
那切小總可以吧?也不行。而且兩端壞掉的機制完全不一樣——這正是「在中間找個折衷」永遠不會滿意的原因。
太小,壞在語意。 一句「其溫度上限為 250°C」,「其」指的是誰留在上一塊了。這塊向量不知道自己在講哪台設備。條件在 A 塊、結論在 B 塊,只命中一塊就會答錯。
太大,壞在向量本身。 一塊 chunk 不管多長,最後都被壓成一個固定維度的向量。這個 pooling 是有損的:塞進去的局部語意越雜,這一個向量就越難同時貼近裡面每一句話。有研究也觀察到長文本會讓向量之間的平均距離縮短,以及首、中、尾的代表性不均等。
但這些效應不是每顆模型都同等程度發生,跟訓練資料與 pooling 方式有關。論文可以告訴你方向;真要決定參數,還是得拿自己的模型與語料量。
下游還有兩刀在等:塞不進 top-k,以及 context 一長,模型對中段的存取會變差(那條規律的成立範圍有前提,明天講 top-k 時再收)。
所以沒有一個跨文件、跨任務都適用的最佳值。因為你要的根本不是一個數字。
把張力攤開就很清楚了。檢索偏小,語意才集中、向量才不被稀釋;生成要完整脈絡,LLM 才答得對。
硬要一個 chunk 同時負責檢索與生成,最後通常兩邊都只做到一半。

圖 4:只有 child 被 embed,parent 留在一般儲存體裡。
文件不同,適合的做法也不同:長手冊可以先拿 Parent-Child 當 baseline;Sentence Window 適合條文、規格、FAQ;Auto-Merging 適合章節結構清楚的長文件。這對長手冊很好用,但不是所有資料的唯一答案。
三個一定會踩的細節:
送進 LLM 前,先依 parent_id 去重。 20 個 child 可能只對應到 5 個 parent。不去重,同一段 parent 最多會在 prompt 裡佔掉四份預算——名額跟 token 都被自己吃掉。
child 的 top-k 要刻意開大。 可以先用目標 parent 數的三到四倍起跑,再拿評估集調——名額會被同一個 parent 底下的 child 吃掉。
parent 多大要算,不是憑感覺填。 而且要用生成模型那把尺算:
每個 parent 的粗略上限
≈ (生成端能分給檢索內容的 token 預算 - metadata 與引用的開銷) ÷ 目標 unique parent 數
「能分給檢索內容的預算」不等於 context window——system prompt、使用者問題、預留給輸出的空間都要先扣掉。扣完剩 8K、想要 5 個 unique parent,那 parent 就不能超過 1,600 上下。設太大,等於根本沒解耦。
今天不用 GPU,全部是 CPU 工作。腳本、語料、鎖定的套件版本與 tokenizer revision 都放在 research/day24/——這篇談的正好是套件預設值,不鎖版本,下次改版就對不上了。
pip install -r research/day24/requirements.txt
python3 research/day24/run.py
換成自己的資料前,先做兩件事:重算 tokens/漢字,然後確認解析結果還留著換行。五分鐘的事,但圖 2 那 2.8% 對 60.8% 的差距就在這裡。
還有一件,是我今天自己踩到的:量之前先確認分母夠大。 一篇文章切 400 tokens 只切得出十幾刀,比例會跳得很誇張。今天卡住我的不是硬體,是分母。
至於 200/400/1,000 三檔在中文語料上的 recall 與 precision 複測,我今天沒給。那個要有 50 到 100 組「問題+標註答案 span」才做得了,而標註是人工活、卡的不是硬體。沒有評估集就沒有這組數字,我不寫一個猜的上去。
chunk 不是先問 200、400 還是 1,000。順序應該是:
用什麼尺量 → 刀落在哪 → 最後才是切多大。
cl100k_base 與 bge-m3 數出來可以差 1.94 倍;child 用 embedder 的 tokenizer,parent 與整包 context 則用生成模型的。keep_separator 把標點留在哪一邊。parent_id 去重。我今天真正修掉的,不是一個 chunk size。
是我原本從第三層開始調,前兩層卻根本沒看。
| 用在哪 | 出處 |
|---|---|
| 四把尺、五種切法的句尾命中率、cap 對實際長度 | 本系列一手實測,research/day24/,可重跑 |
LangChain 的 gpt2 預設、4000/200 用 len()、keep_separator 預設 True |
直接讀安裝版的 langchain_text_splitters 原始碼 |
| recall / precision 三檔、實際平均長度、5 語料庫 472 題、圖 3 | Chroma, “Evaluating Chunking Strategies for Retrieval”, 2024-07 |
| 800/400 也是 OpenAI Vector Stores 目前的預設 | OpenAI Retrieval 文件(但 Chroma 測的是它自己改編的 splitter) |
| 長文本 pooling 的語意稀釋、length collapse、位置偏誤 | 學界結論,本篇只取方向、不引數值,並標明非所有模型同等程度 |
| Sentence Window 的檢索精度較佳但答案品質不穩 | ARAGOG(arXiv:2404.01037),只引定性結論 |
「parent retrieval 能提升 15–30%」那類數字出自單一部落格,我沒有採用。
明天 Day 25〈文件一多就完蛋:top-k 稀釋、專有名詞失效與 Hybrid Search〉:今天調的都還是一份文件裡面的旋鈕。明天文件從 10 份變成 1,000 份,你會發現 chunk size 怎麼轉都救不回來——因為那是文件之間的病。還有中文 hybrid 那個幾乎沒人講的斷詞坑。
咱們明天見。