Human coders are still better than LLMs

Salvatore Sanfilippo

人間のコーダーは今でもLLMより優れている

原文は Salvatore Sanfilippo により に公開されました。 このブログを購読する

人間が今でもLLMよりはるかに優れていることを示す、ちょっとした話だ。先に言っておくと、僕は反AI派とかではない。僕のことを知っている人や、どこかでフォローしてくれている人なら分かると思う。僕はLLMを日常的に使っている。今日もそうだった。自分のアイデアを検証したいとき、コードレビューをしてもらいたいとき、自分が考えたやり方よりも良いアプローチがないか確かめたいとき、自分の専門のぎりぎりの領域を探求したいときなどだ(LLMを使ったコーディングについてのブログを書いたのはもう2年近く前のことで、当時はまだそれほどクールだと思われていなかった頃だ。あの頃からずっとLLMをコーディングに使い続けている。そのうちアップデートも書かなければと思っているが、それは今回の本題ではない)。

とはいえ、今のAIは役に立つし、素晴らしいものでもあるが、人間の知性にはとてつもなく及ばない。そして最近はバランスの取れた会話をすることさえ不可能になっているので、そのことを強調しておきたい。

さて、今日はRedisのVector Setsに取り組んでいて、複雑なバグを修正していた。僕がRedisを離れていた間に、同僚たちがRDBやRESTOREペイロードの破損に対する耐性を導入していたのだ。データのチェックサムが通った場合でさえ検出できるようにするものだ。この機能はデフォルトでは無効になっているが、求める人にはより強固な安全性を提供する。

だが…象のように大きな「だが」がある。HNSWをRedisのRDBへの保存やそこからの読み込みを高速化するために、僕は要素とベクトルのペアではなく*グラフ*そのものの表現をシリアライズしている。そうしないと、HNSWにデータを再挿入しなければならず、それでは100倍も遅くなってしまうのだ(!)。だから各ノードが持つ他のノードへのリンクをすべて整数として保存し、あとでそれをポインタに解決している。なかなかうまいやり方で、とてもよく動いている。だが、これと、表現のランダムな破損、そして僕自身のHNSWへの工夫――ノード間の相互リンクを強制していること(僕は多くの便利な機能を備えたHNSWを自前で実装したが、それらを実現するには相互リンクが必要なのだ)――が混ざると、こんなことが起こりうる:

  1. 破損したデータを読み込むと、AはBにリンクしていると書かれているのに、BはAにリンクしていない(ノードIDが破損している)。
  2. ノードBを削除する。相互性が壊れているため、AからBへのリンクがクリアされない。
  3. そしてグラフを走査し、Bのところに来たときにAにアクセスする。use-after-freeだ :-D :-) :-|

だからデータを読み込んだあとに、すべてのリンクが相互的であるかをチェックする必要がある。素朴なやり方だとこれはO(N^2)になる。各ノードについてすべてのレベルを走査し、各レベルでそのノードのすべての隣接ノードを走査し、相手側もそのレベルで自分にリンクしているかを、相手のリンクを走査して確認しなければならない。良くない。

人間 vs LLM

まずは素朴なやり方を実装して、ファザーがもうバグを見つけられなくなるか試してみた。確かにうまくいったのだが、2000万ベクトルを持つ大きなベクトルセットのロード時間が45秒から90秒くらいになってしまった。なんじゃそりゃ、だ。そこでGemini 2.5 PROのチャットを開いて、LLMに聞いてみた。「やあ、ここで何ができる? 超高速な方法はないか?」と。

Geminiが見つけてきた最善の答えは、「隣接リンクのポインタをソートしておけば、二分探索が使えますよ」というものだった。ああ、もちろん、そんなことは分かっている。16や32個のポインタの配列で、それが速くなるのか遅くなるのかはよく分からないが。そこで「他には何かない?」と聞いてみた。答えは「いいえ、これ以上の良い方法はありません」だった。

そこで僕はこう言った。「いいかい、レベルXでAがBにリンクしているのを見つけたときに、ハッシュテーブルにA:B:Xを保存するのはどうだろう(ただし常にAとBをソートしてA>Bとなるようにし、リンクは方向に関係なく同じものとして扱う)。そして同じリンクをもう一度見たときにそれを消す。今回はIDをリンク内のポインタに解決する際にすでにやっているように全体を走査するだけで、最後にハッシュテーブルが空でなければ、相互的でないリンクがどこかにあると分かる、というのは?」

Geminiはそれは良いアイデアだと言ったが、キーを作るためのsnprintf()やハッシュ化の時間などがかかる、と言ってきた。とはいえ、元のやり方(ポインタのソートさえ含めて)よりは良いとも。僕はsnprintf()は不要だと指摘した。ポインタを固定長のキーにmemcpy()すればいいだけだ。Geminiはそれが可能だと認めた。そこで僕はあることに気づいた…

「ねえ」と僕はGeminiに言った。「A:B:Xのために固定のアキュムレータを使うのはどうだろう? ハッシュテーブルはまったく使わない。リンク(A:B:X、つまり8+8+4バイト)を見るたびに、それを12バイトの現在のアキュムレータにxorするんだ。二回保存されれば打ち消し合うから、最後にレジスタが0でなければ、何かがおかしいと分かる! ただ、この方式は衝突が起こりうることはGeminiにもあらかじめ伝えて、評価してもらった。この機能は通常Redisではオフになっているが、こうした追加のチェックを有効にしたユーザーは、悪意のある攻撃者が意図的に不正なペイロードを作った場合に対する保護も期待することが多いからね」

Geminiはそのアイデアにかなり感心していたが、それでもポインタは…ほら、構造が似ていて数ビットしか違わないから、もし3つの不正なリンクL1、L2、L3があった場合、L1とL2のxorがL3のビットと同じになってしまい、偽陰性(レジスタがゼロ)が起こりうる、と言ってきた。僕も、アロケータは非常に予測可能で外部から推測しやすいことにも気づいた。

改善策がないかGeminiに聞いてみたが、あまり良いアイデアは出てこなかった。そこで僕は考えた。待てよ、十分に良くて、しかも高速なハッシュ関数でハッシュすればいいんじゃないか、murmur-128とか(このタスクでは暗号学的な特性は必要ない)、そして次のようなスキーマをGeminiに提案した:

  1. リンクA:B:Xを取るが、/dev/urandomから得たシードをすべてのキーの前に付ける。つまり実際にはS:A:B:Xとする。
  2. murmur-128(S:A:B:X)の出力を128ビットのレジスタにxorするだけだ。
  3. 最後に、レジスタが0かどうかをチェックする(すべてのリンクが相互的である)。

それを分析してくれとGeminiに頼んだところ、やっと満足してくれて、これはたまたまxorして0になるような孤立したリンクが見つかることも、外部の攻撃者がこれをうまく利用することも、はるかに困難にする、と言ってきた。「S」が不明であり、ポインタも制御しなければならず、それらをすべて組み合わせるのは本当に難しいからだ、と。また、この機能は有効化が必要なベストエフォートの追加的な保護であり、通常はオフになっていて、実用的であるためにはあまり大きなパフォーマンスのペナルティを課すべきではない、とも。

まあ、結局言いたいことはこうだ。僕はちょうど分析を終えて、このブログ記事を書くために手を止めたところだ。この方式を使うかどうかはまだ分からない(たぶん使うだろう)が、人間の創造性にはまだ優位性がある。僕たちは本当に枠の外で考え、奇妙で不正確な、しかし他のものよりうまく機能する解決策を思い描くことができる。これはLLMにとっては極めて難しいことだ。それでも、自分のアイデアを検証する上でGeminiはとても役に立ったし、話し相手となる「賢いアヒル」がいたからこそ、こういう形で問題について考え始められたのかもしれない。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント