検索結果から証拠付き回答へ:RAGパイプラインの実装

この記事には短い動画があります:YouTubeTikTok

1. 総論

Retrieval-Augmented Generation (RAG) は通常、「top-k 個の最も近い段落を検索する」というステップで止まり、そのままプロンプトに投げ込まれます。このアプローチは幻覚を生じやすいです。ベクトル検索は常に何かの最も近い結果を返すからです。たとえどの段落も実際には質問に答えていなくても。本記事では次のステップを掘り下げます:候補(candidates)を信頼できる証拠(evidence)に変換する方法を、ハイブリッド検索、リランキング、そしてデータが不足しているときに回答を拒否するメカニズムを通じて説明します。読み終わったら、Spring Boot を使ってこれらのステップ全体をシミュレートする小さなパイプラインを構築でき、動作を検証するテストも含めることができます。

2. 問題:最も近いことが正しいことを意味しない

チャットボットが従業員に対してフライト取消時の払戻方針を検索するのを支援する場合を想像してください。ベクトル検索は、質問に「近い」30個の passage を常に返しますが、コレクション内にフライト取消について言及している段落がない場合でも返します。システムがこれらトップ3の段落をそのまま LLM に渡した場合、モデルは通常のホテル予約についての段落に基づいて、もっともらしく聞こえる回答を「作る」ように試みるでしょう。

解決すべき2つの問題があります:

  1. 選別:数十の候補者の中から、実際に質問に役立つ段落を選ぶこと。
  2. 制御された拒否:どの段落も十分に良くないときに、推測する代わりに「見つかりません」と言うこと。

3. 全体的なパイプライン

質問
  → ベクトル検索(セマンティック)      ─┐
  → キーワード検索(BM25/完全一致) ├─→ マージ(ハイブリッド) → リランク → 信頼度閾値 → 証拠 → LLM

各ステップは段階的に候補の数を減らし、品質を高めていきます:数十の粗い候補から、わずかな明らかな証拠のある段落へ、引用元付きで。

4. 基本的なデータモデル

public record Passage(
        String id,
        String text,
        String source,
        double score
) {
    public Passage withScore(double newScore) {
        return new Passage(id, text, source, newScore);
    }
}

Passage はインデックスされたテキストの一部を表し、source は引用用で、score は比較・ソート用です。withScore はリランクの各ステップ後に元のオブジェクトを変更せずに新しいスコアで複製を作成するのに役立ちます。

5. ハイブリッド検索:セマンティックとキーワードの組み合わせ

Embedding はセマンティック関連性に優れています(例えば「フライト取消」↔「移動の中断」)が、ポリシーコード POL-2024-07 や固有名詞などの正確なトークンを見落としやすいです。キーワード検索(BM25)はこの弱点を補います。

public interface VectorSearchClient {
    List<Passage> search(String query, int topK);
}

public interface KeywordSearchClient {
    List<Passage> search(String query, int topK);
}

@Service
public class HybridSearchService {

    private final VectorSearchClient vectorClient;
    private final KeywordSearchClient keywordClient;

    public HybridSearchService(VectorSearchClient vectorClient,
                                KeywordSearchClient keywordClient) {
        this.vectorClient = vectorClient;
        this.keywordClient = keywordClient;
    }

    public List<Passage> search(String query) {
        List<Passage> semantic = vectorClient.search(query, 20);
        List<Passage> keyword = keywordClient.search(query, 20);

        Map<String, Passage> merged = new LinkedHashMap<>();
        for (Passage p : semantic) {
            merged.put(p.id(), p);
        }
        for (Passage p : keyword) {
            merged.merge(p.id(), p, (existing, incoming) ->
                    existing.withScore(existing.score() + incoming.score()));
        }
        return new ArrayList<>(merged.values());
    }
}

merge は passage がセマンティック検索とキーワード検索の両方に現れる場合、スコアを加算します — これは、その段落が意味的に関連性があり、かつ正確なキーワードマッチがあることを示す強い信号です。結果は既にマージされた候補のリストですが、最終的な有用性でまだソートされていません。

6. リランキング:ペア比較モデルで再フィルタリング

ベクトル検索は質問と各段落を独立して比較する(bi-encoder)ため、両者間の深い関係性を「読む」ことができません。Reranker(通常は cross-encoder)は質問と段落を同時に受け取り、より正確にスコア付けしますが遅い — したがって、既に粗くフィルタリングされた少量の候補にのみ適用します。

public interface Reranker {
    List<Passage> rerank(String query, List<Passage> candidates, int topN);
}

@Component
public class CrossEncoderReranker implements Reranker {

    private final CrossEncoderClient client; // 外部のreankモデルを呼び出す

    public CrossEncoderReranker(CrossEncoderClient client) {
        this.client = client;
    }

    @Override
    public List<Passage> rerank(String query, List<Passage> candidates, int topN) {
        return candidates.stream()
                .map(p -> p.withScore(client.relevanceScore(query, p.text())))
                .sorted(Comparator.comparingDouble(Passage::score).reversed())
                .limit(topN)
                .toList();
    }
}

client.relevanceScore は質問と各段落間の実際の関連スコア(例えば0–1)を返します。降順でソート後、最良の topN 段落のみを保持します — 通常は3から5個で、プロンプトに投入するには十分短く、コンテキストが薄まりません。

注意: 数千の passage 全体をリランクしないでください — cross-encoder はベクトル検索より数十から数百倍遅いです。常にリランク前の候補入力を制限してください(20–50個)。

7. LLM 用の証拠を引用元付きで構築

リランク後、高品質の段落が数個あります。次のステップはそれらをコンテキストとして明確にパッケージ化し、引用元付きで、LLM が推測する代わりに回答時に引用し直すようにすることです。

@Component
public class EvidenceBuilder {

    public String build(List<Passage> topPassages) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < topPassages.size(); i++) {
            Passage p = topPassages.get(i);
            sb.append("[出典 %d - %s]%n%s%n%n"
                    .formatted(i + 1, p.source(), p.text()));
        }
        return sb.toString();
    }
}

各段落には番号が付けられ、ソース名(source)が付きます。システムプロンプトに統合するときは、LLM にこれらの段落に基づいてのみ答え、対応するソース番号を引用するよう指示します — これにより、ユーザーは回答を盲目的に信じるのではなく、証拠を検証し直せます。

[出典 1 - policy-2024-07.pdf]
従業員は、天候によるフライト取消の場合、発生したホテル費用の100%を払い戻されます...

[出典 2 - policy-2024-07.pdf]
適用条件:フライトが取消される必要があり、6時間未満の delay には適用されません...

8. 回答がない場合を処理する

これが最も見落とされやすい部分です。すべてのコレクションに「最も近い隣人」が存在します — たとえどの段落も実際には質問に答えていなくても。チェックしないと、LLM は無関係なコンテキストを使って推論を行い、もっともらしく聞こえるが間違った回答を生成してしまいます。ユーザーは確かな言い回しのため、それが間違っていることに気づきにくいです。

簡単な解決方法:リランクの最高スコアに基づいて信頼度閾値を設定します。

@Service
public class AnswerService {

    private static final double CONFIDENCE_THRESHOLD = 0.55;

    private final HybridSearchService searchService;
    private final Reranker reranker;
    private final EvidenceBuilder evidenceBuilder;
    private final LlmClient llmClient;

    public AnswerService(HybridSearchService searchService,
                          Reranker reranker,
                          EvidenceBuilder evidenceBuilder,
                          LlmClient llmClient) {
        this.searchService = searchService;
        this.reranker = reranker;
        this.evidenceBuilder = evidenceBuilder;
        this.llmClient = llmClient;
    }

    public String answer(String question) {
        List<Passage> candidates = searchService.search(question);
        List<Passage> topPassages = reranker.rerank(question, candidates, 4);

        boolean hasEvidence = !topPassages.isEmpty()
                && topPassages.get(0).score() >= CONFIDENCE_THRESHOLD;

        if (!hasEvidence) {
            return "この質問に明確に答えるポリシーが見つかりません。"
                    + "人事部に連絡して確認してください。";
        }

        String context = evidenceBuilder.build(topPassages);
        return llmClient.generate(question, context);
    }
}

リランク後の最良の段落が CONFIDENCE_THRESHOLD より低いスコアを持つ場合、システムは LLM を呼び出して根拠のない回答を生成する代わりに、拒否回答を返します。具体的な閾値は実際のデータで調整されるべきです — 低すぎると幻覚を生じ、高すぎると答えられる質問まで拒否します。

9. 動作のテスト

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>
class AnswerServiceTest {

    @Test
    void shouldAbstainWhenBestScoreBelowThreshold() {
        HybridSearchService search = mock(HybridSearchService.class);
        Reranker reranker = mock(Reranker.class);
        EvidenceBuilder builder = new EvidenceBuilder();
        LlmClient llm = mock(LlmClient.class);

        Passage weakMatch = new Passage("p1", "通常のホテル予約規約",
                "hotel-faq.pdf", 0.3);

        when(search.search(anyString())).thenReturn(List.of(weakMatch));
        when(reranker.rerank(anyString(), anyList(), anyInt()))
                .thenReturn(List.of(weakMatch));

        AnswerService service = new AnswerService(search, reranker, builder, llm);
        String result = service.answer("フライト取消時の払戻はどうなりますか?");

        assertThat(result).contains("ポリシーが見つかりません");
        verifyNoInteractions(llm);
    }

    @Test
    void shouldAnswerWhenConfidenceIsHigh() {
        HybridSearchService search = mock(HybridSearchService.class);
        Reranker reranker = mock(Reranker.class);
        EvidenceBuilder builder = new EvidenceBuilder();
        LlmClient llm = mock(LlmClient.class);

        Passage strongMatch = new Passage("p2", "フライト取消の場合、100%払い戻し",
                "policy-2024-07.pdf", 0.82);

        when(search.search(anyString())).thenReturn(List.of(strongMatch));
        when(reranker.rerank(anyString(), anyList(), anyInt()))
                .thenReturn(List.of(strongMatch));
        when(llm.generate(anyString(), anyString())).thenReturn("100%払い戻しを受けられます [出典 1]。");

        AnswerService service = new AnswerService(search, reranker, builder, llm);
        String result = service.answer("フライト取消時の払戻はどうなりますか?");

        assertThat(result).contains("出典 1");
        verify(llm).generate(anyString(), anyString());
    }
}

最初のテストは、スコアが低い場合の拒否動作をチェックします — llm は呼ばれず、コストを削減し幻覚を避けます。2番目のテストは、強い証拠がある場合、システムが LLM を呼び出し、回答がソース引用を含むことを確認します。

10. 一般的なエラー

  • ハイブリッド検索をスキップし、純粋なベクトルのみを使用:コード番号、固有名詞などの正確なマッチが失われやすい — embedding が判別するのが苦手なもの。
  • フィルタリング済みのサブセットではなく、データセット全体をリランク:必要のない遅さとコストが発生。
  • 信頼度閾値を設定しない:コンテキストが無関係でも常に回答が返ります。ユーザーが確かな言い回しのため気付きにくい幻覚につながります。
  • コンテキストが長すぎる:元の30段落すべてをプロンプトに詰め込む代わりに、リランク済みの3–5段落を使うべきです。信号が薄まり、トークンコストが増加します。

注意: ドメインが安定した語彙を持ち、固有キーワードが少ない場合(例えば通常のチャットボット会話)、ハイブリッド検索は冗長かもしれません — 純粋なベクトル検索でも十分で、運用がシンプルです。

11. まとめ

RAG は取得ステップが正しいタイミングで止まるときのみ信頼できます:セマンティック検索とキーワード検索の結果を統合し、実際に有用な段落を選ぶためにリランクし、十分な証拠がないときに拒否する方法を知る必要があります。この記事のパイプライン — HybridSearchService、Reranker、EvidenceBuilder、AnswerService — は、実際のモデル(embedding、BM25 インデックス、cross-encoder)を接続できるスケルトンです。文字予測マシンというより、引用付き、より正直な回答アシスタントになります。

ragllmspring-bootsearchjava