人間のコーダーは今もLLMより優れている
これは、人間が今もLLMよりはるかに優れていることについての短い話です。私は反AIというわけではありません。私のことを知っている人や、どこかで私をフォローしている人なら分かっているでしょう。私は普段からLLMを使っています。今日もそうでした。自分のアイデアを試したいとき、コードレビューをするとき、自分が考えた方法よりもよいアプローチがないか確かめたいとき、自分の専門知識の限界にあることを探るときなどです(LLMを使ったコーディングについて、ほぼ2年前にブログ記事を書きました。当時はまだ、今ほど格好いいものではありませんでした。私はすでにLLMをコーディングに使っていて、それ以来ずっと使い続けています。更新版も書かないといけませんが、今回の記事の話題ではありません)。
それでも、現在のAIは便利ですし、素晴らしいものでもあります。しかし、人間の知性には驚くほど遠く及びません。最近はバランスの取れた会話をするのが不可能になっているので、この点を改めて言っておきたいと思います。
さて、今日はVector Sets for Redisの作業をしていて、複雑なバグを修正していました。私がRedisでの仕事を離れていた間に、同僚たちは、データのチェックサムが通っている場合でも、破損したRDBやRESTOREペイロードに対する耐性を導入していました。この機能はデフォルトでは無効ですが、有効にしたい人に対して、より強固な安全策を提供します。
しかし……大きな「ただし」があります。HNSWをRedisのRDBに高速に保存し、また高速に読み込めるようにするため、私は要素とベクトルの組ではなく、グラフ表現をシリアライズしました。そうしないと、HNSWにデータを再挿入する必要があり、速度が100倍ほど遅くなってしまうからです(!)。そこで、各ノードが他のノードと持つリンクを整数として保存し、あとでポインタに解決します。これはなかなかよい工夫で、うまく動きます。ところが、これに表現のランダムな破損が加わり、さらに私独自のHNSW実装ではノード間の相互リンクを強制しているという事情があります(便利な機能を多数追加した独自のHNSW実装を書いたのですが、その多くを有効にするには相互リンクが必要です)。すると、次のようなことが起こりえます。
- AがBにリンクしているが、BはもはやAにリンクしていない、と破損したデータに書かれている(ノードIDが破損している)。
- Bノードを削除する。相互性が破られているため、AからBへのリンクはクリアされない。
- その後グラフを走査し、Bに到達したところでAにアクセスする。use-after-free :-D :-)|
そのため、データを読み込んだ後に、すべてのリンクが相互的になっているか確認する必要があります。素朴な実装では、これはO(N^2)になります。各ノードについてすべてのレベルを走査し、各レベルではそのノードのすべての近傍を調べ、さらにその近傍が同じレベルでこのノードにもリンクしているか、相手側のリンクを走査して確認する必要があるからです。これはよくありません。
人間 vs LLM
まず、ファザーでこのバグを見つけられなくなるか確認するため、素朴な方法を実装しました。実際、それでうまくいきました。しかし、ベクトルを2,000万個持つ大きなベクトルセットでは、読み込み時間が45秒から90秒かそこらになってしまいました。なんてこった。そこでGemini 2.5 PROのチャットを開き、LLMに「ここではどうすればいい? ものすごく速い方法はある?」と尋ねました。
Geminiが見つけられた最善の解決策は、近傍リンクのポインタを並べ替えて、二分探索を使えるようにすることでした。ああ、まあ、確かにそれは知っています。ただ、16個や32個のポインタしかない配列で、本当に速くなるのか遅くなるのかは、あまり自信がありません。そこで「ほかには? もっとよい解決策はない?」と聞きました。答えはノーでした。
そこで私はこう伝えました。「AがレベルXでBにリンクしているのを見つけたとき、ハッシュテーブルにA:B:Xを保存するのはどうだろう(ただし、AとBは常にA>Bとなるように並べ替える。どちら向きのリンクでも同じだから)。そして、同じリンクをもう一度見つけたら削除する。IDをリンク内のポインタに解決するときに、どうせ全体を走査しているので、そのタイミングで全体を走査すればいい。最後にハッシュテーブルが空でなければ、相互的でないリンクがあると分かるのでは?」
Geminiは、それはよいアイデアだが、キーを作るためのsnprintf()やハッシュ計算の時間などがかかる、と言いました。それでも、私の元の方法(ポインタをソートする方法を含む)よりはよいとのことでした。そこで私は、snprintf()は必要ないと気づかせました。固定長のキーにポインタをmemcpy()すればいいのです。Geminiは、それが可能だと認めました。すると私は、あることに気づきました……。
「A:B:X用の固定長アキュムレーターを使うのはどうだろう?」とGeminiに言いました。ハッシュテーブルは一切使いません。リンク(A:B:X、つまり8+8+4バイト)を見つけるたびに、それを現在の12バイトのアキュムレーターにXORします。同じものを2回保存すれば相殺されるので、最後にレジスタがゼロでなければ、何かがおかしいと分かります! ただし、この方式には衝突の可能性があるので、その点を評価してほしいともGeminiに伝えました。Redisでは通常この機能をオフにしていますが、こうした追加チェックを有効にするユーザーは、攻撃者が意図的に不正なペイロードを作った場合に対する、さらなる保護も期待することが多いからです。
Geminiはこのアイデアにかなり感心していましたが、それでも、ポインタは……ほら、構造が似ていて、変化するビットも少ない、と言いました。だから、3つの余計なリンクL1、L2、L3があった場合、L1とL2のXORがL3のビットと同じになり、誤ってゼロレジスタになってしまう、つまり偽陰性になる可能性があるというのです。私も、アロケーターは非常に予測しやすく、外部から推測できることが多いと気づきました。
そこで、この方法を改善するにはどうすればよいかGeminiに尋ねましたが、たいしたアイデアは出てきませんでした。そこで私は考えました。「待てよ。十分に優れたハッシュ関数を使ってハッシュ化できる。しかも高速なもの、たとえばmurmur-128などを使えばいい(この用途では暗号学的な性質は必要ない)」と。そして、次の方式をGeminiに提案しました。
- リンクA:B:Xを使う。ただし、/dev/urandomから取得したシードを使って、すべてのキーの先頭に付ける。つまり実際にはS:A:B:Xとなる。
- murmur-128(S:A:B:X)の出力を、128ビットレジスタにXORする。
- 最後に、レジスタが0かどうかを確認する(すべてのリンクが相互的である)。
これについて分析するようGeminiに頼むと、ようやく納得したようでした。この方法なら、たまたまXORして0になる孤立したリンクを見つけるのは格段に難しくなります。外部の攻撃者がこれを有効に利用するのも難しいでしょう。「S」が分からないうえ、ポインタも操作しなければならず、それらをすべて組み合わせるのは非常に困難だからです。また、この機能は、必要な人が有効にする追加のベストエフォート型保護であり、通常はオフになっています。実用上、大きな性能ペナルティにならないことも重要です。
まあ、ここまで書いておいて言いたいのは、分析を終えたところで手を止めて、このブログ記事を書き始めたということです。この方式を使うかどうかはまだ分かりません(おそらく使うでしょう)。しかし、人間の創造性は今も一歩先を行っています。私たちは本当に型にはまらない考え方ができますし、奇妙で曖昧な解決策を思い描き、それがほかの方法よりうまくいく可能性を見いだせます。これはLLMにとって非常に難しいことです。それでも、自分のアイデアをすべて検証するうえで、Geminiはとても役に立ちました。もしかすると、話し相手になる「賢いアヒル」がいたからこそ、私はこの問題をそういう形で考え始められたのかもしれません。
記事をランダムに読む