😽

Mastra で作る AIエージェント(4) RAGで社内情報を活用~エンベッディング編

に公開

Mastra で作るAI エージェント というシリーズの第4回です。


前回までは、AIエージェントの構成を「三国志」になぞらえて以下のように把握しました。

  • フロントに立つリーダーの劉備=エージェント:何をやるにしても軍師に相談
  • 天才軍師・諸葛孔明=LLM:劉備に何かと助言するが、決して自分が直接前面に出ない
  • 将軍たち=ツール:劉備に呼ばれて定型作業を遂行

そして前回は最後の将軍=ツールとして「RAG」に挑戦しはじめました。ローカルに置いてある大量のMarkdownファイルを、ベクトルDBに登録していく部分で、ひとつのファイルをほどよい大きさに分割するチャンクの説明が終わったところでした。

今回は、エンベッディングからです。

エンベッディングを理解する

エンベッディング(Embedding) とは、テキスト(文字列)を 数値のベクトル(配列) に変換する技術です。人間が読める形式から、機械で計算できる形式に変換するのです。

// テキスト(人間が読める形式)
const text = "むかしむかし、あるところに、お爺さんとお婆さんが、ありました。";

// ↓ エンベッディング変換 ↓

// 数値ベクトル(機械が計算できる形式)
const embedding = [0.234, -0.567, 0.891, ..., 0.123]; // 1536個の数値

例えば前回も例に出した文章を例に、エンベッディングするイメージはこんな感じです。

テキスト                    → エンベッディング
─────────────────────────────────────────────────
"犬は可愛い"                → [0.8, 0.6, -0.2, ...]
"猫は可愛い"                → [0.7, 0.5, -0.1, ...]  ← 犬と近い位置
"車は速い"                  → [-0.3, 0.1, 0.9, ...]  ← 犬・猫から遠い位置
"スポーツカーは速い"         → [-0.2, 0.0, 0.8, ...]  ← 車と近い位置

この際、「意味が似ているテキストは、ベクトル空間上で近い位置に配置される」というのがポイントです。2次元に落とし込むとこんなイメージです(実際は1536次元なので、もっと複雑に意味が配置されます)。

      可愛さ ↑
             |
    猫●  犬● |
             |
             |
    ─────────┼─────────→ 速さ
             |
             |    車●
             |  自動車●

なぜ数値に変換するのか

理由1: 類似度を計算できる

テキストのままでは「似ているかどうか」を数学的に計算できません。
数値ベクトルにすることで、コサイン類似度などで定量的に比較できます。

// テキストのまま
"犬""猫" はどれくらい似ている? → 計算不可能

// ベクトル化後
[0.8, 0.6] と [0.7, 0.5] のコサイン類似度 = 0.99 → 非常に似ている!
[0.8, 0.6] と [-0.3, 0.9] のコサイン類似度 = 0.12 → 似ていない

理由2: 高速検索が可能

ベクトルDBは、数値ベクトルに対して最適化されたインデックスを構築し、
何百万件のドキュメントからでも瞬時に「最も似ているもの」を見つけられます。

理由3: 意味を捉えられる

従来のキーワード検索と違い、エンベッディングは意味的な類似性を捉えます。

クエリ: "ペットの世話"
→ キーワード検索: "ペット" "世話" という単語を含む文章を探す
→ エンベッディング検索: "犬の飼い方" "猫のお手入れ" など意味が近い文章も見つかる

1536次元のベクトル空間

冒頭に示した取り込みスクリプトでは、Markdownをチャンク化したものを1536次元のベクトル空間にエンベッディングしています。では、そもそも「次元」とは何でしょう。

  • 1次元: 数直線(1つの数値)

    "暑い" → 30.5
    "寒い" → -5.2
    
  • 2次元: 平面(2つの数値)

    "犬" → [動物度: 0.9, 可愛さ: 0.8]
    "猫" → [動物度: 0.8, 可愛さ: 0.9]
    
  • 3次元: 空間(3つの数値)

    "犬" → [動物度: 0.9, 可愛さ: 0.8, サイズ: 0.6]
    
  • 1536次元: 超高次元空間(1536個の数値)

    "犬" → [0.234, -0.567, 0.891, ..., 0.123]  // 1536個
    

このように、多くの次元で表現することでより多くの特徴を表現できます:

  • 感情(ポジティブ/ネガティブ)
  • トピック(動物/乗り物/食べ物/...)
  • 抽象度(具体的/抽象的)
  • 文体(フォーマル/カジュアル)
  • その他1500以上の微妙な意味の違い

具体的にエンベッディングする方法

エンベッディングの理屈は分かった。しかし、いったいどうやって文字列を1536次元の数値に変換すればいいのか? 自分は "犬" → [0.234, -0.567, 0.891, ..., 0.123] // 1536個 こんな変換をするアルゴリズムが思い浮かばないよ! という方もいらっしゃると思いますが、大丈夫です。これをAIのモデルがやってくれます。

例えばOpenAIは複数のエンベッディングモデルを提供しています:

モデル 次元数 性能 価格 (/1M tokens) 用途
text-embedding-3-small 1536 良好 $0.02 一般的なRAG、コスト重視
text-embedding-3-large 3072 最高 $0.13 高精度が必要な場合
text-embedding-ada-002 1536 良好 $0.10 旧世代(非推奨)

まずまず一般的には、コスパが良くて性能も十分な text-embedding-3-small を利用すれば大丈夫でしょう。「医療・法律など高精度が必須の分野」とか「非常に微妙な意味の違いを区別する必要がある場合」などに large を選ぶことを推奨されています。

ソースコード中でエンベッディングに直接関係するところを抽出します。

//(前略)

/** 埋め込みモデル */
const embeddingModel = openai.embedding('text-embedding-3-small');

//(中略)

/**
 * rag-input配下のMarkdownを読み込み、チャンク化・埋め込み・保存を行う
 */
async function ingestMarkdown() {
  //(中略)
  /** 各Markdownファイルを処理 */
  for (const [index, filePath] of markdownFiles.entries()) {
    //(中略)
    try {
      //(中略)
      /** 埋め込みを生成(チャンクごとに個別に処理) */
      const embeddings = [];
      for (const chunk of chunks) {
        const result = await embeddingModel.doEmbed({ values: [chunk.text] });
        embeddings.push(result.embeddings[0]);
      }
      //(中略)  
      /** ベクトルDBに保存(メタデータ付き) */
      const upsertResult = await ragVector.upsert({
        indexName: RAG_INDEX_NAME,
        vectors: embeddings,
        metadata: chunks.map((chunk, i) => ({
          text: chunk.text, // 元のテキスト
          source: relativePath, // ファイルパス
          chunkIndex: i, // チャンク番号
          totalChunks: chunks.length, // 総チャンク数
        })),
      });    
      //(中略)  
    }
  }
}

このように、大量のMarkdownをチャンク分割して、チャンクごとにエンベッディングして、ベクトルDBに格納することでRAGが完成するわけです。

RAGを検索するツールとエージェント

では、いよいよRAGを検索するツールと、それを活用するエージェントです。

説明の簡単化のために会話履歴のMemoryは除去したコードにしています。

import { openai } from '@ai-sdk/openai';
import { Agent } from '@mastra/core/agent';
import { createVectorQueryTool } from '@mastra/rag';
import { LIBSQL_PROMPT } from '@mastra/libsql';
import { RAG_INDEX_NAME, getRagVector } from '../vectors/rag-vector';
import { relatedDocsTool } from '../tools/related-docs-tool';

// Vector Query Tool の作成(RAG検索用)
const vectorQueryTool = createVectorQueryTool({
  vectorStoreName: 'ragVector', // Mastraに登録する名前と一致させる
  indexName: RAG_INDEX_NAME,
  model: openai.embedding('text-embedding-3-small'),
  description: '社内情報のMarkdownドキュメントを検索するツール',
});

export const ragAgent = new Agent({
  id: 'rag-agent',
  name: 'RAG Agent',
  instructions: `
あなたは社内情報に関する専門的なアシスタントです。

社内情報ドキュメント(約10,000個のMarkdownファイル)から情報を検索し、正確な回答を提供してください。

## 回答時のルール
- 必ずvectorQueryToolを使用してドキュメントを検索してください
- 取得したドキュメントIDに関連情報が必要な場合は get-related-docs ツールで関連Markdownを取得してください
- 検索結果に基づいて回答し、情報源(ファイル名)を明記してください
- 検索結果に関連情報がない場合は、「該当する情報が見つかりませんでした」と伝えてください
- 推測や一般論ではなく、ドキュメントに記載された内容のみを回答してください

${LIBSQL_PROMPT}
  `,
  model: openai('gpt-5.1'),
  tools: { vectorQueryTool, relatedDocsTool },  // ← RAG検索 + 関連ドキュメント取得
});

いかがでしょうか。このようにして社内のデータをAIエージェントとして活用できるようになりました。私が携わっているシステム開発プロジェクトでも、要件定義書や設計書、そしてソースコードを全部RAGに突っ込んで、「案件についてなんでも答えてくれるエージェント」を稼働させていますよ!

RAGの世界は奥深い

非常に駆け足でしたが、2回に分けて「ベクトルDBを使ったRAGを活用するAIエージェント」について、特にその考え方を見てきました。かなり教科書的な内容になっていると思います。間違ってはいないけど、実際の現場はもっと泥臭いというか、「教科書通りにやったけど期待通りの精度が出ない」ということはよくあって、そこからのチューニングこそが本番とも言えます。

それでも理屈をある程度理解することで、チューニングの方向性が見えてくると思います。チャンクのサイズを変えたり、エンベッディングのモデルを変えたり、あるいはベクトル検索ではなくテキスト検索に切り替えたり。あなたのRAGに突っ込んだデータの種類・内容・特性によって、そのチューニングの方向性が変わってきます。このあたり、またいつか詳しく掘り下げたいと思います。

>> 次回 : (5) 軍師を気軽に交換

Discussion