この記事はZennに投稿したものの転載です。
元記事: https://zenn.dev/uekibachidan/articles/9da355dd89a020
はじめに
以前、AI関連のニュースだけを集めるまとめサイトを「LLMを使わずに」作った話を書いた。
- LLMを使わずにテキスト分類する: https://qiita.com/uekibachidan/items/39019e14fb70caacaf8e
- Cloudflare Workers無料枠の壁3つ: https://qiita.com/uekibachidan/items/e6afe1c4c896da7998f4
今回も同じ路線で、別のツールの話。
Ningenize(ニンゲナイズ) — 文章を貼ると「AIが書いたときにありがちな表現」に印を付けるチェッカー。
https://ningenize.uekibachidan.com/
こっちも判定にLLMを使っていない。辞書と文字列処理だけでやっている。
理由は前回と同じで、ブラウザだけで完結させたいのと、1回チェックするたびにAPI代がかかる作りにしたくなかったから。
リリース後の改善は、ずっと辞書に語彙を足す形でやってきた。AIがよく使う言い回しを見つけては追加する、という単純な作業。
これが頭打ちになった。語彙を増やしても検出はたいして増えないし、増えるときは人間の書いた文章のほうも一緒に増える。もう語彙の問題ではないな、というところまで来ていた。
一方で、AIっぽさは単語よりも文のリズムのほうに出るようになってきている、という話は界隈でちらほら見かけていた。同じ長さの文が続くとか、語尾が揃うとか、そういう類の話。
特定の記事を挙げられるわけではないし自分で検証したわけでもないので受け売りだけど、辞書の側が飽和しているなら、判定ロジックの側でそういう要素を取り込めるところを取り込みたかった。
その方向でClaudeと壁打ちしていく中で提案されたのが、「段落内の共起を見る」という切り口だった。
共起という言葉に馴染みがなくて調べたのだけど、自然言語処理の用語で、ある語と別の語が同じ範囲に一緒に出てくることを指すらしい(英語だと co-occurrence)。範囲は文だったり段落だったり、前後何語という窓だったりと、目的に応じて決めるものとのこと。
今回でいうと、範囲は段落。見るのは「同じ表現が何回出たか」ではなく、違う種類のAIっぽい表現が同じ段落に一緒に出ているか、ということになる。
結果として、人間のブログ文章とAIに書かせた文章とで、検出される件数の差が 6.0倍から13.5倍に開いた。同じ長さあたり何件光るかを比べた数字で、以降これを分離比と呼ぶ。
改修前の6倍という数字は、聞くと開いているようだけど、文章1本ごとのブレを考えると「AIっぽい」と言い切れる差ではない。
この記事は、辞書ではなく判定ロジックの側をいじった記録。
技術構成
- React 19 + React Router v7(フレームワークモード)/ TypeScript / Vite / Tailwind CSS v4
- エディタは CodeMirror 6。検出したところにデコレーションを重ねて、ホバーで理由を出す形
- ホスティングは Cloudflare Workers。プリレンダリングして静的に配っている
- 辞書は 83KB ほどの JSON 1本。ビルドに同梱してクライアントへ配る
- 判定は全部ブラウザの中で動く。貼った文章はサーバへ送っていないし、そもそも受け取るサーバがない
最後の行が構成上の制約で、この記事に出てくる話は全部「クライアントで、辞書と正規表現だけで、どこまで判定できるか」の枠内にある。
なお実装そのものはAIエージェント(Gemini)に指示書を渡して行っている。
判定エンジンの構成
先に構成を書いておく。エンジンは3つのレイヤーでできている。
- match モード:辞書の表現に当たったら即ヒット。「〜ではないでしょうか」のような、1回出ただけで十分AIっぽいもの
- frequency モード:1回出ただけでは何とも言えないので、密度で判定するもの。「重要」「活用」のような普通の語がここに入る
- 構造検出:語尾の単調さなど、単語ではなく文の並びを見るレイヤー
辞書は日本語332件。うち113件、34%が frequency モードに割り当てられている。
リズム側の受け皿は3つ目の構造検出で、ここは辞書と関係なく動く。まずここから見た。
問題1:リズムを見るレイヤーが、ほとんど何も見ていなかった
語尾の単調さを拾う処理に、取りこぼしが2件あった。
ひとつは語尾の判定が /(です|ます|ました|でしょう|である|だ)$/ しかなかったこと。
「でした」「ません」「ください」「ましょう」「だろう」「ない」で終わる文が、全部「語尾なし」の扱いになる。語尾の単調は「同じ語尾が何文続いたか」で数えているので、対象外の語尾が1文挟まるとそこで連続が途切れる。「〜ません。〜ません。〜ません。」と3文並んでも検出されない。
もうひとつは、構造検出のヒット範囲を文全体にしていたこと。
重なりを除去する処理は「開始位置順・長いほうを優先」で動くので、文全体を占める構造ヒットが、その文の中にある辞書ヒットを全部飲み込んで消していた。リズムを検出すると単語の検出が消える、という相殺が起きていたことになる。※さらに画面側でも同じ重なり除去を二重にかけていたので、そちらも外した。
語尾のルールは正規化テーブルに置き換えて、
const ENDING_RULES: EndingRule[] = [
{ re: /ませんでした$/, key: 'ません' },
{ re: /ませんか$/, key: 'ません' },
{ re: /ません$/, key: 'ません' },
{ re: /ましょう$/, key: 'ましょう' },
{ re: /ました$/, key: 'ました' },
// ...
];
上から順に見て最初に当たったものを採るので、並び順に意味がある。「ませんでした」を「でした」より前に置かないと、「ませんでした」が「でした」として数えられて、丁寧な否定文が並んだだけで語尾単調の判定に入ってしまう。
構造ヒットの範囲は、文全体ではなく語尾の部分だけに縮めた。これで単語の検出と共存できる。
ただ、リズム側でできたのはここまで。文長の分散や句読点の打ち方まで見るとなると別物の作りが要るので、今回は「壊れていたものを直した」以上のことはしていない。
判定の質を動かしたのは、このあとの辞書側の話だった。
問題2:長文になるほど frequency が発火しない
この113件がほぼ発火していなかった。
当時の判定式はこうなっていた。
count >= 2 && count / (textLength / 1000) >= 2.0
実質 count >= 2 × 文字数/1000 を要求している。つまり文章が長くなるほど、必要な出現回数が線形に増える。
| 文字数 | 必要な出現回数 |
|---|---|
| 1,000 | 2 |
| 3,000 | 6 |
| 5,000 | 10 |
| 15,000 | 30 |
5,000字の記事で同じ表現が10回、15,000字なら30回。
「1000字あたり2回」という密度の考え方自体はまともなのだが、これを長文にそのまま伸ばすと、現実の文章では絶対に届かない数字になる。辞書の3分の1が、長い文章では静かに死んでいた。
※短い文章では普通に発火するので、テストで気づきにくい類の穴だった。
直し方は単純で、密度で伸ばすのをやめて、上限で頭打ちにした。
const FREQ_MIN_COUNT = 2;
const FREQ_MAX_COUNT = 4;
const FREQ_CHARS_PER_STEP = 1500;
export function frequencyThreshold(textLength: number): number {
const step = Math.ceil(textLength / FREQ_CHARS_PER_STEP);
return Math.min(FREQ_MAX_COUNT, Math.max(FREQ_MIN_COUNT, step + 1));
}
1,500字ごとに1回ずつ厳しくなって、4回で止まる。15,000字だろうが4回で発火する。
問題3:そもそも「反復」を見ていたのが間違いだった
閾値を直したら検出件数は増えた。ただ、人間が書いた文章の検出も一緒に増えた。
当たり前で、頻度で見ている限り「よく使う言葉を繰り返す人」は全員AI判定に近づいていく。
ここで、AI文と人間文を並べて段落単位で眺め直したときに、傾向がはっきり違うことに気づいた。
AIが書いた文章の特徴は、同じ表現を何度も繰り返すことではない。
違う種類のAIっぽい表現が、同じ段落にまとめて並ぶことだった。
「〜において」が3回出てくる段落より、「〜において」と「重要な役割を果たし」と「〜と言えるでしょう」が1回ずつ並んでいる段落のほうが、よほどAIっぽい。
人間はこれをやらない。癖のある言い回しは持っていても、種類の違う定型を一段落に詰め込むことはあまりない。
そして現行の作りでは、この特徴を作りの上で取りこぼしていた。
frequency モードは、閾値に届かなかったヒットを結果から捨てる(以降これを間引きと呼ぶ)。1回ずつしか出ていない表現はここで全部消えるので、「違う表現が1回ずつ並んでいる」という状態は、判定にたどり着く前に消滅していたことになる。
対策:段落内の共起をゲートにする
やったことは、frequency の間引きに例外を1つ足しただけ。
同じ段落の中に、異なる種類のヒットが2種類以上あれば、出現回数が足りなくても残す。
段落を切り出して、
function splitParagraphs(text: string): { from: number; to: number }[] {
const result: { from: number; to: number }[] = [];
const separator = new RegExp(`${LINE_BREAK}[ \t]*${LINE_BREAK}`, 'g');
let start = 0;
let m: RegExpExecArray | null;
while ((m = separator.exec(text)) !== null) {
result.push({ from: start, to: m.index });
start = m.index + m[0].length;
}
result.push({ from: start, to: text.length });
return result.filter(p => text.slice(p.from, p.to).trim().length > 0);
}
段落ごとに「ラベルの種類」を数えて印を付ける。
private markCooccurrence(text: string, findings: Finding[]): void {
for (const para of splitParagraphs(text)) {
const inParagraph = findings.filter(f =>
f.from >= para.from &&
f.to <= para.to &&
f.severity <= COOC_MAX_SEVERITY &&
!COOC_EXCLUDED_CATEGORIES.has(f.category)
);
if (inParagraph.length < COOC_MIN_KINDS) continue;
const kinds = new Set(inParagraph.map(f => f.label));
if (kinds.size >= COOC_MIN_KINDS) {
for (const f of inParagraph) {
f.isCooccurrence = true;
}
}
}
}
kinds を Set にしているところが要点で、同じラベルが3回出ても1種類として数える。反復ではなく種類の多さを見たいので、ここは重複を潰しておかないと元の頻度判定に戻ってしまう。
順序を間違えると機能が丸ごと死ぬ
これは実装に入る前から分かっていたので先に注記しておいた。共起の判定は、必ず間引きより前に呼ぶ必要がある。
// 1. 辞書マッチ(間引き前の生マッチ)
let dictFindings = this.runMatchDetector(text);
// 2. 段落内の共起を判定して印を付ける(必ず 3 の間引きより前)
this.markCooccurrence(text, dictFindings);
// 3. frequency モードのヒットを密度で間引く(共起で拾ったものは残る)
dictFindings = this.runFrequencyDetector(text, dictFindings);
順番を逆にすると、1回ずつしか出ていない表現が全部消えたあとの状態を数えることになる。共起は原理的に成立しなくなり、機能としては存在するのに何もしない、という一番たちの悪い壊れ方をする。
エラーも出ないし、検出結果も「ちょっと少ないかな」くらいにしか見えない。
逆方向のチューニング:記号は緩める
共起を足すのと同時に、逆に閾値を上げたカテゴリがある。formatting — → や / や全角ダッシュ、日本語の文中に混じる半角スペースといった、記号の使い方を見ている系統。
これが人間の技術記事の誤検出源になっていた。実測すると、人間が書いたブログ文章での検出16件に対して、AI文(約2万字)では0件。完全に逆を向いている。
考えてみれば当然で、日本語の技術文では矢印も全角ダッシュも普通に多用する。そもそもコードや記号を並べる文章なのだから、記号の密度が上がるのは書き手の癖ではなく分野の性質。
この系統だけ閾値を2倍にした。
const FREQ_CATEGORY_MULTIPLIER: Record<string, number> = {
formatting: 2,
};
共起の判定からも formatting は除外してある(COOC_EXCLUDED_CATEGORIES)。記号は「違う種類の表現が並んだ」の頭数に入れない、という扱い。
実測
ChatGPT と Gemini に記事体の文章を書かせたもの(各5本)と、人間が書いたブログ文章20本・39,845字で測った。
| コーパス | 改修前 | 改修後 |
|---|---|---|
| AI: ChatGPT(である調)3,360字 | 3.57 件/1000字 | 8.33 |
| AI: Gemini(ですます調)2,189字 | 5.03 件/1000字 | 8.68 |
| 人間: ブログ20本 39,845字 | 0.70 件/1000字 | 0.63 |
AI文の検出は倍以上に増え、人間文の検出はむしろ減った。記号を緩めた効果が、共起を足した増分を上回っている。
人間文とAI文の分離比は 6.0倍 → 13.5倍。
段落単位で見ると、もっとはっきり出る。「異なる種類のヒットが2種以上ある段落」の割合が、
- ChatGPT: 45.8%
- Gemini: 50.0%
- 人間: 2.4%
約20倍。判定の軸としては、こちらのほうがずっと素直だった。
踏んだ罠:CRLF で全文が1段落になる
リリース後に1件直している。段落の区切りを \n[ \t]*\n で見ていたので、改行コードが CRLF の文章だと空行が区切りとして認識されない。
Windowsで書いたテキストや、環境によってはブラウザのテキストエリアからも CRLF が飛んでくる。そうすると文書全体が1段落として扱われる。
まずいのは、これが「検出されなくなる」方向ではなく**「過剰に検出される」方向**に壊れること。
文書全体が1段落なら、離れた場所にある無関係なヒット同士が全部「共起している」ことになり、frequency の間引きが実質無効になる。人間の書いた長文ほど盛大に光る。
改行コードを吸収する形に直した。段落を単位にした判定を入れるなら、改行コードは最初に潰しておいたほうがいい。
まとめ
やったことは4つ。
- 語尾の正規化ルールを足して、構造検出の取りこぼしを塞いだ(ヒット範囲も語尾だけに縮めた)
- 頻度の閾値を、文章長に比例させるのをやめて上限で頭打ちにした
- 判定軸に「段落内の共起」を足した(反復ではなく種類の多さを見る)
- 記号系のカテゴリは逆に緩めて、共起の頭数からも外した
効いたのは3つ目。閾値をどう動かしても縮まらなかった差が、軸を1本足したら一気に開いた。
数字を調整して精度を上げようとしていたときは、AI文を強く光らせると人間文も一緒に光る、というトレードオフから出られなかった。「何回出たか」ではなく「何種類が同居しているか」を見るようにしたら、そのトレードオフ自体が消えた。閾値をいじるのは最後でいい、というのが今回の学び。
もともとの動機は「語彙を足す改善が頭打ちになった」だったけど、抜け道は語彙の外にあった、という話でもある。同じ辞書のまま、数え方を変えただけで分離比は倍になっている。この方向にはまだ余地があって、文長の分散あたりが次の候補。
ただ、辞書ベースである以上、限界も同じところにある。
これは「AIが書いたか」を判定しているわけではなく、「AIがよく使う表現が固まっているか」を見ているだけ。書き手が表現を散らせば普通に抜けるし、逆に定型を好む人間の文章は光る。判定器というより、書いたものを読み直すときの目印くらいの位置づけで作っている。
AI側の書き方が変われば、辞書も数え方も追いかけ直しになる。たぶんこれに終わりはないと思う。
この記事は AI(Claude)と共同執筆しました。