2026年8月23日日曜日

novelmat - LLMに文章を書かせず、「次に何を書きたいか」だけ覗いてテキストフィルタを作った

☆ この blog は チャッピー生成です☆


OCR文章の不要な改行を消すために、LLMへ文章を書き直させるのをやめた。
代わりにやったのは、数十トークン読ませて、logitsを数個盗むことだった——



OCRすると「そこは改行じゃないだろ」という場所で切れる

書籍をOCRすると、こんなテキストができることがある。

吾輩は猫であ
る。
名前はまだない。

元の紙面では単に行が折り返されていただけなのに、OCR結果には改行文字が入っている。

欲しいのは当然、

吾輩は猫である。
名前はまだない。

である。

一見すると簡単な問題に見える。

ところが、「日本語のどこが文章の区切りなのか」を機械的に判断しようとすると、意外と面倒だ。

形態素解析器を使う方法も考えられる。品詞を見たり、文末表現を判定したり、句読点や括弧を考慮したりすることもできるだろう。

しかし小説には、

  • 会話文

  • 句読点の省略

  • 三点リーダ

  • 括弧

  • 倒置

  • 文の途中でのページ送り

  • OCRによる文字欠落

などが普通に出てくる。

「句点なら文末」「助詞ならまだ続く」といった規則だけでは、すぐに例外が増えていく。

もちろん、きちんと日本語解析の仕組みを作れば解決できる問題ではある。

ただし、改行を1個消すために、日本語の文境界判定器を一から育てたいわけではない。

そこで考えた。

LLMはすでに大量の日本語を学習している。

だったら、その能力をもっと雑に奪えないだろうか。


最初は、「普通に」ChatGPTやQwenに直してもらっていた

実は、最初からこんな仕組みを作ろうと思っていたわけではない。

当初の運用はもっと単純だった。

OCRしたテキストファイルをQwenやChatGPTなどのLLMへ渡して、

OCRによって入った不要な改行を取り除いてください。文章そのものは変更しないでください。

と頼んでいた。

これで済むなら、それが一番楽である。

実際、短い文章ではかなりうまくいく。

しかし、ある程度まとまったテキストを処理させると、だんだん困ったことが起きた。

たとえば、

  • 頼んでいない誤字修正をする

  • 句読点や表記を勝手に整える

  • 文体を微妙に変える

  • 消してほしい改行をいくつか残す

  • 長い文章になると途中の作業が抜ける

といったことがある。

プロンプトで、

改行以外は絶対に変更しない

と強く指定しても、完全には安定しなかった。

LLMからすれば、文章全体を一度生成し直す以上、これはある意味当然でもある。

こちらが欲しいのは、

この改行を残す

か、

この改行を消す

かだけなのに、LLMには文章全体を書き直す権限を渡している。

かなり大げさな仕事の頼み方だった。

特に困るのは、結果が一見まともに見えることだ。

数千文字の文章のうち一か所だけ書き換えられていても、人間が目視で発見するのは難しい。

逆に、不要な改行が一つ残っていても同じである。

生成系LLMへ全文編集を任せる方法は便利ではあるものの、

「入力の文字列をほぼ完全に保存しながら、指定した改行だけを機械的に除去する」

という用途では、思ったより扱いづらかった。

そこで発想を逆にした。

そもそもLLMに文章を書き直させなければいいのではないか。

文章を生成させるから、余計な変更も作業漏れも起きる。

でもLLMが、

この改行は不自然だ

と判断する能力そのものは使いたい。

だったら完成した文章を返してもらうのではなく、

LLMがその場所で次に何を書こうとしているかだけを覗けばいい。

今回の novelmat は、そこから始まった。


文章を生成させるために使う「LLM」

LLMというと、普通は文章を生成させるために使う。

質問を投げれば答えを書いてくれる。要約を頼めば要約してくれる。OCRの誤りを直したいなら、

この文章を自然な日本語に修正してください

と渡せば、それらしい文章を返してくれる。

もちろん、それでもいい。

でも今回やりたかったのは、少し違う。

LLMには文章を書かせない。

代わりに、

「この続きを書くなら、次に何を書こうとしている?」

という情報だけを取り出すことにした。


LLMは「次に何が来るか」を知っている

LLMが

吾輩は猫であ

まで読んだとする。

次のトークンを生成するとき、内部ではざっくり、

「る」       ← かなりありそう
「り」
「る。」
「\n」       ← これはかなり変
「猫」
...

のように、語彙に存在する大量のトークンすべてへスコアを付けている。

普段のLLM利用では、その確率分布から1トークンを選び、

と実際に生成させる。

今回は生成させない。

その一歩手前で止めて、確率分布だけ読む。

これだけで、

吾輩は猫であ
↓
「\n」はどのくらい出したい?
「る」はどのくらい出したい?

を数値として比較できる。

つまり、LLMを文章生成器ではなく、日本語の違和感センサーとして使う。


形態素解析器を作り込む代わりに、LLMの出力を横取りする

何故こんな事を考えるのか?

日本語の文境界をちゃんと認識する仕組みを作ろうとすれば、それなりの実装になる。

形態素解析して、

助詞だから続きそう
終助詞だから切れそう
句点だから切れそう
でも括弧の中なので……

と考えることになる。

さらにOCR特有の壊れ方まで扱おうとすると、ルールは増えていく。

一方LLMは、そもそも、

吾輩は猫であ

を見せた時点で、

次はたぶん「る」だろう

という判断をしている。

ならば、その判断をそのまま盗めばいい。

日本語の構造をこちらで明示的に解析する必要はない。

必要だったのは、

  1. 問題の位置までLLMに読ませる

  2. logitsを取る

  3. 改行と次トークンのスコアを見る

だけだった。

専用モデルを学習したわけでもない。

形態素解析の辞書を調整したわけでもない。

大量の日本語ルールを書いたわけでもない。

それでも、それなりにうまくいく。

LLMの出力を奪うのは、手間のわりにかなり性能がいい。


改行あり vs 改行なしをLLMに競わせる

文章が

A\nB

となっているとする。

まず、改行しない場合を考える。

A → B

について、LLMがBをどの程度自然だと思っているかを見る。

一方で、改行を残す場合は、

A → \n → B

という系列を見る。

log probabilityを使えば、これらを比較できる。

たとえば、

吾輩は猫であ
る。

なら、「吾輩は猫であ」の次に「る」が来る確率は高い。

一方、その途中に突然改行を挟む確率は低い。

だったら、

この改行はOCRによって紛れ込んだ可能性が高そうだ

と判断できる。

逆に、

吾輩は猫である。
名前はまだない。

なら句点後の改行はそれほど不自然ではない。

重要なのは、こちらが、

「ある。」は文末です

というルールを教えていないことだ。

LLMがすでに持っている日本語予測能力を利用しているだけである。


「日本語を理解させて答えを書かせた」わけではない

ありがちな実装なら、

あなたは日本語校正者です。
以下のOCR結果から不要な改行を削除してください。

とLLMに頼み、

吾輩は猫である。
名前はまだない。

という完成テキストを生成させる。

しかし、それをするとLLMにはかなり大きな自由が与えられる。

誤字を直すかもしれない。

句読点を変えるかもしれない。

表記を整えるかもしれない。

原文そのものを書き換えるかもしれない。

今回ほしいのはそんな高度な校正ではない。

この1文字の \n を消すか、残すか。

それだけだ。

そこでLLMには編集権限を与えない。

聞くのは、

この位置で \n をどのくらい出したかった?
その代わり次の文字をどのくらい出したかった?

という内部情報だけ。

最終的にファイルを書き換えるのは普通のPythonコードである。

AIに仕事を丸投げするというより、

日本語能力だけ借りる。



「生成させない」は速度面でも重要だった

この方法には、精度や制御のしやすさ以外にもう一つ大きな利点があった。

ほとんどdecodeしなくていい。

普通にChatGPTやQwenへ、

この文章から不要な改行を取り除いて、修正後の全文を出してください

と頼む場合、LLMは入力をprefillしたあと、修正済みの文章を最初から最後までdecodeして生成する。

たとえば数千トークンの文章を処理するなら、入力側の数千トークンを読むだけでなく、出力側でもまた数千トークンを1トークンずつ生成することになる。

LLM推論では、このdecode部分がかなり重い。

生成は基本的に、

1 token生成
↓
KV cacheを使って次の1 token生成
↓
また次の1 token生成
↓
...

と逐次的に進む。

文章が長ければ、そのぶん何千回もforwardする必要がある。

一方、今回欲しいのは修正済みの文章そのものではない。

ある位置まで、

吾輩は猫であ

とLLMに読ませて、

次は「る」なのか?
それとも改行なのか?

というlogitsを一度見るだけでよい。

つまり処理の大部分をprefillとしてまとめて流せる

prefillは複数トークンをまとめて行列演算できるので、decodeのように1トークンずつ逐次生成するより圧倒的にGPUを使いやすい。

今回の処理は感覚的には、

普通のLLM校正

長い入力をprefill
        ↓
長い修正版をdecode
decode
decode
decode
decode
...

ではなく、

novelmat

周辺テキストをprefill
        ↓
logitsを数個読む
        ↓
終わり

に近い。

LLMを使っているのに、ほとんど文章を生成しない。

そのため、普通にLLMへ全文を校正させる方法と比べるとかなり速い。

これは今回の用途と相性がよかった。

欲しいのは文章そのものではなく、

この場所では何を書こうとしていたか

という一瞬の判断だけだからである。


「これから校正する」と思わせてから確率を取る

先にLLMへ、

あなたの役割は日本語ライトノベルの校正担当です。
OCRで生成された日本語文を修正してください。

#で始まる行はページ情報なので残してください。
ページをまたいだ文は前ページ側へ統合してください。

といった指示やfew-shot例を見せておく。

その後、

Assistant:
吾輩は猫であ

までをprefillする。

しかし、やはり生成はさせない。

「これから校正結果を書くつもりになったLLM」が次に何を書きたがっているかだけを見る。

この使い方では、プロンプトは回答を生成させるためだけのものではなく、

次トークンの確率分布そのものを目的に合わせて変える手段

になる。指示のほか、参考にする画像をprefillするなどしても面白そうだ。


llm-jp-4への単純なモデル差し替えはうまくいかなかった

日本語を扱うなら、日本語特化モデルのほうがうまくいくのではないか。

そこで llm-jp-4-8b-thinking も試した。

ところが、Gemmaで使っていた判定基準をそのまま持っていくと、期待した結果にはならなかった。

たとえば、

今日はいい天気ですね。
明日も晴れるといいな。

のような通常の文間でも、Gemma用に決めた基準では改行を削除する側へ寄りやすかった。

最初は、

日本語特化モデルなのに意外だな

と思った。

しかし、考えてみれば判定ロジックそのものをGemmaのlogit分布を観察しながら作っている。

モデルが変われば、

P(\n)
P(next token)

の絶対値も、差の出方も、分布の形も変わって当然である。

つまりこれは、

llm-jp-4がこの用途に向いていない、という結果ではない。

むしろ、

モデルごとに判定基準をチューニングする必要がありそう

という話だと思う。

Gemma向けに作った閾値をllm-jpへそのまま持っていって、

Gemmaのほうが優秀だった

と結論するのは早い。

今後モデルを差し替えるなら、少量の教師データから閾値を自動的に合わせたり、logit差を正規化したりする仕組みがあってもよさそうだ。


26万語を毎回ソートして41秒かかっていた

最初は llama.cpp / llama-cpp-python の通常のlogprobs取得APIを使っていた。

ところが、やたら遅い。

調べてみると、必要なのは、

P(\n)
P(next_token)

という数個の値だけなのに、API側では巨大な語彙全体をソートしていた。

今回使ったモデルの語彙は約26万トークン。

それを何度もsortする。

3改行を見るだけで、

約41秒

かかっていた。

さすがにテキストフィルタとは呼びづらい。

そこで通常のcompletion APIを迂回し、

model.eval(tokens)
logits = model._scores

からraw logitsを直接取得するようにした。

あとはnumpyでlog-softmaxして、

p_newline = logprobs[newline_token_id]
p_next = logprobs[next_token_id]

だけ取り出せばいい。

必要なのは2要素程度なのだから、26万要素を順位順に並べる必要はない。

その結果、

3改行: 約41秒 → 約1秒

まで縮んだ。

全文を毎回読ませるのも無駄なので、現在は判定位置の前後約50トークンだけを見る。

最終的には、

数十トークン読ませて、logitsを数個盗む

という非常に小さな処理になった。


最後はsedみたいになった

こうしてできたのが novelmat である。

基本は、

novelmat input.txt

標準入力も使える。

cat input.txt | novelmat

ファイルを直接書き換えることもできる。

novelmat -i input.txt
novelmat -i.bak input.txt

判定結果だけJSONで確認することもできる。

novelmat --detect input.txt -o result.json

LLMアプリなのに、UIもチャット画面もない sed のような使い心地。



LLMは「文章生成API」だけではない

LLMを普通に使っていると、

prompt → generated text

というインターフェイスだけを見がちである。

しかし実際には、その途中に、

context
    ↓
巨大なtoken probability distribution
    ↓
sampling
    ↓
next token

がある。

我々が普段受け取っている文章は、その巨大な情報から最後に1個だけ選ばれた結果だ。

その手前には、

次に改行したい度
次に「る」と書きたい度
句点を書きたい度
EOSを出したい度

が全部入っている。

だったら、生成させずにそこを使ってもいい。

今回の改行フィルタは、その小さな例でしかない。

同じ考え方なら、たとえば、

  • OCR結果がページ末で途中切れしているか

  • 2つのテキスト断片が連続しているか

  • ここに句読点があるべきか

  • 改行すべき位置はどこか

  • 語尾が欠落していないか

  • 文書の境界らしいか

といった問題も、次トークン確率から拾える可能性がある。

こうした問題をまじめに解こうとすれば、形態素解析、構文解析、ルール、専用classifierなど、いろいろな方法がある。

それらが不要という話ではない。

精度や説明可能性が重要なら、専用手法のほうが適している場合も多いだろう。

ただ、

ちょっと日本語らしさを判定したい

という用途では、すでに手元にあるLLMへ文章を食わせ、次に何を出したがっているかを横取りするだけでも、意外なほど使える。

専用の日本語処理系を組み立てるより実装の手間が少なく、しかもモデルがすでに持っている大量の言語知識をそのまま利用できる。

日本語は英語などのように区切りが明確ではなく、形態素解析などしても正確な情報が得られるわけではない前提において、これはかなり便利な性質だと思う。


まとめ -「AIに直してもらう」より「AIが何を書こうとしたかを見る」

今回の novelmat は小さなツールである。

やっていることも最終的には、

この改行を残す / 消す

という二値判定にすぎない。

ただ、実装していてLLMとの付き合い方が途中から変わった。

最初は、

LLMならこの日本語を直せるのでは?

だった。

それが、

LLMはこの位置をどう感じているんだろう?

になり、

最終的には、

答えは書かなくていいから、その瞬間のlogitsだけ見せて

になった。

日本語の文章境界を見つける仕組みをこちらで頑張って作る代わりに、

LLMがすでに持っている「続きを予測する能力」を横から奪う。

少々乱暴だが、手間の割にはよく働く。

LLMは文章生成器として非常に便利だ。

でも、生成される文章はLLMが持っている情報のごく一部でもある。

その手前にある確率分布を覗いてみると、

昔ながらの小さなUNIXフィルタの中に、LLMをひっそり埋め込む

みたいな使い方もできる。

novelmat は、そんな実験になった。