📑

Mastra で作る AIエージェント(3) RAGで社内情報を活用~チャンク編

に公開

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


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

  • フロントに立つリーダーの劉備=エージェント:何をやるにしても軍師に相談
  • 天才軍師・諸葛孔明=LLM:劉備に何かと助言するが、決して自分が直接前面に出ない
  • 将軍たち=ツール:劉備に呼ばれて定型作業を遂行、自己紹介が大事
    • 関羽:APIを呼んで、外部の情報を持ってくる
    • 張飛:Web検索をして、外部の情報を持ってくる
    • 黄忠:MCPを使って、外部システムとやりとりする
    • 馬良:データベースに会話履歴を保存し、会話の都度取り出す

さて今回は最後の将軍=ツールとして「RAG」に挑戦します。RAGを使って社内データやプロジェクトの情報を自由自在に取り出して、それに基づいた回答をすることができるようになります。正直、前回までの使い方だったらほとんどChatGPTでできるな、と思いますが、RAGをガンガン使えるようになるとAIエージェント!という感じがぐっと強くなってきます。

広義のRAGと狭義のRAG

RAGとは「Retrieval-Augmented Generation」の略で、直訳すると「検索-拡張、生成」となり、「検索して、それを拡張・補強して、AIの生成に役立てる」というほどの意味になります。

私の見るところ、世間で「RAG」と云うとき、それは広義のRAGと狭義のRAGが入り乱れて言及されているように感じます。

  • 広義のRAG
    • とにかく外部から情報を引っ張ってきたならば、そしてそれをLLMに渡したのであれば、それはすなわちRAGである
    • API経由でも、MCP経由でも、ファイル読み込みでも、データベースアクセスでも、全部RAGである
  • 狭義のRAG
    • 外部情報の中でも、データベースアクセスに限る。
    • とりわけベクトルDBにデータを入れて、ベクトル検索をすることで情報を抽出する仕掛けをRAGと呼ぶ

みなさんも、技術ブログなんかを読むときに「RAG」と出てきたら、広義のRAGか狭義のRAGか、文脈で判断が必要ですのでご注意ください。そしていま皆さんが読んでいるこのブログ記事では、私は狭義の方で「RAG」という言葉を使っています(が、同じ私の文章の中でも、「RAG」ということばのスコープが都度、微妙に変わるかもしれません、ご容赦ください)。

ベクトル検索とは

で、さっそく狭義のRAG、すなわちベクトルDBのベクトル検索についてですが、簡単に言うと「意味を使って検索できる、というものです。どういうことか。

例えば、以下の4つの文書があったとします。

  • ファイルA「犬は可愛い」
  • ファイルB「猫はカワイイ」
  • ファイルC「車は速い」
  • ファイルD「スポーツカーは速い」

これを、普通のデータベースに入れて、「イヌはかわいい」でテキスト検索してもファイルAすらヒットしません。「犬」と「イヌ」、「可愛い」と「カワイイ」、という具合に文字列が完全一致しないからです。

これをベクトルDBに入れて「イヌはかわいい」でベクトル検索すると、ファイルAは「意味が近い」といって返ってきます。次いでファイルBも「近しい」ということでヒットするでしょう。

ソースデータと一字一句同じ文字列で検索するのはしんどい、という課題を解決してくれるデータベースなのです。

Mastraでサポートされているベクトルデータベースには以下のような種類がありますが、今回はLibSQLを使います。

用途 推奨データベース
開発・プロトタイピング LibSQL, LanceDB, Chroma
既存PostgreSQL活用 PgVector
本番環境(小〜中規模) LibSQL (Turso), PgVector, Upstash
本番環境(大規模) Pinecone, Qdrant, MongoDB Atlas
サーバーレス環境 Upstash, Cloudflare Vectorize
エッジコンピューティング LanceDB, Cloudflare Vectorize
既存NoSQL活用 MongoDB, Couchbase

まずは動くものを作る

ここで、チャンクとかエンベッディングとかについて解説してもよいのですが、まずは動かしてみましょう。

練習なので、まずはローカルに社内データとしての大量のMarkdownファイルを置き、それをスクリプトでベクトルDBに取り込みます。そのうえで、そのRAGから情報を収集するツールを作り、エージェントがそれを活用できるようにする、というわけです。

ディレクトリ構成はこちらです。

myMastra/
├─ rag-input/                  # 取り込み対象の Markdown を配置
│   └─ sub-dir/                # サブフォルダも再帰的に走査される
├─ rag-data/                   # ベクトルデータベースの保存先(自動作成)
│   └─ mastra-vectors.db       # .mastra 外に配置
├─ src/mastra/
│   ├─ agents/
│   │   └─ rag-agent.ts        # RAG エージェント定義
│   ├─ scripts/
│   │   └─ ingest-markdown.ts  # 取り込みスクリプト
│   ├─ vectors/
│   │   └─ rag-vector.ts       # LibSQLVector の初期化ロジック
│   └─ index.ts                # Mastra インスタンス登録
├─ .env                        # OPENAI_API_KEY などの環境変数
└─ package.json                # npm スクリプト定義

RAGへの取り込みスクリプトを作る

ingest-markdown.ts を動かすことで、 rag-input フォルダに格納されたMarkdownファイルの内容をベクトルDB rag-data/mastra-vectors.db に取り込むことができます。

なお、この取り込みスクリプトは、Mastraフレームワークの外です。Mastraライブラリは使っていますが、フレームワークの一部ではなく独自実装なので、Mastraサーバを起動しないで実行可能です。

// ingest-markdown.ts
/**
 * MarkdownファイルをRAG用ベクタDBへ取り込むシンプルスクリプト
 * - 取得元: プロジェクトルートの rag-input 配下
 * - 保存先: rag-data/mastra-vectors.db(固定)
 * - 埋め込み: text-embedding-3-small
 */
import 'dotenv/config';
import { openai } from '@ai-sdk/openai';
import { LibSQLVector } from '@mastra/libsql';
import { MDocument } from '@mastra/rag';
import * as fs from 'fs';
import * as path from 'path';

/** RAG用のインデックス名 */
const RAG_INDEX_NAME = 'rag_index';
/** 使用するDBファイルの絶対パス */
const dbPath = path.join(process.cwd(), 'rag-data', 'mastra-vectors.db');
fs.mkdirSync(path.dirname(dbPath), { recursive: true });

/** ベクトルストア(LibSQL) */
const ragVector = new LibSQLVector({
  id: 'ingest-rag-vector',
  url: `file:${dbPath}`,
});

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

/**
 * 指定ディレクトリ配下のMarkdownファイルを再帰的に取得する
 * @param dir 探索対象ディレクトリ
 * @returns Markdownファイルの絶対パス配列
 */
function findMarkdownFiles(dir: string): string[] {
  const files: string[] = [];
  const entries = fs.readdirSync(dir, { withFileTypes: true });

  for (const entry of entries) {
    const fullPath = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      files.push(...findMarkdownFiles(fullPath));
    } else if (entry.isFile() && entry.name.endsWith('.md')) {
      files.push(fullPath);
    }
  }
  return files;
}

/**
 * rag-input配下のMarkdownを読み込み、チャンク化・埋め込み・保存を行う
 */
async function ingestMarkdown() {
  console.log('📚 Markdown RAG取り込みを開始します...\n');
  console.log(`🗄️  データベース: ${dbPath}\n`);

  /** Markdownファイルのパス(プロジェクトルートからの相対パス) */
  const ragInputDir = path.join(process.cwd(), 'rag-input');

  /** rag-inputディレクトリが存在しない場合はエラー */
  if (!fs.existsSync(ragInputDir)) {
    console.error('❌ エラー: rag-inputディレクトリが見つかりません');
    console.log('💡 プロジェクトルートに "rag-input" ディレクトリを作成し、Markdownファイルを配置してください\n');
    process.exit(1);
  }

  const markdownFiles = findMarkdownFiles(ragInputDir);

  console.log(`✅ ${markdownFiles.length}個のMarkdownファイルが見つかりました\n`);

  /** インデックスを作成(既に存在する場合はスキップされる) */
  try {
    await ragVector.createIndex({
      indexName: RAG_INDEX_NAME,
      dimension: 1536, // text-embedding-3-smallの次元数
    });
    console.log(`✅ インデックス "${RAG_INDEX_NAME}" を作成しました\n`);
  } catch (error: any) {
    if (error.message?.includes('already exists')) {
      console.log(`ℹ️  インデックス "${RAG_INDEX_NAME}" は既に存在します\n`);
    } else {
      throw error;
    }
  }

  /** 総チャンク数 */
  let totalChunks = 0;

  /** 各Markdownファイルを処理 */
  for (const [index, filePath] of markdownFiles.entries()) {
    const relativePath = path.relative(process.cwd(), filePath);
    console.log(`[${index + 1}/${markdownFiles.length}] 処理中: ${relativePath}`);

    try {
      /** ファイルを読み込み */
      const content = fs.readFileSync(filePath, 'utf-8');

      /** MDocumentとして初期化 */
      const doc = MDocument.fromMarkdown(content);

      /** Markdownをチャンク化(見出し構造を保持) */
      const chunks = (await doc.chunk({
        strategy: 'markdown',
        maxSize: 512,
        overlap: 50,
      })) as Array<{ text: string }>;

      console.log(`  ├─ ${chunks.length}個のチャンクに分割`);

      /** 埋め込みを生成(チャンクごとに個別に処理) */
      const embeddings = [];
      for (const chunk of chunks) {
        const result = await embeddingModel.doEmbed({ values: [chunk.text] });
        embeddings.push(result.embeddings[0]);
      }

      console.log(`  ├─ 埋め込み生成完了: ${embeddings.length}個`);

      /** ベクトルストアに保存(メタデータ付き) */
      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, // 総チャンク数
        })),
      });

      console.log(`  ├─ upsert結果:`, upsertResult);

      totalChunks += chunks.length;
      console.log(`  └─ ✅ 保存完了\n`);
    } catch (error) {
      console.error(`  └─ ❌ エラー: ${error}`);
      console.error(`     スタックトレース:`, error instanceof Error ? error.stack : String(error));
      console.log('');
    }
  }

  console.log('🎉 取り込みが完了しました!');
  console.log(`📊 総ファイル数: ${markdownFiles.length}`);
  console.log(`📊 総チャンク数: ${totalChunks}`);
}

/** スクリプト実行 */
ingestMarkdown().catch(console.error);

チャンクを理解する

さあ、取り込みスクリプトのソースコードにチャンクとかエンベッディングとかが出てきました。まずチャンクから解説します。

チャンク(Chunk) とは、長い文書を 小さな塊(かたまり) に分割したものです。日本語では「断片」「塊」「ブロック」と訳されます。

例えば、以下のようなすごく長い文章があったとします。

// 1つの長い文書
const document = `
# セキュリティ要件 (2000文字)
セキュリティは重要です...
## 認証
OAuth 2.0を使用...
## 暗号化
AES-256を使用...
## ログ監視
CloudWatch Logsを使用...


これをチャンクに分割します。例えばこんな感じ。

// 複数の小さな塊
const chunks = [
  { text: "# セキュリティ要件\\nセキュリティは重要です..." },  // 512文字以内
  { text: "## 認証\\nOAuth 2.0を使用..." },                    // 512文字以内
  { text: "## 暗号化\\nAES-256を使用..." },                    // 512文字以内
  { text: "## ログ監視\\nCloudWatch Logsを使用..." },          // 512文字以内
];

なぜ、チャンク分割するのか? 逆にチャンクに分割しないと以下の困ったことが起こります。

  • 意味づけの精度が低下
    • 長い文章のままだと、"セキュリティ全般" + "認証" + "暗号化" + "ログ監視" → すべてが混ざって、どの意味が強いかぼんやりします。
  • 検索の精度が低下
    • 意味づけの精度低下は、そのまま検索の精度低下につながります。ユーザが「認証について教えて」と聞いても、「長い文書のベクトル: 認証(30%) + 暗号化(30%) + ログ(20%) + その他(20%)」という風に意味づけされてしまえば、「類似度=40%。微妙!」みたいにヒットしなくなってしまいます。
  • トークン制限
    • そもそもLLMには「トークンの制限」というものがあり、あんまり長大すぎる文章はすべて読むことはできないのです。
  • コスト増加
    • 仮にトークンの上限超えをしなかったとしても、LLMの利用料金はトークンに依存しているのであり、長大な文章を全部読むとそれだけでコストが跳ね上がります。

では、実際にチャンク分割している部分を見てみましょう。

// src/mastra/scripts/ingest-markdown.ts
const chunks = await doc.chunk({
  strategy: 'markdown',  // 1. チャンク化戦略
  maxSize: 512,          // 2. 最大文字数
  overlap: 50,           // 3. 重複文字数
});

チャンク化戦略の「markdown」というのは、Markdown特有の構造(見出し、リスト、コードブロックなど)を理解してチャンクに分割します。ひとつの章ごとにチャンクを分けることで、文章の意味がひとまとまりになりやすいです。

とはいえ、ひとつの章がすごく長くなった場合は文字数でチャンク分割します。最大文字数「512」と、オーバーラップ「50」というのが、その切り方です。

文字数ですが、短すぎるとあまりにも近視眼的すぎて全体の文脈を失いますし、長すぎるとあまりにも巨視的でピンポイントの検索精度が出ません。標準が512と言われていますが、ドキュメントの種類でチューニングするとよい、と言われています。

ドキュメントの種類 推奨maxSize 理由
技術用語集 128-256 各項目が短く独立している
FAQ 256-512 Q&A1件が1チャンクに収まる
一般的なドキュメント 512 バランスが良い
技術記事・ガイド 512-1024 詳細な説明が必要
学術論文 1024-2048 複雑な文脈が重要

次にオーバーラップですが、完全に文字数でブツっと切ってしまうと、特に2つ目以降のチャンクの冒頭で何を言っているのか意味不明になります。

オーバーラップなし

Chunk 1: [────────512文字────────]
Chunk 2:                          [────────512文字────────]

                             文脈が途切れる!

オーバーラップあり

Chunk 1: [────────512文字────────]
Chunk 2:                    [──50──][────────462文字────────]
                            ↑ 重複部分
                         文脈がつながる!

チャンク同士を一部オーバーラップさせることで、2つ目のチャンクも文脈の通じる文章になり、結果として検索効率も上がるのです。


RAGについては、話すことが多すぎるので今回はここまで。次回は分割したチャンクに対して意味づけを埋め込むエンベッディング処理をしたうえで、ベクトルDBに登録。そしてエージェントがツール経由でRAGを利用できるところまで確認します。

>> 次回 : (4) RAGで社内情報を活用~エンベッディング編

Discussion