8月31日、Angela ShiおよびKezhan Shiが「FAQ as RAG: When You Get to Design the Corpus」と題した記事を公開した。この記事では、FAQをRAGのコーパスとして設計する際に、標準的なRAGパイプラインの各工程がどのように変化するかを詳しく解説している。
FAQはRAGにとって「逆さまの問題」である
RAG(Retrieval-Augmented Generation)の一般的なチュートリアルが想定するのは、PDFや契約書、スキャン文書といった「カオスなコーパスを受け継ぐ」ケースだ。パースが戦いの半分を占め、失われた構造を回復することにエンジニアリングの大半が費やされる。
FAQはその逆だ。コーパスを自分たちで書く。構造は自分たちが決める。
近年、カスタマーサポートへのRAG適用は急速に広がっており、チャットボットやFAQボットは多くの企業が最初に取り組むLLMユースケースとなっている。そうした現場でよく見られる課題がある。運用チームがボットのログを数週間後に分析すると、あるパターンが浮かび上がる——ユーザーの問い合わせの大半は同じ15問の言い換えに過ぎないというものだ。「解約するには?」「早期に契約を終了できますか?」「補償を停止したい」——これらは1つの質問の3通りの表現であり、回答はサポートチームが2年前に書いたものがすでに存在している。それでもシステムはクエリのたびに生成コストを払い続けている。
この記事はその非効率を「FAQパイプラインにおける4つのブロック(パース・質問解析・検索・生成)それぞれがどう変わるか」という軸で解説している。架空の住宅保険商品に関する15エントリの合成FAQを題材に使っている。
ブロック1: パースはほぼ自明
一般的なRAGではPDFのレイアウト復元やOCRが必要になるが、FAQではスキーマ設計済みの構造化ファイルをロードするだけだ。
class FAQEntry(BaseModel):
qid: str # 安定した識別子
tag: str # トピックバケット(coverage, claim, exclusions...)
question: str # 質問の正規表現
answer: str # ユーザーに返す回答
class FAQCorpus(BaseModel):
entries: list[FAQEntry]
last_updated: date
owner: str
パースに時間をかける必要はないが、代わりに重要になるのがコーパスのバージョン管理だ。商品が変わればFAQエントリも変わる。どの日付にどのバージョンの回答をユーザーに返したか追跡できなければならない。これは監査や品質管理の文脈でも重要な要件になる。
ブロック2: 検索はキャッシュ的なルックアップになる
FAQにおける検索の仕事は、ユーザークエリが既存の正規質問に対応するかを判定することに変わる。結果は3種類に分類される。
| 結果 | 条件 | 処理 |
|---|---|---|
| Direct match | 類似度 ≥ 0.92 | 正規回答をそのまま返す。LLM呼び出しなし |
| Adjacent match | 0.78 ≤ 類似度 < 0.92 | 正規回答を起点に、LLMが書き換え |
| Miss | 類似度 < 0.78 | フォールバック+ログ記録 |
この分類を担うのが以下のclassify_query関数だ。コサイン類似度(ベクトル間の角度を使って意味的な近さを0〜1で測る指標)を用いてクエリと正規質問の距離を測り、どの経路に振り分けるかを決める。
def classify_query(user_query, faq_corpus, *, direct_threshold=0.92, adjacent_threshold=0.78):
q_vec = embed(user_query)
sims = cosine_against(q_vec, faq_corpus.canonical_vecs)
top_idx = int(np.argmax(sims))
top_sim = float(sims[top_idx])
if top_sim >= direct_threshold:
outcome = "direct"
elif top_sim >= adjacent_threshold:
outcome = "adjacent"
else:
outcome = "miss"
return faq_corpus.entries[top_idx].qid, top_sim, outcome
コストの非対称性が重要だ。Direct matchはミリ秒以下、LLMトークン消費ゼロ。Adjacent matchはembedding1回+LLM補完1回(プロンプトは通常1000トークン以下)。Missは実行時コストが最小だが、ライフタイムで見ると最もコストが高い——FAQチームが追加作業をすべきシグナルだからだ。
なお「LLM呼び出しなしで回答できる」のはDirect matchに限定される点に注意が必要だ。類似度が閾値を下回るケースではLLM呼び出しが発生する。
ブロック3: 質問解析にはハイブリッドスコアリングが有効
コサイン類似度だけでは小規模なコーパス(15エントリ程度)では曖昧になりやすい。記事ではBM25(単語の出現頻度と文書頻度をもとにしたキーワード検索の古典的アルゴリズム)スコアと組み合わせるハイブリッドスコアリングを推奨している。
def hybrid_score(user_query, faq_corpus, *, alpha=0.6):
cos_scores = cosine_against(embed(user_query), faq_corpus.canonical_vecs)
bm25_scores = faq_corpus.bm25.get_scores(tokenize(user_query))
cos_norm = (cos_scores - cos_scores.min()) / (cos_scores.ptp() + 1e-9)
bm25_norm = (bm25_scores - bm25_scores.min()) / (bm25_scores.ptp() + 1e-9)
return alpha * cos_norm + (1.0 - alpha) * bm25_norm
alpha=0.6でコサイン寄り、0.0で純粋なBM25となる。「premium」「pay」「deductible」のような語彙的に明確なトークンはBM25が拾いやすく、埋め込みだけでは0.02以内の差になってしまうケースの決め手になる。意味的な近さと語彙的な一致の両面をカバーすることで、小規模コーパスでの誤分類リスクを減らせるというのが記事の主張だ。
ブロック4: Few-shotプロンプトが「静的アーティファクト」でなくなる
通常のFew-shotプロンプティング(LLMに少数の例を与えて回答スタイルや形式を誘導する手法)は静的だ。シニアエンジニアがシステムプロンプトにQ&Aの例を3つ書いてビルドに組み込む。これはFAQが更新されるたびに陳腐化する。
FAQ-as-RAGでは別のアプローチが自然に成立する。検索ステップがすでにユーザークエリに近いQ&Aペアを取得しているので、それをそのままin-context(モデルへの入力として渡す文脈情報)の例として使う。静的なシステムプロンプトに書かれた例ではなく、クエリごとに動的に取得された最新FAQエントリがFew-shotになる。FAQが更新されれば、例も自動的に更新される。コーパスを自分たちで管理できる状況だからこそ成立するアプローチだ。
まとめ:何が「逆転」するのか
| 工程 | 標準RAG | FAQ-as-RAG |
|---|---|---|
| パース | PDF/OCR/レイアウト復元 | スキーマ定義済みファイルのロード |
| 質問解析 | 文書語彙へのマッピング | 正規質問への分類(direct/adjacent/miss) |
| 検索 | パッセージ取得 | キャッシュルックアップ+Q&Aペア取得 |
| 生成 | 毎回LLM呼び出し | Direct matchはLLM呼び出しなし |
| Few-shot | 静的(手書き) | 動的(検索結果から自動構成) |
「コーパスを自分で設計できる」という条件が揃ったとき、RAGパイプラインの各工程はここまでシンプルになる。カスタマーサポートやヘルプセンターのFAQボット構築を検討しているチームにとって、標準的なRAGの設計をそのまま適用する前に一読する価値がある内容だ。サンプルコードと実行可能なノートブックはdoc-intel/notebooks-vol1で公開されている。
詳細はFAQ as RAG: When You Get to Design the Corpusを参照していただきたい。