RAGパイプラインでは、完全停止より「成功したように見える劣化」の方が厄介です。
HTTP 200が返り、処理件数も増え、回答生成まで完了します。しかし、保存先は空のままかもしれません。入力欠落やリランカー停止も見逃しやすくなります。
本稿では、Qdrant、Ollama、リランカーを対象にします。実運用で遭遇した3種類の失敗と対策を紹介します。
1. Qdrantへ送った件数を「保存できた件数」にしていた
Qdrantクライアントが、サーバーより2つ以上新しいマイナーへ更新されていました。Qdrantが保証する範囲は、1マイナー以内です。
この構成でも、upsertは成功を返しました。ところが、後から対象ポイントを取得すると、結果は空でした。
さらに、アプリは送信配列の長さを成功件数として加算していました。
await client.upsert(collectionName, {
wait: true,
points,
});
upserted += points.length;
この実装で分かる値は送信件数だけです。保存結果は対象IDから確認します。
const pointIds = [...new Set(points.map(({ id }) => id))];
const storedPoints = await client.retrieve(collectionName, {
ids: pointIds,
with_payload: false,
with_vector: false,
});
if (storedPoints.length !== pointIds.length) {
throw new Error(`Qdrant verification failed: ${storedPoints.length}/${pointIds.length}`);
}
この事象をQdrant一般の不具合とは断定しません。ここで重要なのは、送信件数と保存件数を分ける設計です。
依存関係は同じマイナーへ固定します。Qdrantは隣接マイナー間の互換性を保証します。今回は運用ルールをさらに厳しくしました。
@qdrant/js-client-rest = 1.<same-minor>.<patch>
qdrant/qdrant = v1.<same-minor>.<patch>
Dockerイメージ側も環境変数で固定できます。
services:
qdrant:
image: qdrant/qdrant:${QDRANT_VERSION}
互換性警告はCIで失敗として扱います。
読み戻しは追加通信を発生させます。全バッチへの常時適用は避けました。CI、移行直後、バージョンドリフト検出時などに限定しています。
2. モデル最大長とOllama実コンテキスト長のずれ
BGE-M3の仕様上限は8192トークンです。検証時のOllamaは4kコンテキストでモデルをロードしていました。
根本対策はランタイム側の上限を8192へ引き上げることです。十分なVRAMも必要になります。
OLLAMA_CONTEXT_LENGTH=8192 ollama serve
設定後はollama psを実行します。CONTEXT列から割り当て値を確認できます。
NAME ID SIZE PROCESSOR CONTEXT UNTIL
bge-m3:latest example-id example 100% GPU 8192 ...
ただし、上限を広げても入力超過は起こり得ます。アプリ側には切り詰め処理も残します。役割は最後の防波堤です。
境界テストでは、英語とコード断片から作った合成入力を使いました。文字数だけでは判断しません。BGE-M3 tokenizerでトークン数も確認しました。
import sys
from transformers import AutoTokenizer
text = sys.stdin.read()
tokenizer = AutoTokenizer.from_pretrained('BAAI/bge-m3')
print(len(tokenizer.encode(text, add_special_tokens=True)))
この値は目安です。最終判断には実際の埋め込み処理を使います。
文字数による上限は、tokenizerを組み込めない箇所向けの保険です。
const MAX_EMBED_CHARS = 8_000;
let truncateCount = 0;
const truncateForEmbedding = (text: string): string => {
if (text.length <= MAX_EMBED_CHARS) {
return text;
}
truncateCount += 1;
if (truncateCount <= 5 || truncateCount % 100 === 0) {
log.warn('embedder: oversized input was truncated', {
originalLength: text.length,
truncatedTo: MAX_EMBED_CHARS,
truncateCount,
});
}
return text.slice(0, MAX_EMBED_CHARS);
};
8,000文字は英語とコード中心の合成入力から得た暫定値です。日本語中心の文章には流用できません。
文字数とトークン数は一致しません。利用中のモデルとランタイムで境界を測ってください。
重要なのは、切り詰め自体より観測可能性です。無記録で削ると、検索精度の低下しか見えません。
3. リランカー障害時に「似た処理」で埋めなかった
リランカーのモデルロードが失敗しても、生成モデルを代役にすれば処理は継続できます。ただし、スコアの意味と再現性は別物です。
利用不能時はベクトル検索の順序へ戻します。フォールバック発生も記録します。
if (!model) {
log.error('reranker unavailable; using vector ranking');
metrics.rerankerFallback.inc();
return hits.slice(0, topK);
}
精度改善用コンポーネントの停止後に別方式を挟むと、原因まで隠れます。
品質を偽装しません。劣化モードは明示します。こちらの方が調査しやすくなります。
共通して効いた3つのルール
| 静かな失敗 | 防止策 |
|---|---|
| 送信件数だけで成功判定 | 保存先から対象IDを読み戻す |
| モデルカードだけで上限決定 | ランタイム設定を確認後、境界は実測 |
| 障害時に代替処理で補完 | 劣化先を固定し、ログへ記録 |
RAGの品質問題には、例外を伴わないケースもあります。
受信側で確認する。実環境を測る。劣化を隠さない。
この3点を導入した後、「検索精度が何となく悪い」という調査不能な状態は大きく減りました。