導入
「RAG(検索拡張生成)を導入すれば、社内データに基づいた正確な回答が得られるはずだ」
そう信じてプロトタイプを作成したものの、いざ運用してみると、AIが自信満々に嘘をつく現象――いわゆるハルシネーションに頭を抱えているエンジニアの方は多いのではないでしょうか。
「プロンプトで『分からない場合は分からないと答えて』と指示しているのに、無理やり回答を作ってしまう」
「全く関係のないドキュメントを参照して、もっともらしい虚偽情報を生成する」
こうした問題に直面したとき、多くの開発現場ではプロンプトエンジニアリングによる微調整を繰り返してしまいます。しかし、実務の現場における一般的な傾向として言えることですが、確率論的に動作するLLM(大規模言語モデル)の出力を、自然言語の指示(プロンプト)だけで完全に制御しようとするのは、エンジニアリングのアプローチとして不十分です。
問題の本質を捉え、構造的に解決するためには、決定論的なロジックによる「防波堤」が必要です。それが、今回解説する類似度フィルタリング(Similarity Filtering)です。
本記事では、RAGシステムのバックエンドエンジニアに向けて、感覚的な「閾値(Threshold)」設定から脱却し、統計的根拠に基づいてハルシネーションを抑制するインテグレーション手法を解説します。Pythonによる具体的なコードとともに、ステークホルダーに対して「なぜその精度なのか」を論理的に説明できるレベルの知見を提供します。
ハルシネーション制御における「類似度フィルタリング」の役割
そもそも、なぜRAGにおいてハルシネーションが発生するのでしょうか。その最大の要因は、LLMそのものの性能というよりは、Retrieval(検索)フェーズにおける「ノイズの混入」にあります。
なぜLLMは自信満々に嘘をつくのか
LLMは、与えられたコンテキスト(検索結果)を「真実」として扱おうとする強いバイアスを持っています。もし、ユーザーの質問に対して検索システムが「関連性の低いドキュメント」や「全く無関係なドキュメント」を取得してしまった場合、LLMはその誤った情報を元に、文法的に正しい回答を生成しようと努力します。
これを防ぐためには、「ゴミを入れない(Garbage In, Garbage Outの回避)」ことが鉄則です。しかし、ベクトル検索(Vector Search)の特性上、データベース内のドキュメントとの距離計算は必ず行われ、たとえ無関係であっても「最も近いもの」がスコア付きで返ってきます。ここが落とし穴です。
プロンプト制御 vs アルゴリズム制御
プロンプトで「関連情報がない場合は回答しないでください」と指示することは有効ですが、LLMの解釈には揺らぎがあります。一方で、検索スコア(類似度)に基づいたフィルタリングは、数値による明確な線引きが可能です。
- プロンプト制御: 確率的。モデルのバージョンや気温(Temperature)パラメータに依存する。
- アルゴリズム制御: 決定的。設定した閾値を下回れば、物理的にコンテキストをLLMに渡さない。
ビジネスレベルのアプリケーションでは、この二重の防御壁が必要です。特にアルゴリズム制御は、APIコストの削減(無駄なトークンを送らない)にも直結するため、ROIの観点からも重要です。
フィルタリングを挿入すべき2つの重要なポイント
類似度フィルタリングを組み込む箇所は、主に以下の2点です。
- Pre-Generation(生成前): ベクトル検索の結果、類似度スコアが閾値以下のチャンク(Chunk)を切り捨てる。もし全てのチャンクが閾値以下なら、LLMへのリクエスト自体をキャンセルし、定型文を返す。この手法はレイテンシへの影響が最小限であり、コスト削減効果も高いため、最も推奨されるアプローチです。
- Post-Generation(生成後): 生成された回答と、参照元ドキュメントの整合性を検証する(Fact Verification)。これはRAGの評価指標として知られるFaithfulness(忠実性)の概念に近いものですが、リアルタイム処理で行うには別のLLM呼び出しが必要となり、レイテンシとコストが増大します。そのため、多くの実運用環境では、まずPre-Generationでのフィルタリングを確実に実装することが優先されます。
実装アーキテクチャと技術スタックの選定
理論をコードに落とし込む前に、どのような技術スタックでフィルタリングシステムを構築すべきか、アーキテクチャの視点から整理しましょう。本番環境で安定稼働させるためには、単なる機能実装だけでなく、コスト効率と運用性を考慮した設計が不可欠です。
ベクトルデータベースとフィルタリング層の分離設計
Pinecone、Weaviate、Qdrantといった主要なベクトルデータベースは急速に進化しており、特にPineconeの最新サーバーレスアーキテクチャ(Pinecone Serverless)では、待機コストが劇的に削減され、大規模なRAG構築のハードルが下がっています。多くのデータベースにはクエリ時にscore_thresholdパラメータを設定できる機能が備わっていますが、システム設計の観点からは、アプリケーションコード側(Pythonなど)で明示的にフィルタリングロジックを持つことが推奨されます。
その理由は、以下の3点に集約されます:
- ロジックの透明性と制御: DB側のブラックボックスな実装に依存せず、システム側で完全に制御可能な状態を保てます。
- 動的な閾値調整: ユーザーの属性、質問のカテゴリ、あるいは前段のモデル判定に応じて、閾値を動的に変更する柔軟性が生まれます。
- 「捨てたデータ」のログ収集: これが最も重要です。DB側でフィルタリングすると、閾値に届かなかったデータは結果に含まれません。アプリ側でフィルタリングすれば、「閾値以下で切り捨てられたデータ」をログとして保存でき、後の精度分析や閾値の再調整に活用できます。
Embeddingモデルの選定基準:多言語対応と次元数
類似度スコアの信頼性は、文章をベクトル化するEmbeddingモデルの性能に直結します。技術スタック選定における主な選択肢は以下の2つです。
OpenAI (text-embedding-3シリーズ等):
汎用性が高く、手軽に導入できるのが最大の利点です。OpenAIの最新モデルは多言語対応も強化されており、コストパフォーマンスに優れています。ただし、API経由であるためネットワークレイテンシが発生する点は考慮が必要です。HuggingFace (ローカルモデル):
intfloat/multilingual-e5シリーズやBGE-M3など、日本語や多言語に特化したモデルを選択できます。ローカル環境(自社サーバーやコンテナ内)で動作させるため、データプライバシーの確保やレイテンシの極小化が可能です。
初期の検証フェーズや小規模なプロジェクトでは、管理コストの低いOpenAIのモデルで開始し、スループットやセキュリティ要件が厳しくなった段階で、HuggingFace上の特化型モデルへの切り替えを検討するのが合理的なアプローチです。本記事のコード例では、多くの開発者が利用するOpenAI互換インターフェースを想定しますが、ロジック自体はどのモデルを採用しても共通です。
処理遅延(レイテンシ)と精度のトレードオフ
フィルタリング処理自体(数値の比較)にかかる計算コストは、現代のCPUでは無視できるほど微細です。しかし、その前段にあるベクトル検索やEmbedding生成には物理的な時間がかかります。
ユーザー体験を損なわないためには、システム全体のレイテンシを監視することが重要です。特に、Pythonのリスト操作で非効率なループ処理を書くと、データ量が増えた際にボトルネックとなり得ます。そのため、NumPyなどのベクトル演算ライブラリを活用し、高速な行列演算として処理することを基本としてください。
ステップ1:ベクトル空間における「距離」の計算実装
それでは、具体的な実装に入りましょう。まずは、スコアを算出するコアロジックです。RAGにおいて最も一般的に使用されるのは「コサイン類似度(Cosine Similarity)」です。
コサイン類似度 vs ユークリッド距離:RAGにおける正解は?
なぜユークリッド距離ではなくコサイン類似度なのでしょうか。
- ユークリッド距離: 2点間の直線距離。ベクトルの「大きさ(長さ)」に影響を受ける。
- コサイン類似度: 2つのベクトルの「なす角」の余弦。ベクトルの大きさではなく「方向」の近さを測る。
文章の埋め込みベクトルにおいて、ベクトルの長さは文章の長さや頻出単語数に依存することがあります。しかし、ここで重要になるのは「意味的な方向性」が一致しているかどうかです。そのため、方向の一致度を見るコサイン類似度がRAGには適しています。値は -1(正反対)から 1(完全に一致)の範囲を取ります。
Pythonによる類似度計算関数の実装
以下は、PythonとNumPyを使用した、型ヒント付きの計算ロジックです。Scikit-learnを使う方法もありますが、依存関係を減らすためにNumPyだけで実装するケースも多いため、基礎的な実装を紹介します。
import numpy as np
from typing import List, Union
def cosine_similarity(v1: Union[List[float], np.ndarray], v2: Union[List[float], np.ndarray]) -> float:
"""
2つのベクトル間のコサイン類似度を計算する。
Args:
v1 (Union[List[float], np.ndarray]): ベクトル1
v2 (Union[List[float], np.ndarray]): ベクトル2
Returns:
float: コサイン類似度 (-1.0 〜 1.0)
"""
# NumPy配列への変換
vec1 = np.array(v1)
vec2 = np.array(v2)
# ゼロベクトルのチェック(ゼロ除算防止)
norm1 = np.linalg.norm(vec1)
norm2 = np.linalg.norm(vec2)
if norm1 == 0 or norm2 == 0:
return 0.0
# 内積の計算
dot_product = np.dot(vec1, vec2)
# コサイン類似度の計算: (A . B) / (|A| * |B|)
similarity = dot_product / (norm1 * norm2)
return float(similarity)
# 使用例
query_vec = [0.1, 0.5, 0.2]
doc_vec = [0.2, 0.4, 0.1]
score = cosine_similarity(query_vec, doc_vec)
print(f"Similarity Score: {score:.4f}")
バッチ処理による高速化テクニック
実運用では、1つのクエリベクトルに対して、複数のドキュメントベクトルとの関連性を一括で計算する必要があります。forループで回すのは非効率ですので、行列演算で一括処理します。
def batch_cosine_similarity(query_vec: np.ndarray, doc_matrix: np.ndarray) -> np.ndarray:
"""
クエリベクトルと複数のドキュメントベクトル間の類似度を一括計算する。
Args:
query_vec (np.ndarray): クエリベクトル (次元数,)
doc_matrix (np.ndarray): ドキュメント行列 (ドキュメント数, 次元数)
Returns:
np.ndarray: 各ドキュメントとの類似度スコア配列
"""
# ノルム計算
query_norm = np.linalg.norm(query_vec)
doc_norms = np.linalg.norm(doc_matrix, axis=1)
# ゼロ除算回避
if query_norm == 0:
return np.zeros(doc_matrix.shape[0])
# ドキュメントノルムが0の場合は安全な値に置換(警告を避けるため)
doc_norms[doc_norms == 0] = 1e-10
# 内積計算
dot_products = np.dot(doc_matrix, query_vec)
# 類似度計算
similarities = dot_products / (query_norm * doc_norms)
return similarities
ステップ2:閾値(Threshold)決定のための統計的アプローチ
ここが本記事のハイライトです。多くの開発現場で「なんとなく 0.7 くらい」と設定されがちな閾値ですが、これは非常に危険です。使用するEmbeddingモデルによって、スコアの分布は全く異なるからです。
例えば、OpenAIのtext-embedding-ada-002では、全く関係ない文章同士でも 0.7 程度のスコアが出ることがよくあります。一方、別のモデルでは 0.5 でも高い関連性を示すことがあります。
「なんとなく0.7」は危険:ヒストグラムによる分布分析
適切な閾値を決めるには、対象のデータセットにおけるスコア分布を可視化する必要があります。
- 正解ペア(Positive Samples): 質問と、それに対応する正しい回答を含むドキュメントのペア。
- 不正解ペア(Negative Samples): 質問と、無関係なドキュメントのペア。
これらを数百件用意し、それぞれの近接度スコアを計算してヒストグラムを描画します。通常、正解ペアの分布は高いスコア帯(右側)に、不正解ペアは低いスコア帯(左側)に山を作ります。この2つの山が交差する部分が、閾値設定の悩みどころであり、調整ポイントです。
正解データセット(Ground Truth)を用いた境界値の探索
統計的に最適な閾値を決定するためには、以下の指標を理解する必要があります。
- Precision(適合率): 検索結果として返したもののうち、本当に正解だった割合。「嘘をつかせない」ことを重視する場合、これを高める。
- Recall(再現率): 本来検索すべき正解のうち、漏らさず検索できた割合。「答えられない」を減らしたい場合、これを高める。
ハルシネーション抑制においては、Precisionを優先すべきです。つまり、「見逃し(Recall低下)」を許容してでも、「間違い(Precision低下)」を防ぐ設定にします。
ROC曲線を用いた最適な閾値の算出
以下のコードは、最適な閾値を探索するための概念実証コードです。
from sklearn.metrics import precision_recall_curve
import matplotlib.pyplot as plt
def find_optimal_threshold(y_true: List[int], y_scores: List[float], min_precision: float = 0.95):
"""
目標とする適合率(Precision)を満たす最小の閾値を探索する。
Args:
y_true: 正解ラベル(1: 関連あり, 0: 関連なし)
y_scores: 類似度スコア
min_precision: 目標とする最低適合率
Returns:
float: 推奨される閾値
"""
precisions, recalls, thresholds = precision_recall_curve(y_true, y_scores)
# 目標Precisionを超える閾値の中で、最もRecallが高い(閾値が低い)ものを探す
# thresholds配列はprecisionsより1つ短いので注意
optimal_threshold = 0.8 # デフォルト値(安全策)
for p, t in zip(precisions[:-1], thresholds):
if p >= min_precision:
optimal_threshold = t
break
return optimal_threshold
# この関数を使って、テストデータセットから閾値を算出します。
# 例えば、ハルシネーションを極限まで減らしたいなら min_precision=0.95 (95%) に設定します。
このアプローチをとることで、「なぜ閾値を0.82にしたのですか?」と問われた際に、「対象のデータセットにおいて、誤検知率を5%以下に抑えるための統計的な境界値だからです」と論理的に回答できます。
ステップ3:フィルタリングロジックの統合と例外処理
閾値が決まったら、実際のRAGパイプラインに統合します。ここでは、単に切り捨てるだけでなく、ユーザー体験(UX)を考慮した例外処理が必要です。
Re-ranking(再ランク付け)との併用実装
より高度な精度を求める場合、ベクトル検索(第1段階)で広めに候補を取り(Recall重視)、その後に「Cross-Encoder」と呼ばれる高精度なモデルで再ランク付け(第2段階)を行い、そこで厳密なフィルタリング(Precision重視)を行う構成がベストプラクティスです。
Cross-Encoderは計算コストが高いですが、入力された2つの文章の関係性を深く理解できるため、ベクトル検索の弱点である「単語は似ていないが意味は同じ」あるいは「単語は似ているが意味は逆」といったケースを正しく判定できます。
「関連情報なし」と判定された場合のフォールバック設計
すべての検索結果が閾値を下回った場合、LLMに空のコンテキストを渡してはいけません。明示的に「情報が見つかりませんでした」と返すフローを実装します。
class RAGPipeline:
def __init__(self, threshold: float):
self.threshold = threshold
def retrieve_and_filter(self, query_vec, documents):
# 検索処理(省略)
results = vector_db.search(query_vec)
# フィルタリング
valid_results = [
doc for doc in results
if doc.score >= self.threshold
]
if not valid_results:
return None # 関連ドキュメントなし
return valid_results
def generate_answer(self, query, valid_results):
if valid_results is None:
# フォールバック処理
return "申し訳ありません。ナレッジベースに関連する情報が見つかりませんでした。"
# 通常のRAG生成処理
context = "\n".join([doc.text for doc in valid_results])
return llm.generate(query, context)
このフォールバック処理こそが、ハルシネーションを物理的に阻止する最後の砦となります。
メタデータフィルタリングとの組み合わせ
スコアだけでなく、メタデータ(カテゴリ、日付、部署など)による絞り込みも併用しましょう。例えば、特定のカテゴリのドキュメントのみを検索対象に限定することで、ベクトル空間上の探索範囲が狭まり、結果として精度も向上します(これをPre-filteringと呼びます)。
運用と継続的な精度監視
システムをデプロイして終わりではありません。データの傾向は日々変化します(データドリフト)。新しい用語が増えたり、ドキュメントの書き方が変わったりすれば、最適な閾値も変動する可能性があります。
ログ分析による「答えられなかった質問」の追跡
システムが「関連情報なし」として回答を拒否したログ(Fallback Logs)は宝の山です。これらを分析することで、以下の2つの課題が見えてきます。
- ナレッジ不足: そもそもドキュメントに情報がない。 -> ドキュメント追加のアクションへ。
- 検索失敗: 情報はあるのに、スコアが低くて弾かれた。 -> 閾値の再調整、またはチャンク分割(Chunking)戦略の見直しへ。
ユーザーフィードバックを活用した閾値の動的調整
回答に対する「Good/Bad」ボタンのフィードバックを収集し、Bad評価が多い場合は閾値が低すぎてノイズが混じっている(ハルシネーション発生)可能性があります。逆に、ユーザーが「答えがあるはずなのに答えてくれない」と不満を持つ場合は、閾値が高すぎる可能性があります。
定期的なEmbeddingモデルの再評価
AI技術の進歩は速いです。半年前の最新Embeddingモデルが、現在では陳腐化していることもあります。年に一度程度は、新しいモデルでベンチマークを取り直し、より分離性能(正解と不正解を分ける能力)が高いモデルへの移行を検討すべきです。
まとめ
ハルシネーションのない信頼性の高いRAGシステムを構築するためには、プロンプトエンジニアリングという「芸術」だけでなく、統計に基づいた類似度フィルタリングという「科学」が必要です。
- 直感を捨てる: 閾値を感覚で決めず、実データ分布から決定する。
- 防御壁を作る: 閾値以下の情報はLLMに渡さず、潔く回答を拒否する。
- 継続的に監視する: 拒否ログとユーザー評価を元に、パラメータを微調整し続ける。
この3ステップを実践することで、RAGシステムは「嘘をつかない、信頼できるシステム」へと進化します。これは単なる技術的な修正ではなく、AIプロダクトの品質保証(QA)プロセスそのものです。
コメント