Mastraを使ってRAGに入門してみた
本記事は「MIXI DEVELOPERS Advent Calendar 2025」の18日目の記事です。
株式会社MIXIでサロンスタッフ予約サービス「minimo」の機械学習やバックエンドの開発をしている鈴木です。
MastraはAIを搭載したアプリケーションやエージェントを構築するためのフレームワークで、TypeScriptで記述します。
MastraではRAG (Retrieval-Augmented Generation) を使用することができるので、Mastraを使ってRAGに入門してみます。
構成
- Docker Compose
- Mastraコンテナ
- ベクトルデータベースコンテナ: OpenSearch 2.19.0
- embeddingモデル: Amazon Titan Text Embedding v2
- Amazon Bedrock経由で使用するため、AWSアカウントが必要
- 料金はap-northeast-1リージョンで0.000029USD/1000入力トークン (本記事執筆時点 / 詳細は https://aws.amazon.com/jp/bedrock/pricing/ 参照)
- AWS Bedrockで使用できるembeddingモデルの中で最も安いため、このモデルを使用
準備
次のIAMポリシーをアタッチしたAWSのIAMユーザーやIAM Identity Centerの権限セット等を用意します。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": "bedrock:InvokeModel",
"Resource": "arn:aws:bedrock:ap-northeast-1::foundation-model/amazon.titan-embed-text-v2:0"
}
]
}
このIAMユーザー等を手元の環境のAWS CLIで使用できるように aws configure --profile ... や aws configure sso 等で設定します。
この際に設定したprofile名を後で使うのでメモしておきましょう。
入門用コード
TODOコメントが付与されている部分は必要に応じて変更してください。
ドキュメント群
import { z } from "zod";
import { documentSchema } from "./lib";
/**
* ドキュメント群 (ID付き) のスキーマ定義
*/
const documentsWithIDSchema = z.array(
z
.object({
id: z.number(),
})
.merge(documentSchema),
);
/**
* ドキュメント群
*/
export const documents: z.infer<typeof documentsWithIDSchema> = [
{
id: 1,
title: "来月の発注の件について",
body: "来月はA社に製品bを100個発注予定です。",
},
{
id: 2,
title: "先月の発注の件について",
body: "先月はC社に製品dを10個発注しましたが、キャンセルされました。",
},
];
今回ベクトルデータベースに投入するドキュメント群です。
企業のナレッジデータベースを想定して作りましたが、内容の雑さはスルーしてください。
メインスクリプト
import { z } from "zod";
import { createIndex } from "./createIndex";
import { documentsSchema } from "./lib";
import { upsertEmbeddings } from "./upsertEmbeddings";
import { search } from "./search";
async function main() {
// ベクトルデータベース用のOpenSearchのindexを作成する
await createIndex();
// ドキュメントのembeddingを生成してベクトルデータベースに保存する
await upsertEmbeddings();
const question: string = "先月の発注はどのような感じでしたか?";
console.log(`Q: ${question}`);
// ベクトルデータベースからドキュメントを検索する
// id: 2のドキュメントがヒットして欲しい
const documents: z.infer<typeof documentsSchema> = await search(question, 1);
console.log(`A: ${JSON.stringify(documents, null, 2)}`);
}
main();
まず大元となるコードです。
コードを見るとわかるように、以下のような流れで処理を行っていきます。
- ベクトルデータベース用のindex作成
- ドキュメントのembeddingを生成してベクトルデータベースに保存
- ベクトルデータベースからドキュメントを検索
documents.ts と見比べるとわかるように、ここでは id: 2 のドキュメントがヒットすることが期待されます。
ベクトルデータベース用のindex作成
import { embed } from "ai";
import { embeddingModel, vectorDatabase, vectorDatabaseIndexName } from "./lib";
import type { Embedding } from "ai";
/**
* ベクトルデータベース用のOpenSearchのindexを作成する
*/
export async function createIndex() {
// 次元数取得のためのダミーのembedding生成
const { embedding }: { embedding: Embedding } = await embed({
value: "ダミー",
model: embeddingModel,
});
if (vectorDatabase === undefined) {
return;
}
// index作成
await vectorDatabase.createIndex({
indexName: vectorDatabaseIndexName,
// 実際に生成したembeddingからembeddingの次元数を求める
dimension: embedding.length,
// https://github.com/mastra-ai/mastra/blob/7e6db67f5e2ce58ef62eae324844e32ed314bad1/stores/opensearch/src/vector/index.ts#L54
// metricのデフォルト値はcosine
//
// TODO: OpenSearch 2.19以前を使用する場合はcosineに対応していないため、以下のコメントアウトを解除
// metric: "euclidean",
});
console.log(`Created index: ${vectorDatabaseIndexName} `);
}
ベクトルデータベース用のOpenSearchのindexを作成します。
indexを作成する際にembeddingの次元数を指定する必要があります。
Mastraのドキュメントでは次元数を直接指定していますが、本コードではダミーのembeddingを生成し、そこからembeddingの次元数を求めるようにしています。
こうすることで、embeddingモデルを差し替えた際に次元数の設定が自動的に変更されるようにしています。
また、 metric のデフォルト値は cosine になっています。
しかし、 cosine に対応したのは2.19からであり、それ以前のバージョンでは対応していないようなので、2.19以前を使いたい場合は euclidean 等の別の metric を指定しましょう。
ドキュメントのembeddingを生成とベクトルデータベースへの保存
import { embedMany } from "ai";
import { z } from "zod";
import { MDocument } from "@mastra/rag";
import { documents } from "./documents";
import {
embeddingModel,
documentSchema,
vectorDatabase,
vectorDatabaseIndexName,
} from "./lib";
import type { Embedding } from "ai";
/**
* ドキュメントのembeddingを生成してベクトルデータベースに保存する
*/
export async function upsertEmbeddings() {
for (const document of documents) {
const vectorTexts: string[] = [document.title, document.body];
// ドキュメントを初期化してチャンク作成
const chunks = await MDocument.fromText(vectorTexts.join(" ")).chunk({
strategy: "recursive",
});
// embedding生成
const { embeddings }: { embeddings: Embedding[] } = await embedMany({
values: chunks.map(({ text }) => text), // 各チャンクのテキストを渡す
model: embeddingModel,
});
// embeddingのメタデータのスキーマ定義
const embeddingMetadataSchema = documentSchema.merge(
z.object({
documentID: z.number().describe("ドキュメントID"),
chunkID: z.number().describe("チャンクID"),
embeddingsText: z.string().describe("チャンクのテキスト (デバッグ用)"),
}),
);
if (vectorDatabase === undefined) {
return;
}
// embeddingをベクトルデータベースに保存
await vectorDatabase.upsert({
indexName: vectorDatabaseIndexName,
ids: chunks.map((_, i) => `${document.id}_${i}`),
vectors: embeddings,
// デフォルトではベクトルデータベースにはIDとembeddingしか保存されない
// IDとembedding以外の検索時に取得したいドキュメントの内容はメタデータとして保存する
metadata: chunks.map(
({ text }, i): z.infer<typeof embeddingMetadataSchema> => ({
documentID: document.id,
chunkID: i,
title: document.title,
body: document.body,
embeddingsText: text,
}),
),
});
}
}
ドキュメントのembeddingを生成してベクトルデータベースに保存します。
ここではドキュメントの title と body を結合した上でチャンクに区切り、それらのembeddingを作成しています。
これをベクトルデータベースに保存するのですが、ここで注意が必要なのがメタデータです。
ベクトルデータベースに保存されるのはIDとembeddingのみであり、これだけでは検索してもどのドキュメントがヒットしたのか (少なくとも人間には) よくわからないです。
そのため、IDとembedding以外の検索時に取得したいドキュメントの内容はメタデータとして保存する必要があります。
本コードではドキュメントの内容に加え、デバッグ用に各チャンクのテキストも embeddingsText として保存しています。
ドキュメントの検索
import { embed } from "ai";
import { z } from "zod";
import {
embeddingModel,
documentSchema,
vectorDatabase,
vectorDatabaseIndexName,
documentsSchema,
} from "./lib";
import type { Embedding } from "ai";
import type { QueryResult } from "@mastra/core";
const optionalDocumentsSchema = documentsSchema.optional();
/**
* ベクトルデータベースからドキュメントを検索する
* @param question 自然言語による質問
* @param count 取得件数
* @return 取得結果のドキュメント群
*/
export async function search(
question: string,
count: number,
): Promise<z.infer<typeof optionalDocumentsSchema>> {
try {
// embedding生成
const { embedding }: { embedding: Embedding } = await embed({
value: question,
model: embeddingModel,
});
if (vectorDatabase === undefined) {
return;
}
// 検索
const queryResults: QueryResult[] = await vectorDatabase.query({
indexName: vectorDatabaseIndexName,
queryVector: embedding,
topK: count,
});
// 検索結果のメタデータをドキュメントのスキーマ定義に当てはめて返す
return queryResults.map(({ metadata }) => documentSchema.parse(metadata));
} catch (error) {
console.error("Error in Search:", error);
throw error;
}
}
ベクトルデータベースを検索します。
search では引数として question と count をとっています。
question は自然言語による質問であり、すなわち日本語での質問をそのまま与えます。
count は取得件数です。
question のembeddingを生成し、それを使ってベクトルデータベースを検索しています。
そして、その結果のメタデータをドキュメントのスキーマに当てはめて返します。
共通ライブラリ
import { z } from "zod";
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock";
import { fromIni } from "@aws-sdk/credential-providers";
import { OpenSearchVector } from "@mastra/opensearch";
import type { AmazonBedrockProvider } from "@ai-sdk/amazon-bedrock";
import type { EmbeddingModelV2 } from "@ai-sdk/provider";
import type { AwsCredentialIdentity } from "@smithy/types";
/**
* ドキュメントのスキーマ定義
*/
export const documentSchema = z.object({
title: z.string(),
body: z.string(),
});
/**
* ドキュメント群のスキーマ定義
*/
export const documentsSchema = z.array(documentSchema);
/**
* Amazon BedrockのProvider
* embeddingモデルを使用するために必要
*/
const bedrockProvider: AmazonBedrockProvider = createAmazonBedrock({
region: process.env.AWS_REGION || "ap-northeast-1",
// 環境変数 AWS_PROFILE にセットされたprofile名からAWSの認証情報を取得
...(process.env.AWS_PROFILE && {
credentialProvider: async (): Promise<{
accessKeyId: string;
secretAccessKey: string;
sessionToken?: string;
}> => {
const identity: AwsCredentialIdentity = await fromIni({
profile: process.env.AWS_PROFILE,
})();
return {
accessKeyId: identity.accessKeyId,
secretAccessKey: identity.secretAccessKey,
sessionToken: identity.sessionToken,
};
},
}),
});
/**
* embeddingモデル
*/
export const embeddingModel: EmbeddingModelV2<string> =
bedrockProvider.embedding("amazon.titan-embed-text-v2:0");
/**
* ベクトルデータベース
*/
export const vectorDatabase: OpenSearchVector | undefined = process.env
.OPENSEARCH_URL
? new OpenSearchVector({
url: process.env.OPENSEARCH_URL,
})
: undefined;
/**
* ベクトルデータベースのindex名を生成する
* embeddingモデルによってembeddingの次元数が異なるため、embeddingモデルを差し替えた際に別のindexが生成されるようにしている
* @param embeddingModel embeddingモデル
*/
const createIndexName = (embeddingModel: EmbeddingModelV2<string>): string =>
["embeddings", embeddingModel.provider, embeddingModel.modelId]
.join("_")
.replace(/[^a-zA-Z0-9_]/g, "_");
/**
* ベクトルデータベースのindex名
*/
export const vectorDatabaseIndexName: string = createIndexName(embeddingModel);
documentSchema や documentSchema は検索結果として返すドキュメント (群) のスキーマ定義です。
他のドキュメント関連のスキーマ定義のベースにもなっています。
bedrockProvider はAmazon BedrockのProviderです。
ここでは環境変数 AWS_PROFILE にセットされたprofile名からAWSの認証情報を取得し、それらをセットしています。
embeddingModel では bedrockProvider を使ってembeddingモデルを取得しています。
vectorDatabase はベクトルデータベースです。
createIndexName ではベクトルデータベースのindex名を生成しています。
embeddingモデルによってembeddingの次元数が異なるため、embeddingモデルを差し替えた際に別のindexが生成されるようにしています。
vectorDatabaseIndexName では createIndexName を使って生成したindex名を代入しています。
その他実行に必要なファイル群
# Bedrock Configuration (for local development)
# TODO: 記載する
AWS_PROFILE=your-profile-name
# TODO: ap-northeast-1以外のリージョンを使う場合は以下のコメントアウトを解除し記載する
# AWS_REGION=ap-northeast-1
# OpenSearch Configuration (for local development)
OPENSEARCH_INITIAL_ADMIN_PASSWORD=pnssWord@2
IAMユーザー等のAWS CLIでのprofile名を指定します。
また、 ap-northeast-1以外のリージョンを使う場合は AWS_REGION を設定します。
node_modules
.env
.DS_Store
Gitで管理する場合は .env 等を .gitignore に入れます。
{
"name": "mastra-rag",
"scripts": {
"dev": "tsx dev.ts"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "3.0.66",
"@aws-sdk/credential-providers": "3.943.0",
"@mastra/core": "0.24.3",
"@mastra/opensearch": "0.11.18",
"@mastra/rag": "1.3.6",
"ai": "5.0.109",
"tsx": "4.21.0",
"zod": "3.25.76"
},
"devDependencies": {
"@ai-sdk/provider": "2.0.0",
"@smithy/types": "4.9.0",
"@types/node": "24.10.3",
"prettier": "3.7.4",
"typescript": "5.9.3"
}
}
npm run dev で dev.ts が実行されるようになっています。
また、本筋とは関係ないですが、本記事のコードは prettier でフォーマットを修正しています。
FROM node:24.12.0-bookworm-slim@sha256:04d9cbb7297edb843581b9bb9bbed6d7efb459447d5b6ade8d8ef988e6737804 AS base
# Create non-root user for security
RUN groupadd -r mastra && useradd -r -g mastra -m mastra
# Set working directory
WORKDIR /app
COPY . .
# dependenciesのみインストール(tsxでTypeScriptを直接実行)
RUN npm ci --omit=dev && npm cache clean --force
# Change ownership to non-root user
RUN chown -R mastra:mastra /app
# Switch to non-root user
USER mastra
CMD ["npm", "run", "dev"]
RAGを動かすDockerイメージの Dockerfile です。
node_modules
Dockerfile
.dockerignore
.git
.gitignore
.env
.DS_Store
Dockerイメージ上に余分なファイルが載らないように .dockerignore を設定しておきます。
services:
mastra:
build:
context: .
dockerfile: Dockerfile
env_file:
- .env
environment:
- OPENSEARCH_URL=http://opensearch:9200
volumes:
- ~/.aws:/home/mastra/.aws:ro
depends_on:
opensearch:
condition: service_healthy
opensearch:
image: opensearchproject/opensearch:2.19.0
environment:
node.name: "es01"
cluster.initial_master_nodes: "es01"
plugins.security.disabled: "true"
env_file:
- .env
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9200/_cluster/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
RAGを動かすDocker Composeの定義です。
RAGを動かす mastra コンテナと opensearch コンテナで構成されており、 opensearch コンテナの起動が完了すると mastra コンテナが起動します。
{
"compilerOptions": {
"skipLibCheck": true,
"noEmit": true
}
}
本筋の実行とは関係ないですが、 tsc で型チェックが行えるよう、 tsconfig.json を設定しておきます。
実行方法
-
(IAM Identity Centerの権限セットを使用する場合のみ)
aws sso login --profile ...で今回使用する権限セットにログインします。 -
npm installを実行し、package-lock.jsonを生成します。 -
docker compose up --buildを実行します。
しばらく待つと次のように出力されるでしょう。mastra-1 | > dev mastra-1 | > tsx dev.ts mastra-1 | mastra-1 | Created index: embeddings_amazon_bedrock_amazon_titan_embed_text_v2_0 mastra-1 | Q: 先月の発注はどのような感じでしたか? mastra-1 | A: [ mastra-1 | { mastra-1 | "title": "先月の発注の件について", mastra-1 | "body": "先月はC社に製品dを10個発注しましたが、キャンセルされました。" mastra-1 | } mastra-1 | ] mastra-1 exited with code 0 -
結果を確認したら
Ctrl + CでDocker Composeを停止します。
最後に
Mastraの公式ドキュメントにも入門用のコードはありますが、それだけではうまく動かなかったため、コピペすればとりあえず入門できる状態を目指して書いてみました。
この記事を元にRAGに入門してみてはいかがでしょうか。
Discussion