供養ログ

RAGとAIエージェント開発に入門してみた

普段からClaude CodeやCodexのようなコーディングエージェントを使い倒していて、コンテキストエンジニアリングやハーネスエンジニアリングについても情報を追い、それなりに実践してきたつもりでした。ただよく考えると、私は常に「利用する側」にいただけで、RAGやAIエージェントを「実装する側」を触ったことが一度もありませんでした。

触ってみると意外にも簡単にHello Worldできることがわかったので、その記録を残しておきます。

対象読者

AIエージェントを普段から使っていて、コンテキストやプロンプトまわりの仕組みはなんとなく分かっているけれど、RAGやエージェント開発の実装側には触れたことがない、という方を想定しています。

作ったもの

手元のMarkdownやテキストファイルをインデックス化して、ターミナルから自然言語で質問できる小さなCLIツールです。documents/ ディレクトリに適当なファイルを放り込んで ingest すると、ChromaDBにベクトル化して保存され、あとは > プロンプトから質問するとLLMが関連チャンクを検索して答えてくれます。

ベクトル化とは

テキストを「意味を表す数値の配列(ベクトル)」に変換する処理のことです。埋め込み(Embedding)モデルと呼ばれるモデルに文章を通すと、数百〜数千次元の数値ベクトルが返ってきて、意味が近い文章同士はベクトル空間上でも近い位置に配置されます。

これにより「キーワードが完全一致しなくても、意味的に似たチャンクを引っ張ってくる」ことができるようになります。例えば「おぢさんの趣味は?」という質問ベクトルに対して、「おぢさんの趣味は将棋とサウナ巡りです」という文のベクトルが一番近くなる、といった具合です。ChromaDBのようなベクトルDBは、このベクトル同士の近さ(類似度)で検索できる仕組みです。

最初は「LangChainを使ってやるか」と身構えていたのですが、Agent型のRAGを組むくらいならLangChainなしでも十分に成立しました。むしろ依存が少ない方が挙動を把握しやすくて、学習用としては良い選択だったと思います。

動作確認用に、架空の「おぢさん」のプロフィールをまとめた ojisan.md を用意してインデックスに入れました。内容はこんな感じで、20行ほどの事実が並ぶだけのシンプルなものです。

おぢさんの誕生日は1991年3月4日です。
おぢさんの本名は山田太郎です。
おぢさんの趣味は将棋とサウナ巡りです。
おぢさんの職業はソフトウェアエンジニアです。
おぢさんのペットは三毛猫の「みたらし」です。
(以下略)

技術スタック

依存パッケージは以下の4つだけです。

パッケージ役割
openaiOpenRouter経由でLLMを呼び出すクライアント
chromadbローカル永続化できるベクトルDB(今回はデフォルトのEmbeddingFunctionを利用)
python-dotenv.envから環境変数を読む
richターミナル出力を綺麗にするやつ

LLMの呼び出しにはOpenRouterを使いました。やっすいモデルから無料枠のあるモデルも選べます。

埋め込み(Embedding)の処理は、ChromaDBが提供するデフォルトのEmbeddingFunctionに任せきりです。ベクトルDBとEmbeddingモデルは本来別概念で、用途に応じて選ぶべきものですが、Hello World段階では気にしないことにしました。

Hello World までの流れ

実際の手順はほぼ3ステップでした。

# 1. 依存をインストール
uv sync

# 2. ドキュメントを入れてインデックス化
cp ~/memo/*.md documents/
uv run rag
> ingest

# 3. 質問する
> このプロジェクトのアーキテクチャを教えて

ingest 時には、ファイルを読み込んでチャンクに分割し、ChromaDBに突っ込むだけです。

そもそもなぜチャンクに分割するのかというと、LLMに渡せるコンテキストには上限があり、長い文書を毎回そのまま渡すのはコストや精度の面で非効率になりやすいからです。あらかじめ適切なサイズに切り分けておいて、質問が来たときに「関連しそうなチャンクだけ」を引っ張ってきてLLMに渡す、という仕組みで大きなデータも現実的に扱えるようになります。

今回はデフォルトで chunk_size=1000 文字にしていて、ファイルがこのサイズを超えた場合だけ分割処理が走ります。分割ルールは段落境界(\n\n)を優先し、なければ改行、それもなければスペースで切る、というシンプルなものです。

split_pos = text.rfind("\n\n", start + chunk_size // 2, end)
if split_pos == -1:
    split_pos = text.rfind("\n", start + chunk_size // 2, end)
if split_pos == -1:
    split_pos = text.rfind(" ", start + chunk_size // 2, end)

rfind の検索範囲を「チャンクの後半(chunk_size // 2 から end まで)」に絞っているのがポイントで、こうしておかないと先頭近くの段落境界で切ってしまって極端に短いチャンクができてしまいます。「最低でも半分のサイズは確保したうえで、できるだけ末尾に近い意味の切れ目で切る」というイメージです。

ちなみに先ほどの ojisan.md は全体で500文字程度しかないので、分割されずに1チャンクとしてそのままインデックスされます。分割処理が効いてくるのは、ブログ記事や技術ドキュメントのように長めのテキストを入れたときです。

意味的なまとまりをなるべく壊さないための工夫ですが、今回はHello Worldなので単純化していて、日本語向けのより良い分割(句点単位での切り分けなど)やチャンク間のオーバーラップは未対応です。日本語は単語がスペースで区切られないので、「スペースで切る」ルールは基本的に効きません。このあたりも凝ろうと思えばいくらでも凝れる部分だと思います。

Agent型にしてみた

最初は「質問を受けたら毎回ベクトル検索して、その結果をプロンプトに埋め込んでLLMに渡す」という素朴なRAGを書いていたのですが、せっかくなのでAgent型に書き直しました。検索を常に固定実行する代わりに、LLMにツールを渡して、検索が必要かどうかをLLM自身に判断させる方式です。なお、この判断がどれだけ安定するかはモデルのツール利用性能に依存するので、後述するようにモデル選びで苦労する部分でもあります。

ツール定義はOpenAIのFunction Callingそのままで、こんな感じです。

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "search_documents",
            "description": "ドキュメントの知識ベースから、質問に関連するチャンクを意味的に検索する。",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {"type": "string"},
                },
                "required": ["query"],
            },
        },
    },
    # list_documents, get_current_datetime なども同様に定義
]

あとは「LLMを呼ぶ → ツール呼び出しがあれば実行して結果を戻す → 再度LLMを呼ぶ」を最大5回まで回すループを書くだけです。

for _ in range(max_iterations):
    response = client.chat.completions.create(
        model=config.model, messages=messages, tools=TOOLS,
    )
    assistant_msg = response.choices[0].message
    if not assistant_msg.tool_calls:
        yield assistant_msg.content or ""
        return
    messages.append(assistant_msg.model_dump(exclude_none=True))
    for tool_call in assistant_msg.tool_calls:
        result = execute_tool(tool_call.function.name, ...)
        messages.append({"role": "tool", "tool_call_id": tool_call.id, "content": result})

「これだけでエージェントって呼んでいいのか?」と思うくらい素朴ですが、実際にこれでLLMは「この質問は検索が必要」「これは雑談だから検索しない」「一度検索したけど情報が足りないからクエリを変えてもう一度検索する」といった判断を自分でやってくれます。

> おぢさんってどんなひと?

🔧 search_documents({"query":"おぢさん"})
おぢさんは、1991年3月4日生まれの山田太郎さんです。ソフトウェアエンジニアで、株式会社オヂサンテックのシニアエンジニアリングマネージャーを務めています。趣味は将棋とサウナ巡りです。

*   **職業**: ソフトウェアエンジニア
*   **得意なプログラミング言語**: Go、Python
*   **ペット**: 三毛猫の「みたらし」
*   (以下略)

参照元:ojisan.md

ツール呼び出しのたびに 🔧 search_documents({"query": "..."}) とログを流すようにしておくと、エージェントの思考が透けて見えて楽しいです。

触ってみた感想

Hello Worldの範囲で言えば、RAGもAIエージェントも想像していたより近い場所にありました。要素を分解してみると、やっていることは「ベクトル検索 + ツール呼び出しループ」でしかなく、LangChainのようなフレームワークを入れなくても100〜200行程度のPythonで形になります。

意外だったのは、普段コーディングエージェントを使う側で培った勘が、実装側でもそこそこ効いたことです。システムプロンプトでツールの使い分け方針をどう書くか、検索結果の何をコンテキストに残すか、履歴をどこまで保持するかあたりの判断は、普段Claude Codeに指示を書いているときの感覚とほぼ地続きでした。利用側の経験は実装側でも完全には無駄にならないようです。

逆に困ったこともありました。使うLLMによっては、そもそも search_documents を呼びに行く判断をしてくれないケースがありました。同じシステムプロンプトと同じツール定義でも、モデルによっては「自分の知識の範囲で適当に答えてしまう」ことがあって、RAGとして機能しないまま終わることがありました。Function Callingの賢さはモデル依存で、ここはプロンプトの書き方やモデル選びで工夫する余地が大きそうです。

まだ全然分かっていないのは、埋め込みモデルの選定、チャンクサイズとオーバーラップのチューニング、評価の方法あたりです。Hello Worldは簡単でも、実運用に耐えるRAGを作るのは別の山なのだろうなと感じています。

おわりに

最近ローカルLLMが熱いっぽいので今更ながら触ってみた記録でした。(今回はOpenRouter経由だけど)

次はもう少し踏み込んで、評価やチューニング、マルチエージェント的な構成にも触れていきたいと思います。 ハマりすぎると高級グラボマシンが欲しくてたまらなくなりそうなのでほどほどに。。。

参考