powered by TechFeed
表示モード
ハウツー

並列検出器 → 最後に1回のLLM裁定:RAGのアンカー検出でコストとレイテンシを下げる設計

6月24日、Towards Data Scienceが「Anchor Detection for RAG: Parallel Detectors, Then One LLM Call at the End」と題した記事を公開した。この記事では、企業向けRAGシステムにおけるアンカー検出を「並列検出器 → 集約 → 最後に1回のLLM呼び出し」という3段階パイプラインで実装する手法について詳しく紹介されている。

6月24日、Towards Data Scienceが「Anchor Detection for RAG: Parallel Detectors, Then One LLM Call at the End」と題した記事を公開した。この記事では、企業向けRAGシステムにおけるアンカー検出を「並列検出器 → 集約 → 最後に1回のLLM呼び出し」という3段階パイプラインで実装する手法について詳しく紹介されている。


RAGの検索精度を上げようとすると、つい「LLMをパイプラインの中間に挟む」設計に走りがちだ。しかしその都度APIを叩けばレイテンシもコストも跳ね上がる。この記事が提案するのは逆の発想——LLM呼び出しはパイプラインの最後に1回だけ、それまでは安価な検出器を並列で走らせるという設計だ。

アンカーとは何か

この設計を理解するには「アンカー(anchor)」の概念を押さえておく必要がある。ここでのアンカーとは「マッチがどこに着地するか」という位置情報、つまり候補ページや候補セクションの特定を指す。対して「コンテキスト(context)」は生成フェーズに渡すために展開されるテキスト範囲だ。アンカー検出はその前段——どこが候補かを絞り込む処理にあたる。

RAGそのものについては、Retrieval-Augmented Generation(RAG)の概要としてTechFeed内にも関連記事が多数ある。本稿では「検索精度をいかに上げるか」というパイプライン設計の話に絞る。

元記事ではAttention Is All You Need(Vaswani et al. 2017)を題材に使っている。全15ページ、22エントリ・3階層のTOCを持つこの論文は、RAGエンジニアには馴染み深い内容でもあり、手法の説明に集中できる素材として選ばれている。

3段階パイプラインの設計思想

パイプラインは以下の3段階で動く。

  1. Stage 1(並列検出) — キーワード検出と埋め込み類似度をline_dftoc_dfの2テーブルに対して並列実行
  2. Stage 2(集約) — ヒットをセクション・ページ・チャンク等の構造単位に集約
  3. Stage 3(LLM裁定) — 集約された候補を1回のLLM呼び出しでランキング、理由付きで出力

設計の根拠となる3つの原則が明示されている。

  • キーワードは常に走らせる — コストゼロ、決定論的、監査可能。走らせない理由がない
  • 埋め込みは並列・任意 — 語彙ミスマッチや概念的な質問に有効。事前計算済みならクエリ時コストはマイクロ秒オーダー。キーワードシグナルが十分なら省略可能
  • LLMは最後に1回 — 中間に「TOC推論」用LLMステップを挟まない。アービター(裁定者)が1回の呼び出しでTOCもヒットも候補の構造的位置も全部見る

キーワード検出だけでは拾えないケースにどう対処するか

ここが設計の核心だ。

ユーザーが「How is attention computed?」と質問したとき、attentionというキーワードは6つの候補ページにマッチする。正解ページはsoftmaxquerykeyd_kが揃い、TOCが「Scaled Dot-Product Attention」と呼ぶセクションに属している。キーワードも埋め込みも「候補集合」は絞れても、どれが正解かは単独では判断できない。

そこで最後のLLMが登場する。LLMはTOC全体・キーワードヒット・埋め込みヒット・各候補の構造的帰属を1回のプロンプトでまとめて受け取り、ランキングと理由を返す。

記事内で紹介されているreason_on_toc関数はこのロジックを単体でデバッグするための実装だ。なお、以下のコードは元記事のサンプルコードをそのまま引用している。client.responses.parseは元記事が使用するSDKのシグネチャであり、一般的なOpenAI公式SDKとは異なる場合がある点に注意されたい。

class SectionSelection(BaseModel):
    section_ids: list[str]
    reasoning: str

def reason_on_toc(question: str, toc_df: pd.DataFrame) -> SectionSelection:
    toc_text = "\n".join(
        f"[id={row.section_id}] {row.title} (level {row.level}, pp. {row.start_page}-{row.end_page})"
        for row in toc_df.itertuples()
    )
    prompt = (
        "Given this question and the document's table of contents, "
        "identify which sections most likely contain the answer. "
        "Consider implications and related concepts, not just keyword overlap.\n\n"
        "IMPORTANT: return the value inside the [id=...] brackets -- just the bare integer, "
        "e.g. \"9\" not \"id=9\" and not \"5.2\".\n\n"
        f"Question: {question}\n\nTable of contents:\n{toc_text}"
    )
    return client.responses.parse(
        model=model_chat,
        input=prompt,
        text_format=SectionSelection,
    ).output_parsed

Transformer論文に対して「How does the Transformer handle long-range dependencies between words?」と投げた結果、LLMはセクション['4', '11']を選択し、次のような理由を返した。

"The question about how the Transformer handles long-range dependencies between words is best addressed in sections that discuss the attention mechanism (Section 4) and the reasoning behind using self-attention (Section 11)."

キーワードマッチではこの理由は得られない。LLMが理由をインラインで書くことで、監査証跡が自動的に生成される点が設計上の利点として強調されている。

なぜTOC全体をLLMに渡せるのか

toc_dfは典型的なドキュメントで数十〜数百行程度と小さい。だからこそ全件をLLMに渡せる。これがline_df(本文の全行、1万2000行規模)と根本的に異なる点だ。本文全行をLLMに「関連する行を選べ」と投げるのは、コスト・レイテンシ・信頼性のどれをとっても非現実的だと記事は指摘する。

TOCの小ささがこのアーキテクチャを成立させている、という観察は地味だが重要だ。

埋め込みによるタイトルマッチ(オプション)

語彙ミスマッチの具体例として「exit early」という質問語と「Termination」というセクションタイトルが挙げられている。キーワードはマッチしないが、コサイン類似度はこの意味的近接を捉えられる。タイトルの埋め込みはインジェスト時に1回計算しておけば、クエリ時の追加コストはマイクロ秒オーダーだ。

def embed_match_titles(query: str, toc_df_with_embeddings: pd.DataFrame, top_k: int = 3):
    query_vec = get_embedding(query, client=client)
    scored = []
    for row in toc_df_with_embeddings.itertuples():
        title_vec = np.array(row.embedding)
        sim = float(np.dot(query_vec, title_vec) /
                    (np.linalg.norm(query_vec) * np.linalg.norm(title_vec)))
        scored.append((row.section_id, row.title, sim))
    return sorted(scored, key=lambda x: -x[2])[:top_k]

記事はさらに「埋め込みが類似度は捉えられても推論は捉えられない」点を明確に区別している。「exit early ≈ termination」は類似度で捉えられるが、「exit early implies penalties」は推論であり、それはLLMの仕事だという整理だ。

line_dfに対する検出と組み合わせ

本文の詳細(line_dfに対するキーワード検出・埋め込み検出の実装、およびtoc_dfとの組み合わせ方)は元記事のSection 3・4で扱われているが、最終的なLLMアービターの実装と出力JSONはArticle 7C(次稿)に委ねられており、本稿の紹介範囲はTOCレベルのアンカー検出までとなる。line_dfレベルの実装に興味がある読者は次稿の公開を待つか、元記事の続編を直接参照されたい。


詳細はAnchor Detection for RAG: Parallel Detectors, Then One LLM Call at the Endを参照していただきたい。