👌

Microsoft Agent Framework をローカルLLMで試してみる その11(AIContextProviderで簡易RAG)

に公開

シリーズ一覧

一覧

はじめに

Microsoft Agent FrameworkをローカルLLMで試してみる その10では、ProvideAIContextAsyncInvokingCoreAsync でコンテキストをどのように変更できるのかを確認しました。

その11では、AIContextProviderを使った最小構成のRAGを確認していきます。

今回は次の点を見ていきます。

  • 固定ドキュメント検索を RAG の検索部分としてどう切り出すか
  • SearchAiContextProvider で検索結果を AIContext にどう注入するか
  • 最新のユーザー入力を AIContextProvider に安全に渡すにはどうするか

RAGの最小構成

RAG(Retrieval-Augmented Generation)は、ユーザーの質問に関連する情報を検索し、その結果を LLM への入力に追加して回答を生成する構成です。

今回の SimpleRagSample では、ベクトル検索は使わずに検索して注入する部分だけを最小構成で確認します。

このサンプルでやっていることは次の3点です。

  • 検索対象はコード内の固定ドキュメントだけにする
  • 検索ロジックは単純なキーワード検索にする
  • 検索結果は AIContextProvider から assistant message として渡す

今回は検索精度よりも、検索結果を AIContextProvider から Agent に渡す流れを確認することを優先します。

今回確認する構成

主に見るファイルは次の4つです。

ファイル 役割
Program.cs LM Studio 接続、検索サービス作成、Agent の組み立て、実行
InMemoryKnowledgeSearchService.cs 固定ドキュメントに対する最小の全文検索
SearchAiContextProvider.cs 検索結果を AIContext に変換して注入
PendingUserInputState.cs 最新のユーザー入力をセッション状態へ一時保存

全体の流れは次のようになります。

実装

InMemoryKnowledgeSearchService

まずは検索部分です。InMemoryKnowledgeSearchService は、固定ドキュメントに対して単純なスコアリングを行います。

InMemoryKnowledgeSearchService.cs
using System.Text.RegularExpressions;

namespace SimpleRagSample;

// 固定ドキュメント群に対する最小の全文検索。RAG の入口だけ確認したいので実装は意図的に単純にしている。
public sealed class InMemoryKnowledgeSearchService
{
    private readonly IReadOnlyList<KnowledgeDocument> _documents;

    public InMemoryKnowledgeSearchService(IReadOnlyList<KnowledgeDocument> documents)
    {
        ArgumentNullException.ThrowIfNull(documents);
        _documents = documents;
    }

    public IReadOnlyList<KnowledgeSearchResult> Search(string query, int topK = 3)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(query);

        if (topK <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(topK), topK, "topK must be greater than 0.");
        }

        string[] terms = SplitTerms(query);

        // 完全一致、タイトル一致、本文中の出現回数だけで粗く順位付けする。
        return _documents
            .Select(document => new KnowledgeSearchResult(document, CalculateScore(document, query, terms)))
            .Where(result => result.Score > 0)
            .OrderByDescending(result => result.Score)
            .ThenBy(result => result.Document.Title, StringComparer.Ordinal)
            .Take(topK)
            .ToArray();
    }

    private static string[] SplitTerms(string text)
    {
        // 日本語の簡易入力を想定し、句読点や括弧でだけ分割する。
        string[] terms = Regex.Split(text, @"[\s,、。・/\\()\[\]{}]+")
            .Select(term => term.Trim())
            .Where(term => term.Length >= 2)
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .ToArray();

        return terms.Length == 0 ? [text.Trim()] : terms;
    }

    private static double CalculateScore(KnowledgeDocument document, string query, IReadOnlyList<string> terms)
    {
        string normalizedTitle = document.Title.ToLowerInvariant();
        string normalizedContent = document.Content.ToLowerInvariant();
        string normalizedQuery = query.ToLowerInvariant();
        double score = 0;

        // 質問全体がそのまま含まれる場合は、まず強めに加点する。
        if (normalizedTitle.Contains(normalizedQuery, StringComparison.Ordinal) ||
            normalizedContent.Contains(normalizedQuery, StringComparison.Ordinal))
        {
            score += 3.0d;
        }

        foreach (string term in terms)
        {
            string normalizedTerm = term.ToLowerInvariant();

            if (normalizedTitle.Contains(normalizedTerm, StringComparison.Ordinal))
            {
                score += 2.0d;
            }

            // 本文一致は出現回数ベースにして、実装を追いやすくする。
            score += CountOccurrences(normalizedContent, normalizedTerm);
        }

        return score;
    }

    private static int CountOccurrences(string text, string term)
    {
        if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(term))
        {
            return 0;
        }

        int count = 0;
        int index = 0;

        while ((index = text.IndexOf(term, index, StringComparison.Ordinal)) >= 0)
        {
            count++;
            index += term.Length;
        }

        return count;
    }
}

public sealed record KnowledgeDocument(string Id, string Title, string Content);

public sealed record KnowledgeSearchResult(KnowledgeDocument Document, double Score);

この検索サービスでやっていることはかなり単純です。

  • クエリを分割して検索語を作る
  • タイトルに含まれる語を少し強めに加点する
  • 本文中の出現回数で加点する
  • スコア上位だけを返す

ここでは検索品質を高めることよりも、検索結果を取り出して AIContextProvider に渡す経路を確認できれば十分です。
そのため、検索部分はキーワードベースの最小実装に留めています。

SearchAiContextProvider

このサンプルの中心は SearchAiContextProvider です。

SearchAiContextProvider.cs
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using System.Text;

namespace SimpleRagSample;

// 検索結果を assistant message に整形して、モデル呼び出し直前に差し込む。
public sealed class SearchAiContextProvider : AIContextProvider
{
    private readonly InMemoryKnowledgeSearchService _searchService;
    private readonly InMemoryChatHistoryProvider? _chatHistoryProvider;

    public SearchAiContextProvider(
        InMemoryKnowledgeSearchService searchService,
        InMemoryChatHistoryProvider? chatHistoryProvider = null)
    {
        _searchService = searchService ?? throw new ArgumentNullException(nameof(searchService));
        _chatHistoryProvider = chatHistoryProvider;
    }

    protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
    {
        if (context.Session is null)
        {
            return new ValueTask<AIContext>(new AIContext());
        }

        // 直前に保存した最新入力を優先し、見つからなければ履歴へフォールバックする。
        string userInput = GetLatestUserMessage(context.Session);
        if (string.IsNullOrWhiteSpace(userInput))
        {
            return new ValueTask<AIContext>(new AIContext());
        }

        IReadOnlyList<KnowledgeSearchResult> results = _searchService.Search(userInput, topK: 3);
        if (results.Count == 0)
        {
            return new ValueTask<AIContext>(new AIContext
            {
                Instructions = "検索結果が見つからない場合は、その旨を明示して一般的な説明に留めてください。"
            });
        }

        // 最小サンプルなので、検索結果はそのまま読みやすい箇条書きで埋め込む。
        StringBuilder builder = new();
        builder.AppendLine("以下はローカル検索で抽出した参考コンテキストです。質問に関連する場合は優先して参照してください。");

        foreach (KnowledgeSearchResult result in results)
        {
            builder.AppendLine($"- タイトル: {result.Document.Title}");
            builder.AppendLine($"  スコア: {result.Score:F2}");
            builder.AppendLine($"  内容: {result.Document.Content}");
        }
        Console.WriteLine( builder.ToString() );

        AIContext aiContext = new()
        {
            Instructions = "参考コンテキストに書かれている内容を優先し、根拠が足りない場合は断定しないでください。",
            Messages = [new ChatMessage(ChatRole.Assistant, builder.ToString())]
        };

        return new ValueTask<AIContext>(aiContext);
    }

    private string GetLatestUserMessage(AgentSession session)
    {
        string? pendingUserInput = PendingUserInputState.GetPendingUserInput(session);
        if (!string.IsNullOrWhiteSpace(pendingUserInput))
        {
            return pendingUserInput;
        }

        // pending input がない場面では、会話履歴の最後の user message を使う。
        if (_chatHistoryProvider is null)
        {
            return string.Empty;
        }

        ChatMessage? userMessage = _chatHistoryProvider
            .GetMessages(session)
            .LastOrDefault(message => message.Role == ChatRole.User);

        return ExtractText(userMessage);
    }

    private static string ExtractText(ChatMessage? message)
    {
        if (message is null)
        {
            return string.Empty;
        }

        return string.Concat(message.Contents.OfType<TextContent>().Select(content => content.Text)).Trim();
    }
}

処理の流れは次の3段階です。

  1. 今回のユーザー入力を取得する
  2. 検索サービスで関連ドキュメントを探す
  3. 検索結果を AIContextInstructionsMessages に詰めて返す

前回確認した通り、ProvideAIContextAsync は「追加したいコンテキスト」を返すための拡張ポイントです。

このサンプルでは、検索結果が見つかったときに次の2つを返しています。

  • Instructions
    • 「参考コンテキストを優先し、根拠が足りないときは断定しない」という補助命令
  • Messages
    • 検索結果そのものを assistant message として整形したメッセージ

単に検索するだけでなく、検索結果を優先して使うことまで AIContextProvider 側で指示している形になります。

PendingUserInputState

ProvideAIContextAsync が呼ばれる時点では、今回の入力がまだ履歴に保存されていない場合があります。
そのため、このサンプルでは RunAsync の直前に今回の入力をセッション状態へ一時保存しています。

実行時のヘルパー関数

static async Task<AgentResponse> RunWithSearchContextAsync(AIAgent agent, AgentSession session, string userInput)
{
    PendingUserInputState.SetPendingUserInput(session, userInput);

    try
    {
        return await agent.RunAsync(userInput, session);
    }
    finally
    {
        PendingUserInputState.ClearPendingUserInput(session);
    }
}

一時状態の実装

PendingUserInputState.cs
using Microsoft.Agents.AI;
using System.Text.Json.Serialization;

namespace SimpleRagSample;

// AIContextProvider から最新質問を確実に読めるよう、実行中だけセッション状態へ退避する。
public static class PendingUserInputState
{
    public const string StateKey = "SimpleRagSample.PendingUserInput";

    private static readonly ProviderSessionState<State> SessionState = new(
        _ => new State(string.Empty),
        StateKey);

    public static void SetPendingUserInput(AgentSession session, string userInput)
    {
        ArgumentNullException.ThrowIfNull(session);
        ArgumentException.ThrowIfNullOrWhiteSpace(userInput);

        SessionState.SaveState(session, new State(userInput));
    }

    public static string? GetPendingUserInput(AgentSession session)
    {
        ArgumentNullException.ThrowIfNull(session);

        // 値が空文字なら「未設定」とみなして null を返す。
        State state = SessionState.GetOrInitializeState(session);
        return string.IsNullOrWhiteSpace(state.UserInput) ? null : state.UserInput;
    }

    public static void ClearPendingUserInput(AgentSession session)
    {
        ArgumentNullException.ThrowIfNull(session);
        SessionState.SaveState(session, new State(string.Empty));
    }

    public sealed class State
    {
        [JsonConstructor]
        public State(string userInput)
        {
            UserInput = userInput;
        }

        [JsonPropertyName("userInput")]
        public string UserInput { get; }
    }
}

これにより AIContextProvider は常に「今回の質問」を基準に検索できます。
pending input が見つからない場合だけ InMemoryChatHistoryProvider の最後の user message にフォールバックするため、通常の会話継続にも対応できます。

Agent の実装

最後に、これらを組み合わせて Agent を作成します。

Program.cs
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using SimpleRagSample;
using System.ClientModel;

// AIContextProvider が確実に最新の質問を参照できるよう、実行中だけ一時状態に保持する。
static async Task<AgentResponse> RunWithSearchContextAsync(AIAgent agent, AgentSession session, string userInput)
{
    PendingUserInputState.SetPendingUserInput(session, userInput);

    try
    {
        return await agent.RunAsync(userInput, session);
    }
    finally
    {
        PendingUserInputState.ClearPendingUserInput(session);
    }
}

const string lmStudioUrl = "http://localhost:1234/v1";
const string modelName = "openai/gpt-oss-20b";
const string dummyApiKey = "sk-dummy";

// 既存サンプルと同じく LM Studio の OpenAI 互換 API を使う。
var clientOptions = new OpenAIClientOptions
{
    Endpoint = new Uri(lmStudioUrl)
};

var openAIClient = new OpenAIClient(
    new ApiKeyCredential(dummyApiKey),
    clientOptions);

IChatClient chatClient = openAIClient
    .GetChatClient(modelName)
    .AsIChatClient();

var chatHistoryProvider = new InMemoryChatHistoryProvider();

// 検索対象は固定データだけに絞り、RAG の流れそのものを追いやすくする。
var searchService = new InMemoryKnowledgeSearchService(
    [
        new KnowledgeDocument(
            "maf-overview",
            "Microsoft Agent Framework の基本構成",
            "Microsoft Agent Framework では AIAgent を中心に ChatHistoryProvider と AIContextProvider を組み合わせて拡張できます。"),
        new KnowledgeDocument(
            "simple-rag-flow",
            "最小 RAG の流れ",
            "最小構成の RAG ではユーザー質問を検索し、上位ドキュメントを AIContextProvider から assistant message として注入して最終回答を生成します。"),
        new KnowledgeDocument(
            "search-strategy",
            "検索サービスの考え方",
            "この確認用サンプルでは固定ドキュメントに対するキーワード検索だけを実装します。タイトル一致を少し強く評価し、複雑な前処理や加工は行いません。"),
        new KnowledgeDocument(
            "context-provider-role",
            "AIContextProvider の役割",
            "AIContextProvider はモデル呼び出し直前に補助文脈を差し込む拡張ポイントです。検索結果をここで渡すと、モデルは検索内容を根拠として回答できます。"),
        new KnowledgeDocument(
            "pending-input",
            "最新質問の受け渡し",
            "このサンプルでは PendingUserInputState を使って最新の質問を一時保存し、AIContextProvider 側から確実に取得できるようにしています。")
    ]);

// 検索結果を AIContext に詰める責務は AIContextProvider 側に寄せる。
AIContextProvider contextProvider = new SearchAiContextProvider(searchService, chatHistoryProvider);

AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
    Name = "SimpleRagAgent",
    Description = "検索と AIContextProvider ベースの最小 RAG を確認するサンプルです。",
    ChatOptions = new ChatOptions
    {
        Instructions = "提供された参考コンテキストが質問に関連する場合はそれを優先して、日本語で簡潔に回答してください。根拠が不足する場合は不足していると明示してください。",
        MaxOutputTokens = 1200
    },
    ChatHistoryProvider = chatHistoryProvider,
    AIContextProviders = [contextProvider]
});

// サンプル実行は 1 問だけにし、最小の検索 + 文脈注入ループを確認しやすくする。
AgentSession session = await agent.CreateSessionAsync();

string userInput = "Microsoft Agent Framework で AIContextProvider を使った最小 RAG はどう構成しますか?";
AgentResponse response = await RunWithSearchContextAsync(agent, session, userInput);

Console.WriteLine("[UserInput]");
Console.WriteLine(userInput);
Console.WriteLine();
Console.WriteLine("[Response]");
Console.WriteLine(response);

この Program.cs で設定している点は次の4つです。

  • 検索サービスを Agent の外で組み立てている
  • SearchAiContextProvider に検索サービスを渡している
  • ChatClientAgentOptionsAIContextProviders に登録している
  • 実行直前に PendingUserInputState へ今回の質問を保存している

Agent 本体は検索ロジックをほとんど持たず、検索の責務は InMemoryKnowledgeSearchServiceSearchAiContextProvider に分離されています。

実行時の流れ

このサンプルでは、次の順序で処理が進みます。

追加されるコンテキスト

このサンプルでは、元の入力に対して次の情報が追加されます。

項目 内容
元の Instructions 提供された参考コンテキストが質問に関連する場合はそれを優先して、日本語で簡潔に回答してください。根拠が不足する場合は不足していると明示してください。
ユーザー入力 Microsoft Agent Framework で AIContextProvider を使った最小 RAG はどう構成しますか?
検索対象 Microsoft Agent Framework、最小 RAG、AIContextProvider、最新質問の受け渡しに関する固定ドキュメント5件
AIContextProvider が追加するもの 補助 Instructions と、検索結果を整形した assistant message

検索結果が見つかった場合、最終的なリクエストには元のシステムプロンプトに加えて、参考コンテキストと補助命令が追加されます。
逆に検索結果が 0 件だった場合は、参考コンテキストは付かず、検索結果が見つからない場合は、その旨を明示して一般的な説明に留めてください。 という補助命令だけが追加されます。

解説

SimpleRagSample では、RAG の構成要素を小さく分けて確認できます。

  • 検索ロジックは InMemoryKnowledgeSearchService に閉じている
  • コンテキスト注入は SearchAiContextProvider に閉じている
  • 最新入力の受け渡しだけを PendingUserInputState が担当している
  • Agent の組み立ては Program.cs に残している

この分け方にしておくと、あとで検索部分だけを差し替えやすくなります。

  • 固定ドキュメント検索をベクトル検索に差し替える
  • ドキュメントの保存先をファイルやデータベースに変える
  • 検索結果の整形方法を変える
  • AIContextProvider を複数登録して役割を分離する

今回は最小構成なので、どこで検索・注入・入力を保持しているかが追いやすくなっています。

まとめ

  • AIContextProviderの仕組みで事前検索を行ってコンテキストを挿入することができました。
  • 検索方法は必要に応じてベクトル検索などに差し替えも可能です。

Discussion