序章:「埋め込みモデルを別のものに変えてみる」は評価ではない
RAG というパイプラインの中で、埋め込みモデル(embedding model)は最も感覚だけで差し替えられてしまう工程です。検索の精度が出ないとき、真っ先に「モデルが悪い」と考え、より大きく新しい、あるいはあるランキングで上位のモデルに替えて、感覚が改善したかを見てしまう——このやり方の致命的な欠点は、「新しいモデルのほうが良い」という判断が成り立っているのかを示す仕組みがまったく存在しないことです。感覚というやつはノイズも偶然も排除できず、次のモデルの迭代で実際に良くなったのか悪くなったのかにも答えられません。
さらに厄介なのは、埋め込みモデルの品質が、システムから切り離して観測できる性質ではないという点です。同じベクトルの組でも、チャンクの切り方、インデックスパラメータ、rerank モデルの違い、query の表現の違いによって、recall の見え方は大きく変わります。ランキング上の平均スコアが上位であっても、あなたのコーパスに持ってきたら速くもなく正確でもなく、むしろあなたの query 分布のほうでは悪化することもあります。
したがってここで議論したいのは「どのモデルが最善か」ではなく、埋め込みモデルを科学的に選び、それが本当に改善した証拠を示す方法です。中心的な主張は三つ。第一に、選定は総合ランキングの順位ではなく、明示した tradeoff の軸の上に立たなければならない。第二に、いかなる汎用ベンチマークも、明らかに不合格な候補を排除することにしか使えず、最終判断には使えない。第三に、あなたの業務評価セットこそが唯一の審判であり、その品質の上限が評価体系全体の品質の上限を決める。
全文はその順番で展開します。まず選定の軸(dimension、言語、テキストの形態)を説明し、次に汎用ベンチマークでできること・できないことを論じ、タスク粒度の評価、評価セットの構築、rerank との連携、微調の境界、インデックスパラメータによる隠蔽効果へと進み、最後は落とし穴チェックリストと、そのまま実行できるパイプラインの骨格で締めます。
一、選定の最初の軸:dimension・ストレージ・レイテンシの三角形
1.1 dimension は高いほど良いわけではない
直感的には、768 次元より 1024 次元のほうがよく、1024 次元より 1536 次元のほうがよい——この直感は初期には成り立っていました。当時のモデルでは、モデルの容量と dimension が事実上一対一で対応していたからです。しかし 2023 年以降、公開されている主要な埋め込みモデルでは、「同じパラメータ数ならより高次元にすると検索品質が上がる」という関係が自動的には成り立たなくなりました。理由はいくつかの層にあります。
第一に、有効ランクの問題です。テキストコーパスの意味多様体が本来持つ内在次元は、モデル出力の名目上の次元よりずっと小さい。次元を増やした分は、概して異方性のあるノイズまで一緒に拡大されます。実際によく見られる結果は、dimension を上げた後にコサイン類似度の分布がより集中するか、あるいはより分散するか——どちらになるかはモデル次第ですが、いずれにせよ、並び順の境界識別度が線形に向上することはありません。
第二に、学習シグナルの密度低下です。batch size と学習ステップ数を固定すると、より高次元の出力空間では、1 次元あたりに配られる勾配シグナルが平均して少なくなります。このため高次元モデルは、一部の粒度の高いタスクでは逆に、十分学習された中次元モデルに劣ることがあります。
第三に、コストは硬い制約になるという点が 4 つ目です。ベクトルストレージはおおよそ 向量数 × 维度 × 每元素字节数で与えられます。1024 次元 fp32 から 256 次元に落として fp16 にすれば、ストレージはおよそ 1/8 になります。一方、1 回のクエリで百万規模の候補を走査する場合、内積計算の FLOPs は dimension に比例します。dimension は純粋な品質軸ではなく、同時に予算軸でもあります。
したがって正しい問いは「何次元が最適か」ではなく、次のとおりです。許容できるストレージとレイテンシの予算のもとで、どのモデルが私のコーパスに対して最良の並び順の品質を得られるか。この文の 3 つの変数はどれも省略できません。
1.2 Matryoshka:dimension を調整可能なパラメータにする
あるモデルが Matryoshka 学習で訓練された可変次元の埋め込みを主張しているなら、dimension はモデル選択の問題から、実行時ノブの問題に変わります。この種のモデルは学習時に先頭次元からなる部分空間を独立した埋め込みとして制約するため、任意の次元で切り詰めた後も比較的安定した検索性能を維持できるからです。
このことの工学的な価値は、同じ文書の集合を 1 回エンコードするだけでよいという点にあります。まず最低次元で大規模な候補プールを粗く絞り込み、次に高次元で少数の候補を再計算して細かく並べる、さらに次元別のベクトルをインデックスの別セグメントに階層的に格納して低次元から出し、必要に応じて高次元を参照する——この階層型検索構造は Matryoshka モデルでは従来モデルよりも明確に有利に働きます。
ただし境界に注意してください。Matryoshka 学習で得られた切り詰めの品質は均一ではなく、損失は通常、最も低い段の次元に集中します。実務上稳妥なのは、学習や評価の段階で任意の次元を等価な選択肢として扱わず、まず 2〜3 個の候補段(たとえば 256 / 768 / 全次元)で評価を回し、品質が dimension の低下に対してどこで折れ曲がるのかを確認してから、本番用の段を決めることです。
DIMENSIONS = [256, 512, 768, 1024] # 仅在模型支持可变维度时有意义
def sweep_dimension(encoder, corpus, queries, gold, k=10):
"""维度是一个实验因子,而不是模型的固有属性。"""
rows = []
for dim in DIMENSIONS:
vecs = encoder.encode_corpus(corpus, truncate_dim=dim, normalize=True)
row = evaluate(build_index(vecs, metric='cosine'), queries, gold, k)
row.update(dim=dim, index_mb=len(vecs) * dim * 2 / 1024 / 1024)
rows.append(row)
return rows # 关注质量曲线的拐点,而不是最高那一点的绝对值
1.3 正規化と距離指標:先に決めておくべきこと
どの 2 つのモデルを比較する場合も、まず指標の基準を固定しなければなりません。業界での主流なやり方は、学習時に正規化された内積(つまり cosine)を使うか、temperature 付きの正規化内積(対比学習でよく使われる)を使うかで、並び順の結果は近いものの完全に同一ではありません。インデックス側は、実際の指標をベクトルライブラリが決定します。
よくある失敗はこうです。モデル A は正規化 + cosine を推奨し、モデル B は L2 をそのまま使うと推奨しており、インデックスの設定は A の推奨どおりに設定してこのとき比較で出た差異は実際にはモデル能力ではなく、指標の口径の違いです。正しいやり方は、「モデル + 正規化するか否か + 距離指標 + インデックス種別 + インデックスパラメータ + リランク モデル」をまとめて評価対象とするシステム構成として扱い、モデルだけを単体で比較しないことです。
見落とされがちな精度の細部もあります。正規化後のベクトルを fp16 で保存すると相対誤差が生じ、この誤差は高次元では内積で累積します。候補文書どうしの真の類似度差そのものがごく小さい量であるとき、保存精度だけで top-k の並び順が反転しうります。したがって評価時には「エンコード精度 + 保存精度 + 計算精度」をまとめて固定し、レポートに明記しなければなりません。「モデルは本番のほうがオフライン評価より少し悪い」という現象の根因は多くの場合ここにあります。モデル自体ではありません。
同理、インデックスサーバー側が fp16 を fp32 に戻す実装がローカルの評価実装と異なっていれば、再現できない差分が出ます。したがって構成フィンガープリントには dimension と正規化のスイッチに加えて、dtype も含めておくべきです。
1.4 モデル規模はただではないが、品質の唯一の情報源でもない
大きな基盤モデルほど一般に意味理解は良くなります。これは汎用ベンチマークでも非常に安定して現れます。ただし工学的な判断に載せる際には、3 つの帳尻を同時に見ておく必要があります。
1 つめはオフラインのエンコードコストです。文書の全量エンコードは一度きりの作業ですが、コーパスは増え続け、インデックスの再構築頻度は毎週、あるいは毎日になりえます。基盤モデルのパラメータ数が 2 倍になれば、エンコード時間もほぼ 2 倍になります。
2 つめはオンラインサービスのコストです。クエリのエンコードも cross-encoder によるリランクもクリティカルパス上にあります。大規模モデルの推論レイテンシと GPU メモリ使用量が efSearch に割り当てられる予算を直接圧縮します。前に述べたとおり、efSearch が下がれば recall も下がります。
3 つめはデプロイの複雑さです。一定の規模を超えたあたりから、自前の推論サービスのための GPU メモリ、同時実行数、量子化デプロイに専用のエンジニアリング投資が必要になります。さらに大きくなると、レイテンシの効果はすでに逓減していることが明瞭になります。
実務上の判断基準は「大きいほど良い」ではなく、レイテンシ予算を固定したうえで、残った予算でどれだけ recall を買えるかです。モデルを 2 倍にしても recall の改善が 1 ポイントにも届かない一方で、レイテンシ予算が 3 分の 1 消費されて efSearch が下がり、recall がむしろ悪化するなら、このアップグレードは負の利得です。評価体系はこのような非単調な関係を捉えられなければなりません。だからこそレイテンシと recall をセットで報告するわけです。
二、選定の 2 つめの軸:言語、ドメイン、テキストの形態
2.1 中英混在コーパスと多言語検索
中国語企業のナレッジベースには典型的な特徴があります。文書の主体は中国語ですが、英語の技術用語、コード識別子、製品名、略語が大量に混ざっています。このときには 2 つの目標を区別する必要があります。
1 つ目は同一言語内の検索、つまり中国語の query で中国語の文書を引くものです。これはモデルが中国語の長尾エンティティと専門的表現に対して十分に頑健であることを要求します。2 つ目は言語横断検索、つまり中国語の query で英語の文書を引く、あるいはその逆です。こちらは異なる言語のベクトルが同じ比較可能な意味空間に落ちることを要求します。
多言語対応を主張するモデルの多くは、確かに言語横断検索でそれなりの性能を示します。しかしこの能力があなたの業務で実際に必要かどうかは、前提の問題です。コーパスの 95% が中国語で、英語文書が用語を散らす程度なら、言語横断能力のために投じた学習容量は無駄です。一方、ユーザーが中国語で英語の技術文書を検索するなら、この能力は必需品になります。
見落とされがちな点がもう 1 つあります。中英が同じ chunk の中に混在しているときの内部の表現品質は、通常、純中国語でも純英語でもより劣ります。理由は、同じテキストの中で言語が切り替わると、モデルは連続した 1 本のベクトルの中で 2 つの言語の意味投影を調整しなければならないからです。評価セットには混在言語のサンプルを含める必要があります。さもなくば、本番稼動で最初に出会う badcase がそれになります。
2.2 専門用語、OOV、トークナイゼーション戦略
企業のナレッジベースで最も難しいのは、汎用的な意味表現ではなく、社内の用語です。製品の内部コードネーム、システム内で 1 人の人物名が持つ複数の表記、内部の略語。これらは汎用のトークナイザにとってはほぼ確実に分割されるか誤って分割され、モデルの語彙に収まらないため、subword 分割に頼って受け止める必要があります。
subword 分割は情報を守ってくれますが、副作用を 2 つ持ち込みます。1 つは、長い専門用語が複数の token に分けられたとき、ベクトル表現の質が分割が意味的に妥当かどうかに依存します。もう 1 つは、頻度の高い誤った分割によって本来まったく無関係な文書どうしが断片を共有して類似度が水増しされやすいことです。技術文書では特に多く起こります。API、token、embedding などを全員が繰り返し語っているからです。
評価セットの設計には直接結びつく結論が 1 つあります。純粋に用語だけの query を 1 組用意しなければならないということです。これらは構文上の手がかりを一切含まず、製品名や人名に曖昧な意図を 1 つ足しただけのものです。この種の query は「汎用能力が高い」ことと「あなたの領域に本当に適している」ことを区別する試金石です。汎用ベンチマークにはこのようなサンプルがほとんど存在しないからです。
2.3 長いテキストとコード:系統的に過小評価されている 2 つの形態
ほとんどの埋め込みモデルの有効な入力長は数百 token 程度であり、学習コーパスも文や短い段落が中心です。これにより典型的な失敗モードが生じます。単一の chunk がモデルのウィンドウに近づくか超えると、意味が希釈されます。モデルは長いテキストを「圧縮」してより包括的な要約にするわけではありません。後ろに置かれた部分のベクトル内での重みが下がるため、長い chunk のベクトルと短い query の類似度は、主に前方の内容で決まります。
対処は 3 つあり、コスト順に並べると次のとおりです。1 つめは chunk 長の制御で、長文埋め込みモデルに期待はしないこと。2 つめは長いテキストに最適化されたモデルで二次エンコードする、すなわちリコールで得られた候補の長文をもう一度エンコードすることです。3 つめは長い文書をまず階層的な要約にしてから埋め込むことで、LLM が使えるなら費用対効果が非常に高いです。
コードは別の独立した問題です。汎用の自然言語モデルは、コードの意味理解が一般に弱いです。学習しているのはコードの「自然言語による説明」であり、構造や挙動ではないからです。これは「エラーメッセージから関連コードを検索する」といった要望が成立しないことを直結して意味します。コード検索については、コードコーパス上で特化して学習した埋め込みモデルを選ぶか、最低限そのモデルの評価にコード要約だけを見るのではなく実際のコード検索タスクが含まれていることを確認してください。
2.4 ドメイン分布のずれ:言語より見落とされがちな問題
選定の際に最も真剣に扱われるのは言語です。列挙でき、説明でき、議論しやすいからです。しかし実際に結果に影響するのはドメイン分布のずれ、つまりあなたのコーパスとモデルの学習コーパスの間に存在する、話題・文体・時点における距離です。
このずれはいくつかの方面から現れます。1 つ目は文体のずれです。学習コーパスは叙述的なテキストが中心ですが、あなたの文書は表のダンプやログの断片、設定の羅列、チケットの説明などで、意味密度も構文構造もまったく違います。ベクトルも自ずから合わせにくくなります。2 つ目はテーマ集中度です。コーパスが少数の話題に高度に集中していると、モデルが学んだ「どの話題とどの話題が対応するか」という汎用的な関連性の判断は、その狭い話題の上では必ずしも信頼できません。とくに、話題内部の区別が粒度の細かい用語でしかつけられない場合はなおさらです。
3 つ目は鮮度です。過去 2〜3 年の新しい概念、新しい製品形态、新しい技術スタックは、多くのモデルの学習データでは被覆が弱いです。評価セットが主に過去の文書で構成されていると、この種の問題を系統的に過小評価してしまいます。評価セットは時間で均等にサンプルするのではなく、直近に増えた内容を意図的に一部含めるべきです。
ずれの程度を判定する安価な方法があります。モデルのゼロショット top-10 の結果を文書の出所ごとに人が一通り目視することです。ある文書が大量の無関係な query の結果に繰り返し現れるなら、その断片はベクトル空間で「一般化」した表現になっており、識別度が欠けています。これは通常、モデルがその種のテキストに適さないという直接のシグナルです。逆に、結果がごく少数の文書に極端に偏っているなら、モデルにバイアスがある可能性があり、理由を追う価値があります。
三、汎用ベンチマークが教えてくれることと、意図的に教えてくれないこと
3.1 MTEB の構成と、その平均値
MTEB のようなベンチマークの価値は明確です。十几个の検索・リランキング・分類・クラスタリングのタスクを標準化し、モデル間で比較できる座標系を与えました。初めて技術選定をする人にとっては、出発点として使えます。明らかに不合格な候補を素早く除外できるからです。たとえば多言語検索のサブセットで显著に遅れているモデルがあります。あなたの業務が中国語中心なら、この情報は意思決定の価値があります。
ただし、この平均値には構造的な問題が 4 つあります。
- 平均値が分散を隠す。平均値が高いモデルは、「論理的議論の検索」のようにデータ量が非常に多い英語タスクで得点が高いだけかもしれません。「金融法規検索」のような中国語タスクでは中位です。平均値は、この 2 つを 1 つの数に押し潰してしまいます。
- タスク規模が非常に不均衡。あるサブタスクは数十万件の query があり、別のサブタスクは数千件しかありません。後者のスコア自体が大きく揺れますが、平均値には他のタスクと同等の重みで加算されます。
- コーパス分布が業務分布と異なる。ベンチマークのコーパスは公開され、整備され、多言語ですが、あなたのコーパスはノイズがあり、標準から外れ、内部の俗語だらけです。分布が違えば、最適なモデルも変わり得ます。
- 飽和と汚染。大量のモデルが公開データ上で学習・蒸留されるにつれ、ベンチマークのスコア全体が上がり、識別度が下がります。加えて、一部のモデルの学習コーパスにベンチマークデータ自体が含まれている可能性も排除できません。
3.2 順位表そのものが動き、経験は自動更新されない
上の 4 つの構造的な問題に加えて、5 つめにも注意してください。評価ベンチマークのサブセット構成、スコアの口径、出場モデルの一覧は継続的に更新されています。昨年の経験は、今年のバージョンでは簡単に古びます。あるモデルが「当年は強かった」という印象を持っているなら、まずその当時のベンチマークのサブセット構成が今の業務と合っているかを確認してから、候補に入れるかどうかを決めてください。
同様に重要なのは、順位を人格評価のように読まないことです。ある版のベンチマークで 1 位だったというモデルが、中文の技術シーンで最適だという意味でも、2 年後もなお 1 位だという意味でもありません。転移可能な判断は 1 つしかありません。そのモデルが属する技術路線が、どの条件のもとで優位に立ち、どの条件のもとで機能しなくなるのか。这类のメカニズムに関する結論こそを選型文書に書き込む価値があり、1 年後に見直す価値もあります。
途中からランキングから引き下げられたスコアにも注目する価値があります。データ汚染や計数の問題を見つけたプロジェクトチームは、スコアを修正するのではなく、該当するモデルをそのまま降ろすことがよくあります。あるモデルがランキングから消えたとき、最初の反応は「それならだめだ」とすることではなく、能力の問題で除かれたのか、方法論上のコンプライアンスの問題で除かれたのかを調べることです。両者から得られる教訓はまったく異なり、後者はむしろこの技術ルートが注目に値することを示唆することもあります。
3.3 サブベンチマークにもそれ自身の適用限界がある
次善手として、多言語版のベンチマーク、中文サブベンチマーク、コードサブベンチマーク、長文サブベンチマークを見るという妥協もあります。総合ランキングだけを見る方がよほどマシです。とはいえ、それでも 1 つだけ問いかけるべきです。そのサブベンチマークのコーパス、タスク形態、難易度分布は、あなたのシーンに近いているのか。
より本質的で、直感に反する観察があります。中文のために組み立てられたベンチマークのスコアは、実際の中文での検索体験との相関が、人が期待するよりも弱いことがよくあります。理由は、これらのサブベンチマークの文書が、現実の企業文書と比べて、たいていよりきれい、より構造化、より短いからです。現実のシーンで問題になる(long chunk による希釈、用語の断片化、スキャン画像の OCR ノイズ、複数バージョンの文書どうしの汚染)は、ベンチマークにはほとんど出てきません。
もうひとつ注目すべきメカニズムがあります。埋め込みモデルのランキング上の優位性は、reranker に一部飲まれてしまう。あなたのアーキテクチャに reranker が入っている場合、5 点だけ弱い埋め込みモデルでも強い再順位付けと組み合わせれば、末端の差は 1〜2 点しか残らないことがあります。逆に、10 点強い埋め込みモデルであっても、再順位付けが非常に強いなら、その増分は平均値ではなく極端な難例に現れることがあります。これは直接的に意味しています。再順位付けのあるシステムで、ランキングだけを見て埋め込みモデルを選ぶと、頭部モデルの利得を体系的に過大評価してしまいます。
3.4 ランキングだけを信じてよいのはどんなときか
常に自前の評価セットを作る必要はありません。次の 3 つの状況では、公開ベンチマークに少量のサンプリング検証を足すだけで十分です。コーパスが公開の汎用コーパスであること(たとえば英語のウィキペディアや公開論文)、タスク形態が標準的な「短い query で短い段落を検索する」形であること、そして、アノテーションの人手がなく、現段階では使える出発点があればよいことです。
この 3 つの状況から一歩でも外れると、自前の評価セットは選択肢ではなく必須になります。ランキングは、最も重要な 3 つのことを教えてくれません。あなたの文書分割が、目標とする情報を同じ chunk の中に収めているのか。あなたのクエリは実際にどんな形なのか。そして、あなたのコーパスのノイズに対して、あるモデルがいかに頑健か。
3.5 汎用ベンチマークが確実にやってくれる 3 つのこと
ランキングを一律に否定することも、別の形の怠惰です。ベンチマークは 3 つの点で実際に役に立ちます。この 3 つをうまく使えば、自前の実験コストをかなり下げてくれます。
- 明らかに不適格な候補を排除すること。多言語サブベンチマークでの極端な後れ、中文サブセットでの明らかな失速、長文タスクでの切り詰め由来の不利。这类のシグナルは、自前で測り直すことなく、該当するモデルを候補から直接削除するのに十分です。
- 第 1 ラウンドの候補集合を確定すること。ランキングはおおむね妥当な事前確率を与えてくれるので、粗い絞り込みをゼロから始める必要がなく、限られた計算資源を本当に有望な 3〜5 個の候補に集中させられます。
- パイプラインの自己点検のベースラインとして使うこと。自分で測った値が明らかに低いとき、最初に疑うべきはモデルではなく評価の実装です。チャンク切り、正規化、k の値、フィルタロジック、エンコード用プレフィックス。
よく使われるデバッグ順序はまさにこうです。まず公開ベンチマークで、既知の桁数のスコアを再現してパイプラインに誤りがないことを確認し、そのあとで自分のデータに切り替えます。この 3 つを頭に入れておけば、「ランキングは信頼できない」という理由で定量化の比較をやめて、純粋な主観の感覚に後戻りしてしまうようなことにはなりにくいです。後者はそもそも再現できず、半年後に「私たちは実際に良くなったのか」という問いにも答えられません。
利用上の規律がもう 1 つあります。結果を見てから、どのサブベンチマークを使うかを決めないこと。まず総合スコアを見てから、自分の都合に有利なサブセットを選んで論証するなら、それは選択的報告であり、上で挙げた 3 つの正当な使い方とはまったく異なります。サブベンチマークの選択は、実験を始める前に、業務の分布に基づいて書き留めておくべきです。
四、タスクレベル評価:「品質」を測定可能な中間量に定義する
4.1 指標の選び方:Recall@k、MRR、nDCG の違い
タスクレベル評価の最初のステップは、「検索品質」という曖昧な言い方を、具体的な指標定義のセットに落とし込むことです。ここには混同されやすいものの、非常に重要な区別がひとつあります。
気にしているのは「候補に含まれているか」なら Recall@k、「ユーザーが最初に見るかどうか」なら MRR または Hit Rate@1、「上位 10 件の相対的な並び順の質」なら nDCG@k を使います。3 者で最適なモデルは、同じとは限りません。
典型的な 2 段構成の RAG(ベクトル召回 + クロスエンコーダによる再順位付け)であれば、正しい方針は各段それぞれについて、その段の責務に合う指標を使うことです。召回段では Recall@100(候補が十分かどうか)、再順位付け段では nDCG@10(並びが合っているかどうか)、エンドツーエンドでは最終回答の正答率または人手評価を見ます。そうしておけば、本番環境で指標が落ちたときに「検索が悪くなった」としか分からず、どの段が劣化したのか特定できない、という事態を避けられます。
もうひとつ経験則があります。k の値は習慣ではなく、下流側の処理能力で決めるべきだということです。再順位付けが top 20 しか処理しないなら、Recall@100 の 21〜100 位は召回しても無駄です。LLM で top 5 に対して 2 回目の生成を行う予定なら、Recall@5 がキー指標になります。
もうひとつ、既定で見過ごされがちな詳細に注意が必要です。指標ごとに「正解がいくつあるか」についての前提が異なるからです。関連文書が 1 本のときの Recall と、複数のときの Recall では意味がまったく違います。前者はほぼ命中判定、後者は被覆率になります。同じ評価セットでも、あるモデルは弱く関連する文書をもう少し多く召回することで Recall を引き上げられる一方、precision が同時に下がることがあります。したがってレポートではRecall と precision の両方を併記し、アノテーションが複数の正解を認めているかも述べるべきです。どちらか一方しか出さないのは、情報の半分を隠していることになります。
from __future__ import annotations
import math
import random
from typing import Dict, Iterable, List, Sequence
def dcg(gains: Sequence[float]) -> float:
return sum(g / math.log2(i + 2) for i, g in enumerate(gains))
def ndcg_at_k(ranked_rel: Sequence[int], k: int = 10) -> float:
"""ranked_rel: 该 query 下按检索顺序排列的每篇文档的相关性等级。"""
gains = [float(r) for r in ranked_rel[:k]]
ideal = sorted((float(r) for r in ranked_rel), reverse=True)[:k]
idcg = dcg(ideal)
return dcg(gains) / idcg if idcg > 0 else 0.0
def recall_at_k(retrieved: Iterable[str], relevant: Iterable[str], k: int = 10) -> float:
rel = set(relevant)
if not rel:
return 1.0
head = list(retrieved)[:k]
return sum(1 for d in head if d in rel) / len(rel)
def hit_rate_at_k(retrieved: Iterable[str], relevant: Iterable[str], k: int = 1) -> float:
rel = set(relevant)
return 1.0 if any(d in rel for d in list(retrieved)[:k]) else 0.0
def bootstrap_ci(values: List[float], n_boot: int = 2000,
alpha: float = 0.05, seed: int = 7) -> tuple:
"""配对比较前先看区间:区间大幅重叠时,单点均值的差异没有意义。"""
rnd = random.Random(seed)
n = len(values)
means = []
for _ in range(n_boot):
sample = [values[rnd.randrange(n)] for _ in range(n)]
means.append(sum(sample) / n)
means.sort()
return means[int(alpha / 2 * n_boot)], means[int((1 - alpha / 2) * n_boot)]
4.2 指標が持つ方向性の落とし穴
Recall 系の指標には、本質的な偏りがあります。「関係する文書が多くて散らばっている」query には自然に有利に、「正解は 1 つしかないのに深いところに隠れている」query には自然に不利に働きます。つまり評価セットに幅の広い query が高すぎる比率で含まれていれば、高 Recall・低 precision に偏ったモデルを選んでしまう。逆の比率なら、逆のモデルが選ばれます。
そのため、指標は必ず評価セットの構成とセットにして報告しなければなりません。より安全なのは、query の種別ごとに層別化して報告することです。ナビゲーション型(意図が明確で答えが 1 つ)、情報型(意図が広く複数の文書が必要)、排除型(ユーザーが実は否定的な答えを求めている)、そして複数ターンの文脈依存型(query が履歴に依存する)です。現実のトラフィックにおけるこれらの比率には大きな差がありますし、同じモデルでも種別ごとの強弱がしばしば真逆になることがあります。
4.3 段階ごとの帰属:指標を「約束」にする
エンドツーエンドの指標は最も誠実な一方で、ノイズが最も大きく、帰属が最も難しい指標です。生成段階そのもののランダム性が、検索段階の 2〜3 点分の差を埋めてしまうからです。したがって実践では、指標契約を用意しておくべきです。
| 段階 | 主指標 | 補助指標 | 典型的な失敗シグナル |
|---|---|---|---|
| ベクトル召回 | Recall@k | レイテンシ P99、インデックスサイズ | Recall は上がったがレイテンシが倍増した |
| フィルタ/書き換え | フィルタ前後の候補ヒット率 | 誤削除の割合 | ルールが正しい文書まで消してしまった |
| クロスエンコーダによる再順位付け | nDCG@10 | Top1 の変化率 | 再順位付けの時間が召回自体を上回っている |
| コンテキスト構築 | 回答のカバレッジ | 冗長率 | 根拠が途中で切れている、またはノイズだらけ |
| 生成 | 回答の正答率 | 引用の正確率 | 検索は合っているがモデルが答え違えた |
この表の価値は、「検索アルゴリズムは良いか」という曖昧な問いを、それぞれ測定し、それぞれ回帰検査できる 5 つの部分問題に切り分けていることにあります。こうできて初めて、次の反復でどのノブに触るべきかが分かります。
この表には見落とされがちな別の使い方があります。同時にこれは回帰テストの目次でもあります。各行がそれぞれ独立に実行でき、独立にアサートできる小さなテストの組に対応し、それを CI に載せられるからです。将来パース部分をいじった人の変更は、その行のアサーションに引っ掛かります。エンドツーエンドの指標が落ちたときまで発掘作業をしてから気づく必要がないからです。このように「指標をアサーションに分解する」ことは、評価の体系がレポートからエンジニアリングのインフラへ発展していく決定的な一歩です。
4.4 有意差:小数点以下の差に騙されないために
評価セットに含まれるのは通常、数百件の query にすぎません。この規模では、指標平均の信頼区間はかなり幅が広くなります。あるモデルの Recall@10 が別のモデルより 1.5 点高いというだけでは、それは単なる抽出のばらつきである可能性が十分にあります。正しいやり方はペアリングした比較です。同じ一批の query を用いて、2 つのモデルの指標差を query ごとに求め、その差の系列に対してブートストラップ再サンプリングを行い、差の分布が安定的に 0 より大きいかを判定します。
def compare_runs(run_a: Dict[str, float], run_b: Dict[str, float],
n_boot: int = 4000, alpha: float = 0.05) -> dict:
"""配对自助检验。run_* 是 {query_id: 指标值},必须来自同一批 query。"""
ids = sorted(set(run_a) & set(run_b))
if not ids:
raise ValueError('no overlapping queries')
diffs = [run_a[q] - run_b[q] for q in ids]
wins = sum(1 for d in diffs if d > 1e-9)
losses = sum(1 for d in diffs if d < -1e-9)
observed = sum(diffs) / len(diffs)
rnd = random.Random(13)
ge = 0
for _ in range(n_boot):
sample = [diffs[rnd.randrange(len(diffs))] for _ in diffs]
if sum(sample) / len(sample) >= observed - 1e-12:
ge += 1
p = ge / n_boot
return {
'delta': round(observed, 4),
'wins': wins,
'losses': losses,
'n': len(ids),
'p_value': p,
'significant': p < alpha,
}
ここでの判定論理は「p が小さければ差し替える」ではありません。「p が小さいなら、議論を続ける価値がある」というものです。有意でない微細な差に対する正しい結論は「このデータでは区別できない」であって、「安いほうを選ぶ」ではありません。この 2 つの文は実践のなかでしばしば同一視され、チームがノイズのために何度も判断をやり直す原因になります。
4.5 評価セットを難しいまま保つ:能動的にノイズを混ぜる
評価セットには自然な減衰過程があります。システムが強くなるにつれて、もともと識別度の高いサンプルは次第に「どのモデルでも解ける」ものになり、指標は飽和へ向かいます。そのあとは何を走らせてもシグナルは得られなくなります。サンプル数を増やすのは有効ですが、より経済的なのは ノイズ項を能動的に注入することです。ノイズ項は query の正しさを新たにアノテートする必要がなく、並びの正しさだけを確認すればよいからです。
よく使われる方法は 3 つあり、コストの低い順に並べると次のようになります。
- 同義書き換えによるノイズ。各正解に対して、意味は等価だが表現が異なるノイズ文書を 1 つずつ作ります。 「どれも関係して見えても、本当に正しく答えられるのは 1 つだけ」という状況を狙います。汎用モデルと領域適応モデルを最も鋭く見分ける手段です。
- バージョン跨ぎのノイズ。同じ文書の古いバージョンを候補集合に混ぜます。現実のシステムで最も多い badcase に対応します。取得された答えが古いが、その内容自体は意味的に完全に正しい、というものです。
- 他文書の断片によるノイズ。まったく無関係な文書から、query と多くの語を共有する部分を切り出してきます。 これは前述の語形の誤マッチ問題に直接狙いを定めたもので、技術文書のコーパスにはほぼ確実に存在します。
この 3 種類のノイズには共通の性質があります。正解が存在することは確定していて、判定コストが非常に低いからです。ノイズを含む評価セットを構築すれば、新しい query をさらにアノテートするよりずっと安く、飽和した指標に再び差をつけることができます。
4.6 候補集合サイズ k の感度分析
前述のとおり k は下流の処理能力で決めるべき原則ですが、この原則にはもう 1 つ運用上の意味があります。k は単なるパラメータではなく、単独で感度分析を行うべき変数だということです。完全なやり方は、いくつかの k(典型的には 10 / 30 / 100 / 300)について 3 本の曲線を記録することです。Recall@k は召回の上限を、nDCG@k は再順位付け後の効果を、エンドツーエンドの回答品質は業務上の実利を反映します。この 3 本の曲線の飽和点は通常一致しません。そして本当に重要な情報は、それらの交差領域にあります。
例を挙げます。Recall@100 はすでに飽和に近づき、Recall@300 でもほとんど伸びないなら、召回段への投資にはもうリターンがないことを意味します。一方、nDCG@30 と nDCG@10 の差が明瞭なら、再順位付けにはまだ活躍の余地があり、ただより大きな候補集合が必要だということです。
この 2 つの判断はまったく異なる最適化の方向に導かれます。単一の k における 1 つの数値だけを見ていても、この構造は読み取れません。
逆に、ある曲線が下落を示す場合、つまり k を増やしたのに回答品質が悪化した場合、そこには必ず具体的なエンジニアリング上の原因があります。コンテキストが長くなってノイズが増えた、再順位付けがタイムアウトして切り詰められた、あるいは token 予算が重要な根拠を押し出した。这类の非単調な挙動こそ、k 分析の最も価値のある成果です。それが、本番環境で実際に起きている問題に直接指し向けるからです。
4.7 エンドツーエンド評価のコストとその簡略化
エンドツーエンド評価は「ユーザーが本当に満足しているか」に答えられる唯一の方法です。ただし現実的な困難が 2 つあります。判定そのものがノイズを含み、生成段階のランダム性が検索側の差を埋めてしまうからです。前者は複数のアノテーターの一致を取ることで軽減でき、後者は反復サンプリングでしか薄められません。
現実的な簡略化の方法が 1 つあります。エンドツーエンド評価を「変更ごとに毎回走らせる」から「候補バージョンごとに 1 回だけ走らせる」へ格下げするということです。検索の階層では大規模な評価セットを日常のゲートとして使い、ある候補バージョンが検索層の有意差検定を通過したあとに初めて、人手をエンドツーエンド評価へ回す価値があると判断できます。
こうすれば、大きな変更は確実に検証される一方で、エンドツーエンド評価がパイプラインのボトルネックになることも避けられます。別の簡略化は自由判断を閉義題に置き換えることです。あらかじめ各 query について「正解の中で必ず触れてほしい要点」を書き下しておき、評価時はその要点が現れるかだけを確認します。判定の空間は「答えがよいか」から「複数の要点が●か×か」へ狭まり、ノイズは明確に減少します。代償として表現の質は測れませんが、表現の質は通常、検索モデルの選定で関心を持つものではありません。気にしているなら、話題はすでに別のものに移っています。
五、業務に本当に密着した小さな評価セットをつくる
5.1 データの出所:信頼性の順に並べる
評価セットのデータ源は、コストの低い順にほぼ 4 段階に分かれ、信頼性は順に高くなります。
- 本番ログからの抽出。もっとも現実的な方法ですが、長さの偏りを処理する必要があります。ユーザーは長い query を入力しがちなので、評価セットが長尾の難例に寄ってしまいます。加えてマスク処理が必要で、特に社内文書のシーンではそれが厳しくなります。
- アノテーション。抽出済みの query の一部について、アノテーターに「この質問に答えられるのはどの文書か」を判断してもらう方法です。標準的な進め方で、コストの大部分はアノテーション規約の設計と一貫性の検証にかかります。
- クリックやフィードバックの信号からの逆算。インタラクションがあった query を正例、なかった query を負例とみなします。コストは極めて低い一方でノイズは大きいです。インタラクションがなかったのは、ユーザーがすでに答えを知っていたとか、UI の問題だったといった事情もあるためです。
- LLM による合成と人手による検証。実際のログにはめったに現れない、業務上は重要なシーンを補うのに向いています。たとえば用語だけの query や言語をまたぐ query です。 ただし必ず人手による検証の工程を置かなければ、合成データには生成モデルの系統的な偏りが乗り、評価セットが構造的にある種類のモデルに有利な形にできてしまいます。
実際は複数の出所を組み合わせて使い、各サンプルの出所タグを記録しておくのが有用です。そうすれば後で「出所別の層別パフォーマンス」を報告でき、データ出所の偏りを疑ったときの感度分析にも使えます。
5.2 規模の下限:5000 件を求めるより、300 件の層別サンプルを求める
評価セットの規模に対する不安は普遍的ですが、大きい評価セットがより良い意思決定につながるわけではありません。より実用的な目標はどの業務層にも、結論を支えるのに十分なサンプル数があることであり、総数が大きいことではありません。
運用可能な目安が 1 つあります。query を 4〜6 種類の型(ナビゲーション型、広い情報型、用語型、言語跨ぎ型、文脈依存型、否定型)に分け、1 種類につき最低 50 件、全体で 300〜500 件にします。この規模なら、5〜10 点の指標差は通常は判定可能です。一方、1〜2 点の差は通常は判定できず、「区別がつかない」と正直に言うべきです。
規模を積み上げることに労力を使うより、アノテーションの品質を高めることに労力を使うべきです。実用的な方法として、各 query について「その質問に答えるのに十分な」文書集合だけをアノテートする方法があります。関連する文書をすべてアノテートすると、一貫性が著しく悪化します。アノテーターによって「関連」の境界の理解がまったく異なるからです。
5.3 層別抽出と難易度によるスライス
「1 種類あたり何件にするか」を決めるうえで、覚えておく価値のある経験があります。サンプル数はトラフィックの比率ではなく「この種類の結論をどれだけ信頼できるか」で配分するということです。トラフィック比率が最大の種類は、たいていサンプルを最も足しやすい種類です。
しかしその種類の指標は最も安定しており、モデル間の差も最も小さいものです。選定を左右するのは、サンプルが少なく分散が大きい種類です。それらはまさに、より多くのサンプルを必要とします。運用上が非常に有用なテクニックが 1 つあります。まずベースラインを走らせて各 query の難易度ラベルを取得し、「この種類で 5 点の差を区別するために必要なサンプル数」を逆算してから、その条件を満たしているかを後から点検します。条件を満たしていない種類については、サンプルを補うか、レポートに「このスライスはサンプル不足のため結論は使えない」と明記するかのどちらかです。「使えない」と明示することは、揺らぎの大きい数値を示すよりずっと有用です。後者は必ず誰かの根拠として引用されてしまいますから。
ログからランダムに 300 件しか引けないなら、簡単すぎる集合を引いてしまう可能性が高く、すべてのモデルが 95 点となり、まったく区別できません。より良い方法は、まずベースラインを 1 回走らせ、そのベースラインの難易度で層別抽出することです。
| スライス | 定義 | 役割 |
|---|---|---|
| 易 | ベースラインモデルでも top 1〜3 まで上位に出せるもの | サンプルが簡単すぎて飽和するのを防ぐ。たいてい選定は決められない設問 |
| 中 | ベースラインモデルは召回できるが top 5 に入らないもの | 重み付けモデルの主戦場であり、埋め込みモデルを分ける主要な区間 |
| 難 | ベースラインモデルが召回に失敗するもの | 語面の不一致や領域ドリフトを露呈する。差がもっとも明確に出やすい |
| 対抗 | 非常に類似しているが誤ったノイズ文書が存在するもの | 粒度の細かい区別能力を測る。汎用モデルと領域適応モデルを分ける |
難例の別の出どころはハードネガティブの挖掘です。まず現行モデルで検索し、高順位に出た一方で関連しない文書を集め、ノイズ項として追加します。これによって評価セットの識別度は大きく改善します。ただし循環論法には十分に注意が必要です。モデル A で掘り出したハードネガティブを使ってモデル A の改善版を評価すれば、そもそも A に有利になるようにできています。正しいやり方は異なる複数の出所のモデルで順にネガティブを掘ることであり、少なくともは、測定対象のモデルとは無関係なモデル(たとえば BM25 や別アーキテクチャのモデル)でノイズ集合を組み立てることです。
5.4 リーク防止:評価セットには独自のライフサイクルが必要
評価セットの最大のリスクは規模が小さいことではなく、最適化プロセスによって汚染されることです。チームが同じ評価セットのスコアを繰り返し見て、それに基づいて prompt を調整し、重みを調整し、モデルを交換し始めると、その集合は検証セットになり、テストセットではなくなります。スコアは上がり続けますが、本番環境で改善しているとは限りません。
防护措施至少包括 3 つあります。1 つは、リリース前の検証セットを固定して置き、モデル反復の過程で実行回数の上限(たとえば候補バージョンごとに 1 回)を規定し、個々の明細は公開しないことです。2 つ目は、評価セットを時間スライスに分け、より新しいスライスを検証セットとして使うことです。3 つ目は、評価セットが本番ログから来る場合、単体の query でランダムに分けるのではなく、ユーザーまたはセッションでグループ化してから分けることです。
同一ユーザーの似た質問が学習用の調整セットと評価セットの両方に現れてしまうと、深刻な過大評価につながります。import random
from collections import defaultdict
from typing import Dict, Iterable, Iterator, List, Tuple
def group_kfold(records: List[dict], folds: int = 5,
group_key: str = 'session_id',
seed: int = 0) -> Iterator[Tuple[List[dict], List[dict]]]:
"""按会话/用户分组切分,避免同一话题的近重复 query 跨越训练与评测两侧。"""
groups = sorted({r[group_key] for r in records})
random.Random(seed).shuffle(groups)
buckets = defaultdict(list)
for i, g in enumerate(groups):
buckets[i % folds].append(g)
for f in range(folds):
test_groups = set(buckets[f])
train = [r for r in records if r[group_key] not in test_groups]
test = [r for r in records if r[group_key] in test_groups]
yield train, test
def assert_no_overlap(train: Iterable[dict], test: Iterable[dict],
group_key: str = 'session_id') -> None:
a = {r[group_key] for r in train}
b = {r[group_key] for r in test}
leak = a & b
assert not leak, 'group leakage detected: %d' % len(leak)
5.5 アノテーション規約でいちばん書き切るべき 3 つのこと
評価セットの上限はアノテーションの質であり、その質の上限は規約です。次の 3 つを書き切っていなければ、アノテーションの一貫性は必ず崩壊します。
1 つ目は二値判定にするか、関連度の段階分けにするかです。二値判定ではアノテーターに「この文書でこの質問に答えられるか」を問いますが、境界は本質的にぼやけます。3 段階(アノテーション:直接回答/部分回答/関連のみ)なら境界ははるかに明確になりますが、コストは高く、指標の選択も限られます。実際には二値のほうがよく使われますが、重要なのは判定例を十分に示すこと、特に境界例の提示です。
2 つ目は複数の文書が関連する場合の扱いです。関連する文書をすべて指摘するよう求める設計では、アノテーターによって「まだ別の答えはないか」という判断が大きくばらつき、大量のノイズが混入します。運用可能な折衷案は、関連する文書を複数アノテートすることを認めつつ、関連度順に並べ替えることを要求し、少なくとも最適な文書を 1 つ明示させることです。 こうすれば recall の意味は保てるとともに、nDCG も計算できます。
3 つ目はコーパスに答えが存在しない query をどうするかです。 単純に捨ててしまうと、評価セットは拒答能力を測る次元を失います。しかもそれは、まさに本番経験で重要な種類のものです。「断るべき」集合を単独で管理し、類似度の閾値を較正するために使います。この集合は主となる評価セットよりずっと小さく、数十件あれば閾値をかなり安定させられます。
この 3 つを書き切ったあとは、必ず 2 名による独立アノテーションを 1 度行い、一致率を算出してください。一致率が足りないときは、サンプル数を増やす前に規約を修正するのが先です。規約が曖昧な状態でアノテーションすれば、アノテーションするほどノイズが速く蓄積し、最後にはデータ全体をやり直すことになります。
大模型で自動判定を行うなら、さらに 2 つの偏りには注意が必要です。1 つは位置バイアスで、判定モデルは候補リストの前方の位置に対して構造的に高い点をつけます。そのため順序を必ずシャッフルするか、位置を入れ替えた対照実験を行う必要があります。もう 1 つは長さへの選好で、より冗長な候補は、質問にもっとも正確に答えていなくても「より完全である」と判定されがちです。 対策は、プロンプトの中で「質問に答えるために必要な情報が含まれているかだけを見よ」と明示的に求めることです。さらに、人手でアノテーションした既知の部分集合を使って、自動判定の正答率を較正します。較正後の一貫性が足りないなら、臆せず人手を使うべきです。これは効率の問題ではなく、信頼性の問題だからです。
5.6 評価セットには独自の更新メカニズムが必要
評価セットは一度きりの構築作業ではなく、必然的に古びます。コーパスは増え、古い文書は削除されるか改訂され、ユーザーがクエリを投げる姿勢も変化しています。1 年前に作った評価セットは、いまや現在のシステムの現実を表しているとはいえません。
現実的に回せる更新のペースとしては、バージョン横断の比較に使う動かさないコアセットを 1 つ固定し、新しい問題を見つけるために四半期ごとに入れ替える動的セットを別に維持するのが妥当です。コアセットは小さく安定していて、専ら回帰比較のために使い、サンプルは安易に入れ替えないようにします。動的セットは最新のログからサンプリングし、差し替えを許容して、新しい失敗パターンを捉えるために使います。
見落としやすいシグナルを 1 つ監視してください。本番 query の分布と評価セットの分布のズレです。単純に新しいログから query の長さ、語彙の分布、評価セットとの類似度分布を定期的に集計しておけば足ります。ズレがはっきりしているなら、評価セットはすでに本番の状況を表していないということです。そのときは再構築をトリガーすべきで、古い集合の上でモデルの比較を続けるべきではありません。
5.7 badcase で評価セットを育てる
評価セットを最も費用対効果の高い方法で増やすのは、定期的にサンプルを無差別に増やすことではなく、本番で検出した badcase を 1 件ずつ構造化して評価セットに沈め戻すことです。このサイクルの鍵は「構造化」という 3 文字にあります。
badcase を沈める価値があるのは、その 3 つに答えられることが前提です。そのときのユーザーの query は何か。システムが返した文書はどれか。正解はどうなのか。3 つ目がもっとも難しいところです。正解を誰も説明できないなら、それは単に 1 回のユーザーの混乱にすぎません。特定できたなら、それはシステムが実際に失敗した 1 例に対応する高価値なサンプルになります。
実装レベルでは、沈める各サンプルに 3 つのタグを付けて管理します。根本原因タグ(チャンクング問題、召回失敗、リランキングの誤り、生成時のハルシネーション、閾値が高すぎる)、形態タグ(専門用語型、長文、言語横断、コンテキスト依存)、そして出所タグ(本番ログ、人手による作成、回帰の発見)です。
この 3 つのタグがあれば、評価レポートは根本原因ごとに統計をとれます。あるモデルがどの失敗パターンを実際に治したのか、またどこを壊してしまったのかを数え上げられるからです。平均スコアの改善だけを見るよりも情報量ははるかに多く、説得力も増します。
もう 1 つ、防ぐべき副作用があります。badcase を狙って組んだ評価セットは急速に過学習する、という副作用です。チームは、いつの間にか、いま分かっている失敗パターンの回収に必死になり、まだ出てきていない問題にはまったく手を打てません。運用では、この集合はカバレッジの広い安定集合と並べて保持し、比率も制御します。badcase 集合は 3 割から 4 割が妥当で、残りを安定集合に残すのが合理です。
六、リランキングモデルと埋め込みモデル:対決ではなく協業
6.1 二段構えの責務の境界
埋め込みモデルと cross-encoder によるリランキングモデルは、能力面で明確に役割分担があります。この分担が、選定のときに何を見るべきかを決定します。
埋め込みモデルが担当するのは「召回」、すなわち大規模な候補集合から、関連しうる文書を扱える小さな集合まで絞り込むことです。ここで見るべき核心的な能力は意味的なカバレッジで、答えを落とし込まずに拾い出せるかどうかです。学習方法は双塔型の contrastive learning で、query と文書のあいだの細かな相互作用そのものは見ません。得意なのは意味レベルの照合であり、精密な細粒度での対応には弱くなります。
リランキングモデルが担当するのは「精排」、つまり query と候補文書を連結して完全にエンコードし直すことです。そのため語レベルの対応関係や論理的な制約を捉えることができます。核心的な能力指標は並び替えの精度で、計算量は候補数に比例します。したがって召回のあとの小さい集合でしか実行できません。
この分担からは、選定について重要な帰結が得られます。強いリランキングを使うなら、埋め込みモデルには「召回で漏らさない」ことしか求めなくてよく、「並び替えが精密であること」は求めないということです。つまり中堅の埋め込みモデルと強いリランキングの組み合わせは、頭打ちの埋め込みモデルと弱いリランキングの組み合わせに勝ち得ます。逆に言えば、埋め込みモデルのわずかな優位も、リランキングの段階で両者とも正しく並べられれば表れません。したがって、正しい評価単位は常に「埋め込み+リランキング」の組み合わせです。
6.2 組み合わせ評価の正しい姿勢
組み合わせで評価するときは、候補集合の大きさ k は明示的に固定しておかなければならない重要なパラメータです。k を 100 から 500 に上げると、リランキングの品質は通常改善します(正解が候補に入ってくるため)。ただしレイテンシのコストは線形に増加します。そこで次のようにします。
- 埋め込みモデル横断の比較では recall_k を定数に固定します(たとえば全員 top 100 をリランキングに渡す)。これで比較されるのはリランキング後の最終的な並び替え品質になります。
- エンドツーエンドのレイテンシ予算(たとえば 300ms)を固定し、各埋め込みモデルが自分で必要な k と efSearch を選ぶようにします。これで比較されるのは予算内での最終品質です。
- 両方の視点で 1 回ずつ回します。2 つの視点で結論が食い違うなら、そのシステムはトレードオフのフロンティア上の敏感な位置にいて、より細かいスライス分析が必要です。
もう 1 つ実用的なテクニックとして、リランキングの限界寄与曲線を測ることがあります。埋め込みモデルを固定したうえで top 20 / 50 / 100 / 200 のそれぞれでリランキングを回し、nDCG@10 がいつ飽和するかを見ます。この飽和点はリランキングが実際にどれだけの有効候補を処理しているかを教えており、バッチサイズとタイムアウトの設定を直接左右します。
最後に、late interaction(多ベクトル表現)系の方式の特殊な点に注意してください。細粒度の照合能力を埋め込み段階に組み込むことで、理論的にはリランキングを置き換えられます。ただしインデックスコストが 1 文書あたり数十から数百のベクトルとなり、通常は圧縮が必要です。この種の方式を評価するときは、召回品質だけでなくインデックス規模も同時に報告しなければなりません。そうしないと単一ベクトル方式との比較が成り立ちません。
6.3 疎・密・ハイブリッド方式の評価の視点
融合戦略を評価し始める前に、よく飛ばされているもっと基本的な作業があります。それは、リランキング段階自身の対照ベースラインを作ることです。多くのチームは「同じ埋め込みモデルの下でリランキングを別のものに変えたらどうなるか」という結果を記録していないため、リランキングによる改善と埋め込みモデルによる改善を取り違えて帰し、最終的に「埋め込みモデルが重要だ」という、完全に誤っている可能性がある結論にたどり着きます。
測定方法は複雑ではありませんが、前提が 1 つあります。リランクを通さない状態の並び替え結果を、固定された入力として用意しておくことです。この入力を固定したままリランキングモデルを差し替えれば、差分はリランキング段階の純粋な寄与になります。この前提が重要なのは、リランキングのたびに召回まで実行し直してしまうと、埋め込み側の揺らぎが比較に混ざり、結論がふたたび信頼できなくなるからです。
帰属を分けたうえで優先順位をつけると、順序はしばしば直感と逆になります。中堅の埋め込みモデルに強いリランキングを組み合わせたほうが、上位の埋め込みモデルに弱いリランキングを組み合わせたほうが安定する可能性があります。逆に、リランキングがすでに能力上限に近づいていて、k を増やしてもこれ以上成果が得られないなら、埋め込み側の召回の上限が本当の制約になり、このときこそ埋め込みモデルを交換するのが投資に見合う投資になります。
疎検索は能力の軸において密ベクトルとは直交しています。語形の正確な一致に依存し、固有名詞、型番、エラーコード、珍しい略語といったクエリに対して自然に有効です。正にそこが密検索でもっとも失敗しやすい領域です。弱点も同様に明確で、言い換え、言語横断、口語的な表現にはほとんど対応できません。
評価の観点から見ると、両者の収穫は単純に足し合わせられないということを意味します。評価セットに用語型クエリがほとんどないなら、疎検索の価値を過小評価してしまいます。用語型クエリばかりなら、逆に密検索の価値を過小評価してしまいます。ハイブリッド方式の評価では、両種類のサンプルが十分な比率で存在することを確認し、両経路それぞれの寄与を別々に報告しなければなりません。
実用的な帰属方法として、候補集合を「疎検索だけでヒット」「密検索だけでヒット」「両方でヒット」の 3 つのバケットに分けて、最終的な並び替え品質をそれぞれ報告する方法があります。「両方でヒット」のバケットが明らかに高品質なら、両経路が同じ正解を並べており、重複は無駄だと分かります。「両方でヒット」の品質がむしろ下がるなら、融合戦略がよい候補を押し出してしまっています。この分割に追加の計算コストはごくぶんで、融合パラメータをどう調整するかを直接示してくれます。
ついでに、選定に直接関係する判断を 1 つ挙げておきます。評価で「用語型クエリが主要な失敗モードである」と判明したとき、正しい結論は埋め込みモデルを交換することではなく、疎検索の経路を 1 つ足すことです。domain fine-tuning よりはるかに安く、汎用能力の collapse を招くリスクもありません。
6.4 埋め込みモデルの選定に触らないべきとき
「埋め込みモデルを選ぶ」という行為そのものを意味のないものにする状況があります。リランキングはすでに強く、召回段には余裕があるという場合です。判断方法は単純で、recall_k を 200 まで上げて Recall@k がすでに飽和に近づいているか(たとえば 0.95 を超えるか)、同時に recall_k 50 と 200 のあいだで nDCG@10 の差が小さいかを見ます。そうなら、埋め込みモデルはすでにシステムの中でいちばん弱い環節です。正しい投資の順序は、まずリランキングと chunking を整え、そのあとで埋め込みモデルの交換を考えることです。
逆に、Recall@k の曲線が k を増やしてもなお明らかに伸びているなら、ボトルネックは召回にあります。この場合は埋め込みモデルを交換したりインデックスパラメータを調整したりすることの限界効果がはるかに高くなります。
七、domain fine-tuning の収益の境界
7.1 微調が本当に変えるもの
domain fine-tuning の基本的なやり方は、実際の query と文書のペアで contrastive learning を行うこと(正のペアを近づけ、負のペアを遠ざける)、あるいは既存の埋め込みモデルに射影層を足して軽量の適応をさせることです。実際に変えられるものは具体的で、限られています。
- 用語と表現の表現としての整列。「チケット保留」と「注文一時停止」があなたのビジネスでは同じ意味かもしれない、とモデルに学ばせます。
- hard negative の識別能力。これが最大の効果です。とくに類似度の高い近義文書がたくさんある場合に効きます。
- 出力空間の判別性。無関係な文書が共通の語彙を共有するだけで似てしまう、という類似度の高い誤りを減らします。
変わらないものも明確です。トークナイザが私有用語をどう切り分けるか、モデルが長いテキストを希釈してしまう問題、埋め込みと上位の生成とのあいだのインタフェースの約束。これらはアーキテクチャの問題であり、微調では解けません。
ついでに、よく飛ばされる技術的な詳細を 1 つ。損失関数の選び方が、モデルの細粒度による区別能力に直接影響するのです。トリプルやペアの並び替えに基づく損失は相対的な順序をより強調するので、並び替え品質を重視する場面に向いており、サンプルのノイズにも頑健です。純粋な contrastive loss は到達できる上限が高い一方、負のサンプルの質に強く依存します。前者は安定、後者は高い、というトレードオフが示しているのは、見落とされがちな事実です。負のサンプルをどう掘り出すかは、損失をどう選ぶかより重要なことが多い。これはまさに評価セットが担うべき役割そのものです。
7.2 収益が消える 4 つの場面
1 つ目はデータ量が足りない場合です。contrastive learning は負のサンプルの数に敏感で、数千組のサンプルでは安定した改善が得られにくく、しかもそのサンプル集合に過学習してしまう傾向があります。現れ方は、評価セットでは美しくても本番では不安定、という形です。研究では収益の安定性が正のサンプル数の増加とともに改善すると報告されていますが、この閾値はタスクによって大きく異なります。実践的には、1 回の実験のスコアを見るのではなく、「安定して再現できるか」を唯一の判断基準とするべきです。
2 つ目は文書のカバレッジが不足している場合です。A 部門の query でファインチューニングすると、モデルは B 部門で退行しがちです。もっともよく見落とされる退行の 1 つで、集計指標には現れません。業務ラインごとに切って見てはじめて表面化します。
3 つ目は汎用能力を汚染する場合です。狭い領域で長く訓練すると、モデルはその領域へ collapse し、領域横断や言語横断の検索で明らかに点数を落としてしまいます。システムに複数のドメインが同時に存在する場合、あるいは長尾の新ドメインをいつでも追加してゆきたい場合は、この種の狭化による長期的な保守コストが当期の収益を上回る可能性があります。
4 つ目は上流の変更が拡大される場合です。ファインチューニングしたモデルは、特定のトークナイザと特定の最大長に結び付いています。chunk 戦略を変更したい日、あるいは上限の短いベースモデルに置き換えたい日が来たら、ファインチューニングした資産そのものが無効になります。それなら、汎用モデル+プロンプト+ hard negative による並び替えという組み合わせのほうが、差し替えコストははるかに低くなります。
7.3 もっと費用対効果の高い代替経路
ファインチューニングを決める前に、コストが 1 桁も違う経路を先にくくまで試しておく価値があります。
1 つめ、LLM に hard negative の発掘と並び替えをさせる方法で、埋め込みモデルには触りません。この 1 歩の効果はしばしばファインチューニングのほぼ全部をカバーします。区別能力は本質的にリランキング段階が供給しているからです。
2 つめ、ハイブリッド検索で用語の完全一致を補う方法です。ベクトル検索は意味には得意ですが正確な語形の照合には弱く、BM25 や固有名詞の辞書を組み合わせれば、用語型クエリの挙動が改善し、汎用能力をまったく失いません。
3 つめ、query の書き換えと拡張です。LLM に口語的な query を重要な語彙を含む検索式へ書き換えさせれば、クエリと文書の分布の一致度がそのまま上がり、コストはファインチューニングよりはるかに安くなります。
4 つめ、文書側の表現の増強です。chunk ごとに構造化された文脈サマリーやキーワードの層を足し、合わせてエンコードさせます。これは「用語が文書に現れるが、希釈されている」という問題を解決するもので、ファインチューニングを回避できます。
この 4 つをすべて試して、しかも評価データが明確に「召回段階がボトルネックで、失敗モードが近義文書の混同に集中している」と指摘している場合だけ、ファインチューニングが候補に入るべきです。
7.4 ファインチューニングの最小実現可能な実験設計
最終的にファインチューニングすると決めた場合でも、実験そのものは想像より厳しく設計する必要があります。さもないと「うまくいっているように見える」結論を引き寄せてしまいます。
1 つめ、ベースモデルと対照条件をそろえることです。最低でも 4 条件を動かします。元の汎用モデル、汎用モデルに射影層を足したもの、あなたのデータによる全パラメータファインチューニング、そして同量のデータで訓練した対照ベースライン。このどれか 1 つが欠けると、改善が領域知識から来ているのか、それともただ「1 周学習しただけ」なのかを区別できません。
2 つめ、評価には完全に見たことのない領域を含めることです。ファインチューニング用のデータから業務ライン 1 つを丸ごと取り分けて、訓練には一切使わず、評価にだけ使います。この条件の数字は、dimension collapse が起きているかを判断するもっとも直接的な証拠であり、ファインチューニングプロジェクトでもっとも飛ばされがちなステップです。結果はたいてい見苦しい数字ですが、それこそが今回のファインチューニングが割に合う取引かどうかを決定します。
3 つめ、データ規模に対する収束曲線を記録することです。データ量の複数の段階で評価して、得られるのは単点ではなく曲線です。単点のハイスコアは、その規模における運のせいかも知れず、曲線「改善はまだ伸びているか」「今の投資はすでに飽和に近いか」「さらにデータをためる価値があるか」を教えてくれます。
4 つめ、保守コストを判断に含めることです。ファインチューニングが産むのはモデルのファイルだけではありません。それに依存する一整套の訓練データ、ベースモデルのバージョン、推論設定、回帰テストが伴います。これらの資産の保守コストが、ファインチューニングによる 2、3 点の改善を上回るなら、オフライン指標が明らかに改善していても、エンジニアリング上は割に合いません。評価レポートにあるべきなのは指標表だけでなく、このコスト判断です。
八、インデックスパラメータがモデルの品質を隠してしまう
8.1 HNSW の M、efConstruction、efSearch
評価工程でもっとも見落とされ、結果としてもっとも深刻になる層がここです。同じ埋め込みモデルでも、インデックスの設定が違えば Recall の差が 10 数点、数十点にも達します。モデルを比較するときにインデックス設定を固定していなければ、測ったのは設定の差であってモデルの差ではない可能性があります。
3 つのパラメータの意味は分けて押さえておく必要があります。
- M は各ノードの出次数で、インデックスのグラフ品質とメモリ使用量を直接決めます。一般的な出発点は 16 か 32 です。あげたときに効くのはごく高い召回域(95% 以上)で、中低の召回域での改善は限定的です。
- efConstruction はグラフ構築時の候補キューの大きさで、グラフ構造の品質に影響します。オフラインのパラメータなので、一度ビルドすればそこで終わりです。一般的な出発点は 200 で、あげるほど得られるものは目減りし、かつインデックス構築時間が線形に伸びます。
- efSearch は検索時の候補キューの大きさで、実行時パラメータです。レイテンシ予算の範囲なら動的に調整できます。召回率とレイテンシを直結させるもっとも直接的なハンドルです。
重要な判断は、現在の Recall@10 が 80% 程度ならボトルネックはモデルの側であってインデックスではないため efSearch を触ると効き、すでに 95% 以上ならボトルネックはモデル側で、インデックスを触っても何も搾り出せないというものです。逆に、インデックスの設定が緩ければ、より良いモデルに交換しても改善が見えないことがあります。天井が efSearch に塞がれているからです。
8.2 量子化と近似:召回の上限を決めるもの
メモリの壁は、ほぼすべてのシステムに圧縮を導入させます。主な道筋はスカラー量子化(int8)、積量子化(PQ/OPQ)、二値化、そして各ベクトル DB 独自のディスクインデックスフォーマットです。仕組みは異なりますが、代償は一致しています。量子化誤差がベクトルの識別可能性を下げるため、元々 3 位だった正解が 30 位まで落ちるというものです。
このへんで、単独で評価しておく必要がある点がいくつかあります。
- 量子化前の分布が適しているか。埋め込みモデルごとに出力されるベクトル分布の違いは大きく、一部の次元では分布が極端に集中し、別の次元では分散が大きいところもあります。PQ のコードブックは訓練データ上でクラスタリングして得られるので、あるモデルのベクトル分布が訓練分布によって良好にカバーされていないなら、量子化による損失は目に見えて大きくなります。同じ量子化設定でもモデルごとに損傷の度合いが異なるのは、このためです。
- 残差補正の役割。OPQ 系の方法は先に回転してから量子化することで損失を大幅に下げられます。この回転は通常、あなたのコーパスと一緒に訓練する必要があり、設定の一部として評価対象に含めます。
- 二値化の適用境界。完全な二値化は保持する情報がきわめて少なく、召回の損失は通常大きくなります。超大規模で、かつ強いリランキングを組み合わせる場合辛うじて成立しますが、それでも量子化後の Recall@k の減衰を必ず別途報告しなければなりません。
8.3 評価ではインデックス設定を固定して報告しなければならない
パラメータを走査する前に、よく見落とされる前提がまだあります。量子化設定による損傷の度合いはモデルが出力するベクトル分布に依存し、モデルごとの差は非常に大きいということです。同じ積量子化の設定でも、分布が集中しているモデルではほぼ損失がなく、分布が分散しているモデルでは明らかに点数を落とすことがあります。したがって量子化方針はモデル越しにそのまま流用できず、候補モデルごとに減衰の幅を個別に測定しなければなりません。
from dataclasses import dataclass
from typing import Iterable, List, Sequence
@dataclass(frozen=True)
class IndexConfig:
m: int = 16
ef_construction: int = 200
ef_search: int = 64
metric: str = 'cosine' # 与模型的归一化约定保持一致
quantize: str = 'none' # none | scalar | pq | binary
normalize: bool = True
def sweep_index(corpus, queries, gold: dict,
configs: Sequence[IndexConfig], k: int = 10) -> List[dict]:
"""固定语料与模型,只扫索引配置,定位“召回天花板”到底在哪里。"""
rows = []
for cfg in configs:
index = build_index(corpus, cfg)
hits, lat = [], []
for q in queries:
res = index.search(q.vector, top_k=k,
ef_search=cfg.ef_search)
hit = 1.0 if (res.ids and gold[q.qid] in res.ids) else 0.0
hits.append(hit)
lat.append(res.latency_ms)
rows.append({
'm': cfg.m,
'ef_construction': cfg.ef_construction,
'ef_search': cfg.ef_search,
'quantize': cfg.quantize,
'recall_at_%d' % k: round(sum(hits) / len(hits), 4),
'p50_ms': round(percentile(lat, 50), 2),
})
return rows
この表の正しい使い方は、まず召回率と efSearch の関係曲線を描き、現在のモデル設定で 折れ曲がり点がどこにあるかを探すことです。曲線はごく小さい efSearch で飽和するなら、インデックスはボトルネックではありません。曲線がまだ急角度で立ち上がっているなら、インデックス設定がモデルの出力を押さえ込んでおり、この状態でのモデル比較の結論は信頼できません。
もう 1 つ忘れがちな設定が類似度の閾値と空結果のポリシーです。多くのシステムは類似度が閾値を下回ると空を返しますが、この閾値が評価時に表に出なければ、見る Recall は静かに数ポイント押し下げられます。したがってレポートには閾値の分布と空結果の比率を必ず含めなければなりません。
8.4 IVF 系のインデックスと構造化フィルタ:もうひとつの隠蔽効果
グラフインデックスのほか、倒立ファイル系のインデックスの召回の挙動はまったく異なります。まず複数のバケットを粗く選び、選ばれたバケットの中だけで精算するからです。したがって鍵となるノブは probe 数で、召回率への影響はデータ量が小さいときにきわめて急激に現れます。バケットを 1 つ多く probe すると検索範囲は倍になり、コストも倍増します。
ここから誤判断しやすい現象が生まれます。同じモデルがグラフインデックスでは安定しているのに、IVF に移すと急落することがあるのです。このとき正しい結論はインデックス設定の不適合であり、モデル側の問題ではありません。逆に、モデル間の差はインデックス構造によって拡大縮小されるので、「どちらのモデルがよいか」という問いは、厳密にはインデックス設定を固定して初めて確定的な答えを持ちます。
構造化フィルタはもうひとつの隠蔽要因です。現実のシステムではほぼ常に、権限、部門、時刻範囲といったメタデータの条件が重ねられます。フィルタが検索のあとで実行されると、正しい文書の一部が黙って削除され、「なぜ recall が落ちたのか分からなく」という形で現れます。フィルタがインデックスの内側へ押し下げられると、アルゴリズムはフィルタ後の候補の中で検索を続けなければならず、等価召回はフィルタの選択率に依存します。フィルタが厳しいほど、等価召回は低くなります。
したがって評価設定には、フィルタ条件の分布と選択率、およびフィルタが検索の前・途中・あとのどこで実行されるかが、明示的に含まれていなければなりません。「フィルタなし」の条件だけで測った召回の数字は、現実のマルチテナント環境ではほぼ参考になりません。
8.5 召回率とレイテンシのトレードオフは評価の中で固定しなければならない
多くのベクトル検索のインタフェースでは、レイテンシと召回率の関係は単調に調整できます。レイテンシを増やせば、召回率が上がる。したがって「どのモデルがよいか」という問いは、次のように形が変わります。同一のレイテンシ曲線のもとで、誰の位置がより優れているか、です。
これは、ある設定における「召回 X、レイテンシ Y」を単独で報告しても情報量がほとんどないことを意味します。設定を 1 組入れ替えれば、同じように妥当な別の数字が得られるからです。本当に比較可能な書き方は 2 つしかありません。等レイテンシでの召回の比較と、等召回でのレイテンシの比較です。前者は「ユーザーの体感遅延を悪化させない」という制約に対応し、後者は「品質を譲らない」という制約に対応します。
どちらを選ぶかは業務の約束で決まります。本番に明確な応答時間の SLA があるなら前者のほうが現実に対応しています。品質が最優先で、レイテンシは交渉できるなら後者のほうが適しています。どちらの場合でも、レポートにはどちらの方式を使ったか、そしてどのレイテンシ点を基準に比較したかを明記しなければなりません。そうしないと、2 つのチームの数字は対話が成立しません。
8.6 インデックス構築そのものが不確かである
見落としがちな事実があります。モデルもパラメータもデータも完全に同じでも、2 回構築したインデックスはクエリの結果に差が出ることです。原因は、グラフインデックス構築時の並行順序とランダム性、バケット分割アルゴリズムの実装差、量子化コードブック学習時のサンプリングのランダム性などによります。
ここから実践上の要求が 2 つ生まれます。1 つめ、同じ評価レポートの中のすべての比較は、すでに構築済みの単一のインデックスから得出しなければならないということです。候補モデルごとに別々にインデックスを構築して比較すると、グラフ構築のランダム性がノイズとして混ざります。2 つめ、インデックスを再構築するなら、その再構築自体を実験変数として報告しなければならないということです。同じ設定で 2 回再構築し、指標がどれだけ揺れるかを見ます。この揺れの幅が、あなたのシステムのいちばん底のノイズフロアになります。
ついでにエンジニアリング上の提案を 1 つ。インデックス構築も再現可能な成果物にするというものです。ランダムシードを固定し、並列度を固定し、構築パラメータとインデックスのバージョン番号を一緒に記録します。完全に一致させるのが難しい場合でも、構築時間と各段階の並行設定は記録しておき、レイテンシの数値の説明範囲に根拠を持たせましょう。評価体系における再現性のうち、半分の労力は実はインデックス側に使われていて、モデル側ではないのです。
ノイズフロアを知っていれば、このあとの「X は Y より 2 点高い」という主張がはじめて論じえるようになります。この数字は想像より大きいとおもわれます。特に大規模で並行構築されるグラフインデックスではそうなるのです。また、これは「1 回だけ回して結論を出す」ことが評価においてとくに危険な理由でもあります。運の善し悪しの問題ではなく、そもそもシステムの固有の変動範囲を測定していないからです。
8.7 インデックス設定の最小報告項目
ここまでの節を 1 枚のチェックリストにまとめます。どの評価レポートにも、最低限次の項目を含めてください。どれかが欠けているなら、ある結論は第三者によって検証できないことを意味します。
| カテゴリ | 必ず記載する項目 | 書かないと何が起きるか |
|---|---|---|
| データ | コーパスのスナップショットバージョン、チャンクングの規則とパラメータ、長さの切り詰め方針 | チャンクングを変えるたびに、過去の数値が無効になる |
| エンコード | モデル名と revision、dimension、正規化の有無、精度型、クエリのプレフィックス | モデルが変わったのか設定が変わったのか区別がつかない |
| インデックス | インデックスの種別、M、efConstruction、efSearch または nprobe、量子化の方式 | 結論がインデックスパラメータによって隠される |
| フィルタ | フィルタ対象のフィールド、選択率、実行される段階 | 本番で権限フィルタを入れたあとに比較できなくなる |
| リランキング | リランキングモデル、候補数、タイムアウトと切り詰めの設定 | 帰属がずれて、どの段階の改善なのか分からなくなる |
| 評価 | 評価セットのバージョン、スライス構成、アノテーションの一致率、信頼区間 | 数値の有意性を判断できなくなる |
| 環境 | ハードウェア、並列度、キャッシュの冷温状態 | レイテンシの数値が再現できなくなる |
この表は、そのまま選定ドキュメントの骨格としても使えます。価値にあるのは項目の並び自体ではなく、「このモデルのほうがよい気がしている」を 1 項目ずつ確認できる清単に翻訳してくれることです。確認できない項目を含む結論は、まだ意思決定に書くべき段階に達していません。
九、評価における 9 つの落とし穴
落とし穴 1:同じデータをチューニングにも評価にも使う
もっとも頻出で、もっとも影響が重い落とし穴です。典型的な症状は、繰り返しのたびにスコアが着実に上がっているのに、本番の効果はとっくに停滞、あるいは退行していることです。理由は単純で、あなたが最適化しているのはこのデータの上での性能であって、汎化能力ではないからです。
見分け方は直接的です。チームが各評価サンプルの正解を知っていて、あるサンプルの「なぜ順位を間違えたのか」を議論するときに繰り返し同じサンプル群に戻ってくるなら、そのデータセットは実質的に学習データに化けています。対策は、チューニング用セットと評価用セットを物理的に分離し、レポートにチューニングのラウンド数を書くことです。固定した評価セットで連続して選択を行うこと自体が、一種の過学習なのです。
落とし穴 2:完全一致だけで判定する
「クエリにキーワードが出ていて、文書にも出ていれば正解」という判定は、一見合理的に見えます。実際にはキーワードを積み上げただけの文書に対して系統的に有利になります。もっとも差別力を持つ判定基準はこの文書はこの質問に答えられるかで、「これらの語が入っているか」ではありません。
日本語の技術シーンではこの問題はより深刻です。同義表現が非常に多いためです(「起動しない」と「起動できない」、「エラーが出る」と「例外が投げられる」)。完全一致では大量の正しい文書が関連なしと判定されます。もっとも確実な方法は、小さなサンプル群を 2 名でアノテーションしてアノテーションの一致率を計算し、一致率によって判定ルールが信頼できるかを確かめることです。一致率がしきい値を下回るなら、モデルを比較する前に判定ルールそのものを見直す必要があります。比較を急ぐべきではありません。
落とし穴 3:長いテキストとコードを無視する
メカニズムはすでに議論しました。ここでは評価の中で現れる形態を強調します。長い chunk の召回品質は短い chunk よりも明らかに低く、その劣化の幅はモデルによって大きく異なるということです。短い段落だけで構成した評価セットで選んだモデルは、長文文書のシーンに本番導入すると大幅に退行することがあります。
コードはもっと特殊です。汎用埋め込みモデルのコード理解は、自然言語の説明とほぼ同然です。評価セットに実物のコードクエリがなければ、この欠陥はまったく見えません。もっとも安い対策は、chunk の長さごとにバケット分けて指標を報告することと、「エラーメッセージからコード位置へ」というクエリを別途つくることです。この 2 つのデータがあれば、並び替えの方向性における問題が出現します。
落とし穴 4:平均の指標だけを見る
平均値はもっとも重要な情報を潰してしまいます。とくにサンプル数がスライスごとに著しく偏っているときです。スライス単位のレポートにはすでに触れてきました。ここに 1 点補足します。2 つのモデルの平均点が近いとき、正しい意思決定の根拠は差が最も大きいスライスを見ることです。もし A が主要なシーンのスライスで B より安定して上なら、平均点がわずかに低くても、選ぶべきは A です。あなたのトラフィックは均一に分布していないからです。
落とし穴 5:クエリ分布の長さの偏りを無視する
本番ログのクエリ長分布は、評価セットのそれとは異なることがほとんどです。とくに短いクエリが绝大多数を占めるシーン(検索ボックスに 2、3 語しか入力しない場合)では、最適化の方向性が大きく変わります。評価セットに長クエリの比率が高いままなら、選ばれるモデルは複雑な意味の処理ではなく精密な語形の照合を優先するモデルになります。
正しいやり方は、本番の実際の長さ分布を使って評価セットを再サンプリングすること、あるいは長さごとに層別して報告することです。どちらも難しいことではありませんが、コストの大きい選定ミスを 1 回避けられます。
落とし穴 6:検索器と生成器とのミスマッチを無視する
この罠は、エンドツーエンド評価をしたときにだけ踏みつけるものです。検索側の指標は上がったのに、回答の品質は変わらない。むしろ悪化することもあります。よくある原因は 2 つあります。より多くの関連文書が召回されて文脈が長くなり、ノイズが混入した。あるいは、召回された文書は関連していたとしても、肝心の情報は途中にあり、文脈の「途中の情報が失われる」効果で弱められた。
したがって、埋め込みモデルを変更するたびに、検索側の指標に加えて、参照回答をベースとした人手または自動判定の回答品質指標も併せて報告すべきです。50 件の粗いサンプリングで十分です。検索側の指標だけを見て本番に上げるのは、検索を目的ではなく手段として扱う、典型的な間違いです。
落とし穴 7:エンコード条件の不整合
文書のエンコードとクエリのエンコードは、多くのフレームワークで別のコード経路を通ります(異なるプレフィックス、異なる正規化、異なる最大長の切り詰め)。この種の問題は評価工程で殊に見つけにくいのは、オフラインのバッチ評価には影響せず本番の挙動だけを左右するからです。オフラインでは正しい設定を使い、本番のいずれかのサービスが誤った設定をキャッシュしつづけていた、という状態です。
防御手段は、エンコード設定をバージョン管理のコンフィグファイルに置くことと、文書側とクエリ側にそれぞれ一貫性チェックを書くことです。両側の正規化のスイッチ、プレフィックス、最大長がリリースのたびに一致していることを assert します。
落とし穴 8:文書の鮮度とバージョンを無視する
現実のシステムでは、評価セットのクエリに対応する「正解文書」が、数か月前には別の文書だったことがあります。評価セットが文書のバージョンを記録していなければ、出てくるスコアは時間とともに漂い、この漂いはモデルの変更とはまったく無関係です。対策は各アノテーションに文書のバージョンまたはタイムスタンプを持たせ、評価時にそれが現在も有効かを検査することです。失効したサンプルは、平均に黙って参加させるのではなく、明示的に印を付けて除外します。
落とし穴 9:1 回の結果を結論にする
埋め込みモデルのエンコードは決定的です。したがって「複数回実行した結果が違う」のは通常、ランダム性の問題ではなく設定の不整合です。そうであっても、インデックス構築の順序、並列度、キャッシュの状態が相当のゆらぎを生むことがあります。稳妥なやり方は、評価ごとに完全な設定のフィンガープリント(モデルのバージョン、dimension、正規化、インデックスパラメータ、量子化の方式、データセットのバージョン)を記録し、複数回実行したレポートを残すことです。2 回の実行の結論が異なるなら、その差がノイズの水準内にあるということであり、その時点では何も決定すべきではありません。
これに付随する記録の規律が 1 つあります。レポートには今回実行のノイズフロアがいくだったかを書いておくこと(インデックス再構築の実験とブートストラップ再サンプリングから導出します)、そしてそれより小さい差はすべて「区別できない」と注記することです。この規律は形式上の要求にしか見えませんが、実際の効果は強烈です。「このモデルのほうが少しよい」という判断が、主観から明確なしきい値をもつ命題に格下げされ、ついでに「1.5 点差でモデルを交換する」ような行き来も大量に消えるからです。
def config_fingerprint(**fields) -> str:
import hashlib
payload = '|'.join('%s=%s' % kv for kv in sorted(fields.items()))
return hashlib.sha256(payload.encode('utf-8')).hexdigest()[:12]
fp = config_fingerprint(model=name, revision=rev, dim=dim, max_len=max_len,
normalize=True, metric=metric, m=m, ef_c=efc,
ef_s=efs, quantize='none', dataset=dataset_version)
assert fp_run_a == fp_run_b or noted, 'config changed between runs' # 两次对比必须来自同一次运行
落とし穴 補足:推薦システムの経験をそのまま持ち込む
高頻度に出じる典型的な誤りは、推薦システムの評価直感を検索にそのまま当てはめることです。両者には確かに共通点があります。どちらも候補の並び替えだからです。ただし決定的な違いはもっと大きいです。推薦の候補プールは高度にパーソナライズされ、行動ログから成長したものです。検索の候補プールは全体で共有され、知识ベースから来ます。
そこから 3 つの具体的な偏りが生じます。1 つめ、推薦では「多く露出している」ことは「関連がある」ことを意味しませんが、検索における「インタラクションがない」は通常、中立なシグナルにすぎません。したがってクリックログから負のサンプルを逆算するやり方を検索にそのまま移すと、系統的なノイズが混入します。これが hard negative の発掘が殊に慎重に掘り出さなければならない理由です。2 つめ、推薦が最適化するのは長期的なリテンションであり、検索が求めているのはその場での正解です。評価関数が根本的に異なります。3 つめ、推薦における多様性や分散の制約は、検索のシーンでは通常成り立ちません。無理に当てはめると、オフラインの指標が真の目標から乖離します。
移植してよいのは、そのうち業務に依存しない方法論の部分です。層別スライス、信頼区間、ペア比較、版管理の回帰。これらは評価科学そのものの共通項で、具体的な業務とは無関係だから、安心して借りられます。
落とし穴 補足:指標の定義がツール間で静かにズレている
最後の見落としやすい罠はツールチェーンの層で起きます。同じ「Recall@10」という名前でも、ベクトル DB のサンプルコードや評価フレームワークごとに定義が同じとは限りません。余弦類似度で並べて数えるもの、正規化ありと無しの両方で 1 回ずつ計算して平均するもの、フィルタ適用後の結果で数えるもの、フィルタ適用前で数えるものがあります。
この種の差はエラーを出さず、2 つの数字の比較可能性だけを失わせます。防御手段は、すべての指標をプロジェクト内の 1 つの参照実装でのみ計算することを許すことであり、他のツールの出力は最終指標ではなく中間量(生の順位リストなど)としてのみ扱います。レポートにはその実行の重要な中間量をいくつか併記します。たとえば top-10 のヒット数、生の距離スコアです。こうしておけば、将来定義を修正する必要が生じたときも過去データを再計算できます。
十、そのまま動かせる評価パイプライン
10.1 データ層
データ層で固定すべきものは、想像より多いです。バージョン番号を持つ文書集合(チャンクングの規則、チャンクングのパラメータ、最大長の切り詰め方針を記録)、出所タグと層別タグを持つ query 集合、アノテーション規約と一致率のレポート、そして固定分割されたチューニング用セットと検収用セットです。
文書集合にも版管理が必要です。チャンクング戦略を調整すると、すべてのアノテーションが無効になるからです。合理的なやり方は、チャンクング規則が変わったときに自動的にアノテーションへの影響分析を起動し、新しいチャンクングの下で答えの文書が失われたり分割されたりしたアノテーションを一覧することです。
10.2 実行層
実行層の中核は設定駆動の実験ランナーです。入力は候補モデルのリストと設定の行列、出力はスライス別の指標表、レイテンシ/メモリ表、そして設定のフィンガープリントです。ランナーは、同一回の実行のなかにすべての設定が同一の文書エンコードと同一の query 順序を共有することを保証しなければなりません。そうでなければペア比較が成り立ちません。
実験行列は一度に爆発させてはいけません。妥当な順序はこうです。まず固定のデフォルトインデックス設定で 1 回の粗い篩いを行い(通常は候補 3〜5 で十分)、明らかに遅れているものを除外します。次に生き残った候補について dimension の走査と efSearch の走査を行い、それぞれの品質−レイテンシ曲線を見つけます。最後に等レイテンシ予算のもとで最終比較を行います。この順序なら実験回数を許容できる範囲に収められます。
10.3 レポート層
合格した評価レポートには少なくとも 6 つの部分が必要です。設定のフィンガープリントとデータセットのバージョン、スライス別の指標表(信頼区間を含む)、ペア比較の有意性と勝敗の統計、品質−レイテンシ−メモリの 3 軸トレードオフ図、主要な badcase のリストとその帰属、そして今回の結論が明確に答えられないという問いの明示です。
from collections import defaultdict
from typing import Dict, List
def slice_report(runs: Dict[str, float], meta: Dict[str, dict],
k: int = 10) -> List[dict]:
"""按来源 / 难度 / 语言 / chunk 长度分层汇总,禁止只报总体均值。"""
buckets = defaultdict(list)
for qid, value in runs.items():
m = meta[qid]
buckets[('source', m['source'])].append(value)
buckets[('difficulty', m['difficulty'])].append(value)
buckets[('lang', m['lang'])].append(value)
buckets[('chunk_len', m['chunk_len_bucket'])].append(value)
rows = []
for (dim, value), vals in sorted(buckets.items()):
lo, hi = bootstrap_ci(vals)
rows.append({
'dimension': dim,
'slice': value,
'n': len(vals),
'recall_at_%d' % k: round(sum(vals) / len(vals), 4),
'ci95': [round(lo, 4), round(hi, 4)],
})
return rows
10.4 回帰ゲート:評価をパイプラインの一部にする
評価体系が本当に価値を発揮するのは、悪い変更の本番投入を止め始めたときです。そのためには評価を CI やリリースプロセスに組み込み、参考資料としてのレポートではなく硬いゲートとして扱う必要があります。
ゲートの設計ですべての指標を自動ブロックの閾値にしてはいけない、というよくある落とし穴を避けてください。評価セットは数百件しかなく、指標には本質的に揺らぎがあります。閾値が厳しすぎるとパイプラインが頻繁に誤検知を出し、遂には全員がそれを無視する習慣がついてしまいます。これはゲートがないより悪いことです。妥当なやり方は2 層のゲートにすることです。重要な指標には絶対の下限(通常は比較的緩いハード閾値)を設け、ベースラインに対する変化にはソフトな警告を設けます(低下が一定の幅を超えた場合は人の確認と badcase の添付を求めます)。さらに、前述のペア単位の有意差検定で、その警告が反応に値するものかどうか判断します。
ゲートはデータフィンガープリントも持たなければなりません。コーパスのバージョン、チャンクング規則、評価セット自体が変わった場合、指標は過去と直接比較できません。このときは比較を自動的にスキップするか新しいベースラインに切り替えるべきで、「大幅に低下」という偽の警報を報告してはいけません。そうしないと最初の誤検知のあとで全員がゲートの出力を無視するようになり、ゲートは事実上機能しなくなります。
10.5 そのまま書き写せる実行順序
ここまでの内容を 1 本の実行可能な経路に圧縮すると、おおむね次のようになります。まずシステム設定のベースライン(モデル、正規化、距離指標、インデックス、リランキング)を固定してフィンガープリントを記録します。次に公開ベンチマークで 1 回パイプラインの自己点検を行い、実装が正しいことを確認します。続いて 300〜500 条の層別評価セットを作り、アノテーションして一致率を検証します。そして候補モデルを 2〜3 個使って第 1 回の粗い篩いを行い、平均値だけでなくスライスで報告します。生き残った候補について dimension とレイテンシ予算を走査し、それぞれのトレードオフ曲線を描きます。等レイテンシ制約のもとで最終比較を行い、ペア検定で差が有意であることを確認します。最後に設定を固定し、この評価を回帰ゲートとしてつなぎ、検収用セットの使用回数の上限を取り決めます。
この経路でもっとも飛ばされがちな 2 ステップは、たまたまもっとも価値の高い 2 ステップです。層別スライス報告は誤った選定を 1 回防ぎ、検収用セットの使用回数の制限は、体系全体が半年で失効することを防ぎます。どちらも即座に見える数字を生みませんが、このあとのすべての数字が信頼できるかどうかを決定します。
10.6 繰り返し現れる誤り:評価を一度きりのプロジェクトにする
多くのチームの評価体系は、ある一点集中した最適化の中で作られ、その 1 ラウンドを終えるとそこで止まります。3 か月後に「モデルを交換して効果が出るか」と聞かれても、使える評価セットがないため、また感覚だけで決めることになるのです。これは前述した落ち穴が繰り返し現れる根源です。誰かが不用心だったからではなく、体系が欠けているときの必然的な表れだからです。
生き残らせるために必要なのは、2 つだけ守ることです。1 つは評価の実行コストが十分に低いことで、検索に関わる変更のたびに手作業で回せる水準にします。もう 1 つは結果を実際に意思決定に使っていることです。つまり毎回の選定でレポートを書いて、「今回はモデルを交換しない」という結論も含めて記録します。交換しないことも記録することが殊に重要です。評価が「交換を促す道具」から「交換すべきかどうかを判断する道具」に変わるので、はじめて信頼できる尺度になります。
結び:選定を反証可能な 1 回の実験に変える
整篇文章を振り返ると、要点は 1 つに集約されます。埋め込みモデルの選定は知識の問題ではなく、実験設計の問題であるということです。汎用ベンチマークは候補の範囲は狭めますが、しかし意思決定の代わりにはなりません。順位表の順位は、あなたのコーパス上での測定の代わりにはなりません。1 回のスコアはペア比較の代わりにはなりません。孤立したモデル指標は、「埋め込み+リランキング+インデックス」というシステム指標の代わりにはなりません。
実行可能な提案を 3 つだけ残すとしたら、こうです。1 つめ、何かを比較する前に「モデル+正規化+距離指標+インデックスパラメータ+量子化+リランキング」を 1 つのシステム設定に固定し、比較対象をモデルではなく設定にすること。2 つめ、評価セットはスライスで報告し、結論が「区別できない」まで保守的に留めても、ノイズに基づく決定はしないこと。3 つめ、ボトルネック診断で投資順を決めること。Recall の曲線がまだ立ち上がっているならインデックスとリランキングを変え、Recall は飽和しているのに nDCG が上がらないならリランキングとチャンクングを変え、両方が飽和して初めて埋め込みモデルの交換を考えます。
最後に 1 点、念のための注意です。評価体系それ自体、保守すべき資産です。コーパスは変わり、query は変わり、上流のモデルは変わり、アノテーションは古びます。評価セットを版管理し、回帰チェックを CI の一部にしてはじめて、本当に守ってくれます。さもないと、ある日「誰も触れたがらない、必要に応じて部分的にしか信じられない歴史的なドキュメント」に変わってしまいます。
- 本番投入前チェックリスト:設定のフィンガープリントを記録したか。評価セットを平均値だけでなくスライスで報告したか。ペア単位の有意差検定を行ったか。インデックスパラメータを固定し、単独で走査したか。量子化/圧縮による召回の減衰を数値化したか。エンドツーエンドの回答品質のサンプリング検証を通したか。エンコード設定について文書側とクエリ側の一貫性チェックを通したか。失効したアノテーションサンプルを印付けして除外したか。