Skip to content

fix(pipeline): bound embed batches by UTF-8 byte count, not character count [pipeline, poller, docs, tests] - #245

Merged
smileygames merged 1 commit into
mainfrom
244-fixpipeline-bound-embed-batches-by-utf-8-byte-count-not-character-count
Aug 14, 2026
Merged

fix(pipeline): bound embed batches by UTF-8 byte count, not character count [pipeline, poller, docs, tests]#245
smileygames merged 1 commit into
mainfrom
244-fixpipeline-bound-embed-batches-by-utf-8-byte-count-not-character-count

Conversation

@smileygames

Copy link
Copy Markdown
Member

Closes #244

何を変えたか

planEmbeddingBatches の予算軸を UTF-16 code unit(文字数)から UTF-8 バイト数へ移した。

なぜ

#242 が置いた前提「1 token は入力の 1 文字以上に対応する」は byte_fallback で破れる。語彙に無い文字は UTF-8 バイト列へ分解されるため、3 バイトの日本語 1 文字が最大 3 token になる。

実測(2026-08-15 00:30 JST cron、#242 deploy 後):

Failed to batch-embed diffs for Liplus-Project/neuron-graph-rag@92eb94d
  chunk offset 0: 3030: Max context reached 68736 tokens but model supports only 60000

文字数で高々 60000 と課金した 1 batch に 68736 token が返っている(1 文字あたり 1.146 token)。

byte_fallback は tokenizer が作れる最も細かい分解であり、それ以下には割れない。したがって 1 token は入力の 1 バイト以上を必ず消費する。batch の合計 UTF-8 バイト数を天井(60000)以下に保てば、合計 token 数も無条件に天井以下になる。#241 が求めた「校正しない、構成上正しい」という性質を保ったまま、軸を 1 つ下げただけである。

変更点

変更 内容
MAX_EMBEDDING_BATCH_CHARS -> MAX_EMBEDDING_BATCH_BYTES 課金を utf8ByteLength(input) に変更。天井 60000 は据え置き
utf8ByteLength を追加 code unit から数えるので入力の複製を確保しない。孤立サロゲートは TextEncoder が置く U+FFFD と同じ 3 バイトとして数える(truncate が code unit 境界で切るため実際に発生しうる)。TextEncoder との一致をテストで固定
SPECIAL_TOKENS_PER_INPUT (2) -> TOKEN_OVERHEAD_PER_INPUT (3) sentinel 2 個(<s></s>)に加え、SentencePiece の語境界マーカーが先頭 piece に併合されない場合、入力バイトを消費しない token として 1 個現れる。極小 input が多数並ぶ batch ではこれが件数分積み上がるため、input ごとに課金する(#242 の判断を維持)
MAX_EMBEDDING_INPUT_CHARS の comment 「8192 token 相当」という根拠は同じ前提の上にあったので、事実に合わせて書き直した。値と挙動は変更していない(truncate 軸は別軸)

#240 の file 予算との整合(issue の必須検証項目)

1 batch あたりの最小 file 数が 7 -> 2 に下がる(1 input = 8000 文字 = 最大 24000 バイト、予算 60000)。

  • 按分 = 2 subrequest / 2 file = 1
  • 固定 = 2(D1 FTS mirror 書き込み、store row)
  • worst case = ちょうど 3 = DIFF_SUBREQUESTS_PER_FILE

したがって定数の変更は不要。ただし丸めの余裕(従来 2.29 -> 3)は無くなったため、この不変条件を poller.test.ts の実行可能な assertion にした。batch 軸をこれ以上絞ると invocation 予算を静かに超過するのではなくテストが落ちる。

6 repo 時の 1 phase の worst case: file 18 × 2 + batch 9 × 2 + overhead 20 = 74 <= 75(floor(900/12))。

テスト

  • utf8ByteLengthTextEncoder と全幅(ASCII / 2 / 3 / 4 バイト / 孤立サロゲート)で一致
  • 16 file の日本語 commit(文字数予算なら 1 call、実測で天井超過)が分割され、全 file が index される回帰ケース。fixture が「文字数予算なら通る」ゾーンに留まっていることも同時に assert しているので、後から patch サイズを変えても再現形状から静かに外れない
  • 非 ASCII を「読んだ長さ」ではなく「かかる費用」で課金することの確認(fix(pipeline): bound embed batches by character count, not estimated tokens [pipeline, poller, docs, tests] #242 が持っていた「同じ文字数なら同じ分割」という性質は、まさにそれが原因だったので反転させた)
  • 既存の契約(順序保持、range の連続性、単独超過 input の単独送信、input 配列と file 配列を同じ境界で切る)は不変

npm test = 345 tests pass、tsc --noEmit clean。

残る前提(明記した上で受け入れ)

「tokenizer の NFKC 正規化が入力をバイト数で膨らませない」ことだけが前提として残る。互換分解を持つ文字(アラビア語の合字、CJK 組文字)は例外だが、退けた 2 つの前提が通常の diff テキストで破れたのとは性質が異なり、こちらは入力の大半がその稀なブロックで占められている必要がある。隠さずコードと docs/0-requirements.md の両方に前提として書いた。

コスト

日本語主体の入力では 1 文字 3 バイトのため、文字数軸に比べ約 3 倍保守的になり batch 数が増える。これは正しさの代金として受け入れる(issue の方針どおり)。

… count [pipeline, poller, docs, tests]

`planEmbeddingBatches` の予算軸を UTF-16 code unit から UTF-8 バイト数へ移した。

#242 が置いた前提「1 token は入力の 1 文字以上に対応する」は byte_fallback で
破れる。語彙に無い文字は UTF-8 バイト列へ分解されるため、3 バイトの日本語 1 文字が
最大 3 token になる。実測は 2026-08-15 00:30 JST の cron で、文字数で高々 60000 と
課金した batch に 68736 token が返った(1 文字あたり 1.146 token)。

バイト数はその分解より下にある。byte_fallback が tokenizer の作れる最も細かい
分解であり、1 token は入力の 1 バイト以上を必ず消費するので、batch の合計
UTF-8 バイト数を天井(60000)以下に保てば合計 token 数も天井以下になる。#241 が
求めた「校正しない、構成上正しい」性質はそのままに、軸を 1 つ下げただけである。

変更点:

- `MAX_EMBEDDING_BATCH_CHARS` -> `MAX_EMBEDDING_BATCH_BYTES`、課金は
  `utf8ByteLength(input)`。`utf8ByteLength` は code unit から数えるので入力の
  複製を確保しない。孤立サロゲートは `TextEncoder` が置く U+FFFD と同じ 3 バイト
  として数える(truncate が code unit 境界で切るため実際に発生しうる)
- `SPECIAL_TOKENS_PER_INPUT` (2) -> `TOKEN_OVERHEAD_PER_INPUT` (3)。sentinel 2 個
  (`<s>` … `</s>`)に加え、SentencePiece の語境界マーカーが先頭 piece に併合され
  ない場合、入力バイトを消費しない token として 1 個現れる。極小 input が多数並ぶ
  batch ではこれが積み上がるため、input ごとに課金する(#242 の判断を維持)
- #240 の file 予算との整合: 1 batch の最小 file 数が 7 から 2 へ下がる
  (8000 文字 = 最大 24000 バイト、予算 60000)。按分は 2/2 = 1、固定 2 と合わせて
  worst case はちょうど 3 で、`DIFF_SUBREQUESTS_PER_FILE = 3` は変更不要。ただし
  余裕は無くなったので、その不変条件を `poller.test.ts` の実行可能な assertion に
  した
- 回帰テスト: 16 file の日本語 commit(文字数予算なら 1 call、実測で天井超過)が
  分割されること、`utf8ByteLength` が `TextEncoder` と全幅で一致すること

残る前提は「NFKC 正規化が入力をバイト数で膨らませない」ことのみで、互換分解を持つ
文字(アラビア語の合字、CJK 組文字)が入力の大半を占める場合にのみ問題になる。
退けた 2 つの前提が通常の diff テキストで破れたのとは性質が異なるため、
コードと要件記述の両方に前提として明記した。

#244
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
github-rag-mcp 5753a41 Aug 14 2026, 04:20 PM

@smileygames smileygames left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI self-review (auto mode, parent)

utf8ByteLength の正しさ

分岐を全て追った。< 0x80 → 1、< 0x800 → 2、well-formed な surrogate pair → 2 code unit で 4(i++ で low をスキップ)、孤立 surrogate → 3、その他 → 3。TextEncoder の意味論と一致する。孤立 surrogate を 3 にしているのは U+FFFD 置換が 3 バイトであることに合わせたもので、prepareDiffEmbeddingInput が code unit 境界で truncate する以上、実際に発生しうる入力への手当てになっている。

TOKEN_OVERHEAD_PER_INPUT を 2 から 3 へ上げた判断

支持する。しかもこれは、過去 3 回と同じ種類の穴を先回りして塞いでいる。

バイト境界が成立する条件は「全ての token が入力の 1 バイト以上を消費すること」。SentencePiece の語境界マーカー は、先頭に付与される場合に入力バイトを 1 つも消費しない token になりうる。入力ごとに 1 個。そして batch の入力数に上限が無い(#242 が per-input 課金を選んだのと同じ理由)以上、微小入力を並べれば未計上の token が積み上がって境界が破れるわ。

#237 は「推定が実測より楽観」、#242 は「文字より細かい token が存在する」で破れた。今回のこれは**「入力を消費しない token が存在する」**で、同じ族の 3 つめよ。実測を待たずに閉じているのは良い仕事。

#240 との整合(本 issue が要求した検証)

算術を再計算した。

1 入力の最大バイト数 = 8000 code unit × 3 = 24,000
1 batch の最小 file 数 = floor(60,000 / 24,000) = 2
batch あたり subrequest 2 を 2 file で按分 = 1/file
固定費 2/file(D1 FTS mirror + Store DO)
合計 = 3/file = DIFF_SUBREQUESTS_PER_FILE

一致する。ただし余裕が消えている。文字数軸のときは最小 7 file/batch で按分後 0.29、丸めで 3 に切り上げていた分の緩衝があったけれど、いまは端数なしでちょうど 3 よ。

6 repo での phase 最悪値も確認した。18×2 + 9×2 + 20 = 74、予算 75。残り 1

この状態を放置せず実行可能な assertion に変えていることを評価する。batch 軸をこれ以上詰めたら、静かに invocation 予算を超えるのではなくテストが落ちるわ。rules/model/subtractive-structural-beauty.md が言う「将来の実行が保証されない手続きは、確実に実行される構造へ置き換える」の実施例ね。

境界の性質について、正確に書いておく

この PR で境界は無条件にはならない。仮定が 1 つ残り、性質が変わっただけよ。

tokens(input) <= utf8ByteLength(input) + TOKEN_OVERHEAD_PER_INPUT

右辺の 3 は「入力バイトを消費しない token は入力あたり 3 個以下」という仮定を含む(<s> / </s> / )。それを超える種類が存在すれば破れる。

ただし過去 2 回との差は大きいわ。#237 は比率の推定(連続量の当て推量)、#242 は偽である補題。今回は離散的な列挙で、外れるとすれば「数え漏らした種類がある」という形になる。同じ轍ではないけれど、無条件と呼ぶのは正確ではないの。実装側が NFKC 正規化の残存仮定を明記しているのと同じ扱いで、こちらも記録しておく。

受け入れ条件

criterion 結果
1 batch の合計 UTF-8 バイト数が上限を超えない pass — planner が utf8ByteLength + 3 で課金
1fb0f6b / 92eb94d が embed に成功 post-merge 観測
forward watermark が 2026-08-11T15:33:02Z を越える post-merge 観測
非 ASCII を含む実測相当の回帰テスト pass — 16 file 日本語 commit、文字数予算では通り実コストでは超える二重固定
#240 の file 予算が破綻していない pass — 上記の算術、assertion 化済み

345 tests pass、tsc --noEmit clean、CI 3 件 green、mergeStateStatus CLEAN。

申し送り(判断を求められた件)

MAX_EMBEDDING_INPUT_CHARS の per-input 軸にバイト上限が要るかどうか。本 PR では対応不要と判断する。

per-input 窓はモデル自身が持っていて、endpoint が拒否しているのは batch 合計だけ。軸が違うわ。ただし旧根拠(8000 文字 ≈ 8192 token 窓の内側)が偽と判明した以上、per-input 側が実際に何を保証しているかは未確認の状態になっているの。本 PR が導入した退行ではないので、観測が出てから扱う。

next step

auto mode のため human check なし。self-review pass をもって AI が直接 merge する。merge により Workers Builds が deploy し、次の DIFFS_CRON(毎時 30 分)が判定になる。4 周目にして初めて、境界の根拠が実測より楽観でない状態で観測に入る。

@smileygames
smileygames merged commit 59e88fd into main Aug 14, 2026
3 checks passed
@smileygames
smileygames deleted the 244-fixpipeline-bound-embed-batches-by-utf-8-byte-count-not-character-count branch August 14, 2026 16:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(pipeline): bound embed batches by UTF-8 byte count, not character count

1 participant