rgの完全一致は半角スペース1個で嘘の0件を返す — 16年分のテキストを意味検索する索引を作った

個人の知的基盤を夢見て、Markdownファイル群を整えようとした結果、ただ散らかして終わったディレクトリを畳んでいました。畳むに至った経緯は前の記事に書いています。 畳むといっても丸ごと捨てるわけではなく、試行錯誤の中で生まれた意味のあるファイルは残したいと思い、AI支援のもとファイルを一件ずつ処理して「これは他のどこかに原典があるか、それともこれが唯一のものか」を判定していく作業を行いました。原典があるものは安心して削除できる。そこにしか無いものだけは救出しないと消えてしまう。
判定の道具にはAIが使いなれている rg(ripgrep)を使いました。あるファイルの中身を表す固有名詞を拾って、他のメモ全部を rg で横断検索する。ヒットすれば「他に原典あり=重複」、0件なら「これしかない=原典」。単純で速くて、信頼していました。
その判定が、家族史のファイルで1件、嘘の0件を返しました。
rgが0件を返した理由は半角スペース1個
そのファイルには曽祖父の姓名が入っていました。AIはこれを拾い上げ rg で全ファイル検索したら0件。原典は無い、つまりこのファイルが唯一のものと一瞬で判定が出ました。
ところが原典はちゃんとありました。別の場所の家族史ファイルに、同じ姓名が姓と名のあいだに半角スペース1個を挟んで書いてあったんです。rg は完全一致なので、スペース1個ぶんずれた瞬間に0件を返す。AIはその0件を「これしかない」と読んで、原典が現にあるファイルを原典の側に振り分けていました。
今回は誤りの向きが安全側でした。原典があるのに「これしかない」と過剰判定したので、結論は「残す」に倒れます。消す前にもう一度複数のエージェントで照合をかけ直したところ、半角スペース入りの曽祖父の名前の原典が別の家族史ファイルに見つかり、誤判定が訂正されました。
問題は向きが逆だったときです。完全一致は表記ゆれの前であっさり嘘の0件を出す。その0件を「これしかない」と読む運用で、もし取りこぼしが「消してよい」側に転んでいたら、唯一のファイルを消す事故になっていました。削除という不可逆な判断を、検索語の打ち方ひとつに預けている。これが怖い…。
欲しくなったのは、完全一致を意味の比較で守らせる仕組みでした。半角スペースも、表記ゆれも、10年スパンの言い回しの変化も、語の一致ではなく意味の近さで拾ってくれる索引です。
索引にしたコーパスは32,913チャンク
作ったのは、自分の16年分のテキストを丸ごとベクトル化した意味検索インデックスです。対象にしたコーパスはこれだけあります。
| コーパス | チャンク数 | 中身 |
|---|---|---|
| x-posts | 17,065 | 2009-12からの自分のポスト全部 |
| chatgpt | 9,694 | ChatGPT会話アーカイブ |
| vault | 4,892 | 旧Obsidian vault |
| claude | 1,142 | Claude会話エクスポート |
| commonplace | 120 | 拾ったリンクと散文の時系列ログ |
| 合計 | 32,913 | 2009〜2026 |
これを1チャンクずつベクトルにします。チャンク分割は文字数で1000、隣と150文字オーバーラップ。境界で文脈が切れて意味が落ちるのを、重ねて拾い直すためです。ただし重ねが効くのは長文側だけで、全体の半分強を占めるx-postsは1ツイート=1チャンク、重ねずに1000字で切るだけです。
埋め込みモデルは cl-nagoya/ruri-large。日本語特化の埋め込みで、1チャンクを1024次元のベクトルに写します。自分のテキストはほぼ日本語なので、英語混じりの汎用モデルより日本語特化を選びました。出来上がる行列は 32,913 × 1024。
ビルドの実数はこうです。
{
"model": "cl-nagoya/ruri-large",
"dim": 1024,
"count": 32913,
"by_corpus": { "vault": 4892, "commonplace": 120, "claude": 1142, "chatgpt": 9694, "x-posts": 17065 },
"doc_prefix": "文章: ",
"query_prefix": "クエリ: ",
"peak_temp_C": 71,
"cooldown_pauses": 0
}
doc_prefix と query_prefix は ruri が要求する接頭辞です。文書側は「文章: 」、クエリ側は「クエリ: 」を頭に付けてから埋め込む。peak_temp_C はビルド中のGPU最高温度で、83℃で一時停止する温度ガードを入れてあるんですが、今回は71℃止まりで cooldown_pauses は0。そのまま走り切りました。
FAISSは使わない、3.3万×1024の行列を総当たりする
検索の実装は拍子抜けするほど素朴です。FAISSもANNインデックスも使っていません。
32,913 × 1024 のベクトルを fp16(半精度)で1枚の行列に持つ。これがディスク上 約67MB(emb.npy)、メタデータ込みの索引一式で約75MBです。検索はクエリ1本を同じく1024次元に埋め込んで、行列との内積を総当たりで取るだけ。ベクトルは作るときに長さ1へ正規化してあるので、内積がそのままコサイン類似度になります。その上位を並べれば、それが意味的最近傍です。
3.3万件しかないなら、近似最近傍の凝った索引構造を建てるより、行列1枚の総当たりのほうが速くて壊れにくい。実測でクエリ1本0.06秒(CPUの常時ONサーバ機)、Mac MPSでも0.3〜2.3秒。近似が要るのは件数が桁2つか3つ上がってからで、いまの規模では総当たりが正解でした。よけいな依存を1個も増やさずに済むのも効きます。
ビルドはGPUで回しますが、消費(検索)はCPUでもMacのMPSでも動きます。q.py 側がCUDA / MPS / CPU を自動判定する。だから索引を作る機械と引く機械は別でいい。
索引一式はgitに入れません。コミットすると履歴が肥大するので、GitHub Releaseのアセットとして配ります。tar で固めて gh release upload --clobber で最新だけ上書き。取得する側は gh release download して展開するだけ。これで端末をまたいで同じ索引が引けます。実際にWindows(GPUでビルド)、Ubuntu(GPU無しなのでCPU版torchを明示)、Mac(MPS)の3端末で同じクエリの再現を確認しました。日本語形態素まわりで fugashi のApple Silicon導入を警戒していたんですが、wheelでそのまま入った。杞憂でした。
そして家族史です。この索引に最初の姓名を入れると、半角スペース入りの原典が並ぶ家族史ファイルが1位で返ってきます。rg が0件で取りこぼしたものを、意味検索は1位で拾う。完全一致の取りこぼしを、機械の比較が黙って埋める。今回の誤判定はvaultに原典を見つけて手で正しましたが、この種の取りこぼしを次から先回りで潰す補助線が、これで1本引けました。
1ツイートを種に、16年分の軸が芋づるで出る
この索引は単発検索のために作ったものですが、中身は32,913チャンク × 1024次元の行列で、年代も付いています。だから時系列の地図として読めます。
種に選んだのは、2009年12月16日の自分のこのツイートです。
Genepoolのようなシミュレータを作りたい。ハードルは高いがw
社会人になる前、プログラミングに没頭し始めた頃の1行です。Genepoolは進化生態系シミュレーションで、これを自分でも作りたい、という願望をこぼしただけのもの。この1ツイートの格納ベクトルと、全32,913チャンクのコサインを総当たりで取りました。クエリとして引き直すのでもなく、モデルも要らない、行列の内積を1回まわすだけです。語の一致ではなく、意味の近さだけで並べています。
近傍の素の上位は近年のものが多くなります(オニール、VRChatのオニール、Unityの生態系、テトリスの3次元化…)。そこから、何度も戻ってくる軸ごとに代表を1本ずつ拾うと、2010年から2025年まで散らばってこうなりました。スコア順そのままではなく、軸ごとに1本という選び方です。
| 類似度 | チャンク | 年代 |
|---|---|---|
| 0.871 | 立方体をレンダリングしてBMP出力するソース書き書き。…成功したらキューブをくるくる | x-posts 2010-02 |
| 0.890 | Unity勉強で作ってる生態系シミュレータ。本当は遺伝子的なナニカを加えて適者生存させたい | x-posts 2014-05 |
| 0.884 | befungeで人工生命作りたい!! | x-posts 2012-08 |
| 0.878 | 任意長のArray[Byte]を遺伝子として評価して人工生命として仮想世界で生存競争させるソフト作りたい | x-posts 2012-01 |
| 0.900 | オニール・シリンダーシミュレーター作るの筋いいかな? | chatgpt |
| 0.871 | Claude 3.7 Sonnet でフライトシミュレーター作ってるがむずい | x-posts 2025-03 |
並んでいるのは、後から手で掘り当てた「何度も戻ってくる」軸です。純Cで3Dの回転を書いた最古層。バイト列を遺伝子に見立てて生存競争させる人工生命。Befungeで作るalife。Unityの生態系シミュレータ。回転人工重力のオニールシリンダー。Webへのフライトシミュレータ移植。年代は2010から2025まで散らばっていて、しかも「Genepool」という語は、並んだどのチャンクにも入っていません。共有しているのはせいぜい「作りたい」「シミュレータ」くらいで、Genepool本体の語ではつながっていない。
種を変えて、軸ごとに最古種を辿る引き方もできます。
| 軸 | 機械が出した最古種 | 発火スパン |
|---|---|---|
| 進化シム/人工生命 | 2009-12「Genepoolのような…」 | 2009→2026 ほぼ毎年 |
| 3D/物理/回転重力 | 同じ2009-12種が最近傍/上位は2026のオニール | 2009→2026 |
| Befunge/難解言語 | 2011-09「広大なBefungeのソース空間…」 | 全年 |
| 差分/重複検出 | 2010-01「Flickrて重複した写真を検出する機能は無いみたいだな・・・」 | 全年 |
この最後の行が、最初の家族史の話とつながります。差分・重複検出に近いチャンクの最古は、2010年1月のFlickrの重複の話でした。3万枚の写真の重複排除と版管理をなんとかしたい、という16年前の話です。家族の記録を半角スペース1個で取りこぼした今回と、その対策にこの索引を作ったこと自体が、同じ系列に並ぶ。種を1本入れただけで、機械がそこまで並べてきます。
次にやること
きっかけは半角スペース1個でした。完全一致の rg が嘘の0件を返し、原典があるファイルを唯一コピーの側に振り分けた。それを意味の比較で守らせたくて、16年分・32,913チャンクを1024次元にベクトル化した索引を作りました。FAISSも使わず、約75MBの索引(行列1枚)を総当たりするだけで、表記ゆれの向こうの原典を1位で拾えます。
行列として読むほうも効きました。2009年の1ツイートを種に、Genepoolの語を一度も介さないまま16年分の軸が芋づるで並ぶ。引くだけでなく、時系列の地図として読める。次はとりあえず、いま日付メタの無いコーパスにチャンク単位の年代を振って、全コーパスで時系列に並べるあたりから手をつけます。