はじめに
前回の記事では、健康診断書の画像をAIで自動解析し、34項目の検査データをDBに保存・トレンド分析するシステムを実装しました。
今回はその続編として、蓄積した健康診断データを活用し、ユーザーの健康状態と好みに合った食事メニューを自動提案する RAG 検索システム を実装しました。「データを貯めるだけ」から「具体的なアクションにつなげる」への拡張です。
技術スタック
| レイヤー | 技術 |
|---|---|
| AIフレームワーク | Mastra v0.24.6 |
| Embedding モデル | AWS Bedrock Amazon Titan Text Embeddings V2 (1024次元) |
| データベース | PostgreSQL 16 + pgvector |
| バックエンド | Node.js, TypeScript 5.9 |
| フロントエンド | Next.js 16, React 19 |
アーキテクチャ全体像
┌─────────────────────────────────────────────────────────────────┐
│ ユーザーリクエスト │
│ (年齢, 健康診断結果, アレルギー, 料理の好み, 予算, etc.) │
└──────────────────────────┬──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ Mastra Agent (generate-weekly-schedule) │
│ │
│ 1. 健康診断DBから最新データ取得 → 異常値を条件タグに変換 │
│ 2. ユーザー設定(freeText)をパース → 予算・食材好み・ジャンル抽出 │
│ 3. RAGモード判定 (embedding付きマスターが存在するか) │
│ 4. 日別ループ: │
│ └── search-relevant-menus (朝/昼/夕/間食) │
└──────────────────┬───────────────────────┬───────────────────────┘
│ │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────────────┐
│ AWS Bedrock │ │ PostgreSQL + pgvector │
│ Titan Embed V2 │ │ │
│ │ │ menu_masters (296件) │
│ 検索クエリ → 1024次元 │ │ embedding vector(1024) │
│ ベクトル生成 │ │ + health_tags, allergens... │
│ (~200ms/リクエスト) │ │ │
└─────────────────────────┘ └─────────────────────────────────┘
データフロー:
- ユーザー設定 + 健康診断結果から 条件タグ を生成
- 条件タグと自然言語テキストから 検索クエリ を構築
- AWS Bedrock で検索クエリを 1024次元ベクトル に変換
- pgvector のコサイン類似度 + タグ一致率 + ジャンル一致で ハイブリッドスコアリング
- WHEREフィルタ(アレルゲン除外、年齢範囲、予算)を適用
マスターデータ設計
テーブルスキーマ
-- pgvector拡張の有効化
CREATE EXTENSION IF NOT EXISTS vector;
-- メニューマスター
CREATE TABLE IF NOT EXISTS menu_masters (
id SERIAL PRIMARY KEY,
menu_name VARCHAR(255) NOT NULL,
meal_type VARCHAR(20) NOT NULL, -- breakfast/lunch/dinner/snack
cuisine_type VARCHAR(50) DEFAULT 'general', -- japanese/chinese/italian/...
description TEXT,
ingredients TEXT[] DEFAULT '{}', -- PostgreSQL配列型で食材リスト
calories INTEGER,
estimated_cost INTEGER, -- 1食あたりの概算コスト(円)
health_tags TEXT[] DEFAULT '{}', -- 栄養特性タグ
target_conditions TEXT[] DEFAULT '{}', -- 対象健康課題タグ
allergens TEXT[] DEFAULT '{}', -- アレルゲン情報
dietary_categories TEXT[] DEFAULT '{}', -- 食事スタイル分類
age_tags TEXT[] DEFAULT '{}', -- 年齢適性タグ
min_age INTEGER DEFAULT 0,
max_age INTEGER DEFAULT 100,
embedding vector(1024), -- pgvector: 1024次元ベクトル
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
タグ設計
PostgreSQL の 配列型 (TEXT[]) をフル活用し、1レコードに複数のタグを持たせています。これにより正規化テーブルを増やすことなく、柔軟なフィルタリングが可能です。
各メニューには以下のタグが付与されています。
| タグ | カラム | 用途 |
|---|---|---|
| 食事タイプ | meal_type |
breakfast / lunch / dinner / snack |
| 料理ジャンル | cuisine_type |
japanese, chinese, italian 等 11種 |
| 栄養特性 | health_tags |
high_protein, omega3 等 17種 |
| 健康課題 | target_conditions |
lipid_issue, glucose_issue 等 7種 |
| アレルゲン | allergens |
dairy, gluten, egg 等 |
| 食事カテゴリ | dietary_categories |
vegan, gluten_free, low_carb 等 |
| 年齢適性 |
age_tags / min_age / max_age
|
senior_friendly 等 |
| 主要食材 | ingredients |
["鮭", "米", "味噌"] 等 |
| カロリー | calories |
概算 kcal |
| コスト | estimated_cost |
1食あたり概算コスト(円) |
上記のうち、スコアリングに直接使われる 栄養特性 と 健康課題 の詳細は以下の通りです。
栄養特性 (health_tags) — 17種類
| タグ | 意味 | 付与数 | タグ | 意味 | 付与数 |
|---|---|---|---|---|---|
high_protein |
高タンパク | 173 | heart_healthy |
心臓に良い | 39 |
balanced |
バランス型 | 110 | anti_inflammatory |
抗炎症 | 38 |
low_fat |
低脂肪 | 105 | omega3 |
オメガ3脂肪酸 | 33 |
vitamin_rich |
ビタミン豊富 | 95 | low_sodium |
低塩分 | 27 |
high_fiber |
食物繊維豊富 | 95 | kidney_friendly |
腎臓に優しい | 25 |
low_calorie |
低カロリー | 70 | probiotic |
腸活 | 23 |
easy_digest |
消化に良い | 69 | low_purine |
低プリン体 | 19 |
antioxidant |
抗酸化 | 60 | |||
mineral_rich |
ミネラル豊富 | 59 | |||
liver_friendly |
肝臓に優しい | 46 |
健康課題 (target_conditions) — 7種類
| タグ | 対応する健康診断項目 | 付与数 |
|---|---|---|
weight_issue |
BMI・腹囲 | 125 |
lipid_issue |
中性脂肪・LDL・HDL・総コレステロール | 115 |
glucose_issue |
血糖値・HbA1c | 84 |
blood_pressure_issue |
収縮期血圧・拡張期血圧 | 64 |
liver_issue |
GOT・GPT・γ-GTP | 52 |
uric_acid_issue |
尿酸 | 39 |
kidney_issue |
クレアチニン・eGFR・尿素窒素 | 33 |
データ規模
| テーブル | 件数 | 内訳 |
|---|---|---|
menu_masters |
296件 | 朝食50, 昼食100, 夕食87, 間食59 / 11ジャンル |
Embedding 生成
AWS Bedrock Titan V2 の設定
Vercel AI SDK (ai) + @ai-sdk/amazon-bedrock で Titan Text Embeddings V2 を利用します。
const EMBEDDING_MODEL = bedrock.embedding('amazon.titan-embed-text-v2:0');
マスターデータ登録時に296件分の Embedding を一括生成し、DB に保存します。シード投入は20件ずつのバッチ処理(バッチ間500msディレイ)で Bedrock のレート制限を回避しています。
検索時は ユーザーの検索クエリ1回分(~200ms)のみ API を呼び出し、296件との類似度比較はすべて PostgreSQL 内で完結します。毎回全メニューの Embedding を生成し直す必要がないため、高速かつ API コストを最小限に抑えられます。
Embedding テキストの構築
Embedding の品質はテキスト構築ロジックに大きく左右されます。メニューの各属性を / 区切りで結合し、検索時の意味的マッチングを促進します。
// src/mastra/db/seeds/seed-master-data.ts
function buildMenuEmbeddingText(menu: MenuSeed): string {
const parts = [
`メニュー: ${menu.menu_name}`,
`食事: ${menu.meal_type}`,
`ジャンル: ${menu.cuisine_type}`,
menu.ingredients.length > 0 ? `素材: ${menu.ingredients.join(', ')}` : '',
menu.description ? `説明: ${menu.description}` : '',
menu.health_tags.length > 0 ? `効果: ${menu.health_tags.join(', ')}` : '',
menu.target_conditions.length > 0 ? `対象: ${menu.target_conditions.join(', ')}` : '',
menu.age_tags.length > 0 ? `年齢層: ${menu.age_tags.join(', ')}` : '',
];
return parts.filter(Boolean).join(' / ');
}
生成されるテキスト例:
メニュー: サーモン定食 / 食事: lunch / ジャンル: japanese / 素材: 鮭, 米, 味噌, 豆腐
/ 説明: オメガ3脂肪酸が豊富で心臓に良い / 効果: omega3, high_protein, heart_healthy
/ 対象: lipid_issue, blood_pressure_issue / 年齢層: middle_age_prevention
このテキストが Titan V2 で 1024次元ベクトルに変換され、menu_masters.embedding カラムに格納されます。
pgvector によるベクトル検索
セットアップ
Docker Compose で pgvector 対応の PostgreSQL を起動します。
# docker-compose.yml
services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: health_checkup
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "15432:5432"
-- 拡張の有効化
CREATE EXTENSION IF NOT EXISTS vector;
インデックス設計
検索性能を確保するため、HNSW インデックス と GIN インデックス の2種類を併用します。
-- HNSW index (コサイン類似度によるベクトル検索)
CREATE INDEX IF NOT EXISTS idx_menu_embedding
ON menu_masters USING hnsw (embedding vector_cosine_ops);
-- GIN index (配列フィルタ: アレルゲン除外、タグ一致)
CREATE INDEX IF NOT EXISTS idx_menu_health_tags
ON menu_masters USING gin (health_tags);
CREATE INDEX IF NOT EXISTS idx_menu_conditions
ON menu_masters USING gin (target_conditions);
CREATE INDEX IF NOT EXISTS idx_menu_allergens
ON menu_masters USING gin (allergens);
| インデックス種別 | 対象 | 用途 |
|---|---|---|
| HNSW |
embedding カラム |
ベクトル近傍探索(コサイン類似度)。近似最近傍でも精度が高く、構築後の検索が高速 |
| GIN |
health_tags, target_conditions, allergens
|
配列の要素一致 (&& 演算子, = ANY()) によるフィルタリング |
ハイブリッド検索の実装
ここが本システムの核心部分です。単純なベクトル検索ではなく、ベクトル類似度 + タグ一致率 + ジャンル一致 を組み合わせたハイブリッドスコアリングで検索精度を高めています。
検索クエリテキストの構築
ユーザーの健康状態や好みを自然言語テキストに変換し、Embedding 化します。
// src/mastra/tools/search-relevant-menus.ts (抜粋)
// 1. 条件テキスト構築
const queryParts: string[] = [];
queryParts.push(`${mealType}の食事`); // 例: "lunchの食事"
if (healthConditions.length > 0) {
const conditionLabels: Record<string, string> = {
lipid_issue: '脂質異常',
glucose_issue: '血糖値管理',
liver_issue: '肝機能改善',
kidney_issue: '腎機能保護',
blood_pressure_issue: '血圧管理',
uric_acid_issue: '尿酸値管理',
weight_issue: '体重管理',
};
const labels = healthConditions.map(c => conditionLabels[c] || c).join('、');
queryParts.push(`${labels}に効果的`);
}
if (age) queryParts.push(`${age}歳向け`);
if (cuisinePreference) queryParts.push(`${cuisinePreference}料理`);
if (preferredIngredients.length > 0) {
queryParts.push(`${preferredIngredients.join('、')}を使った`);
}
queryParts.push('健康的な食事メニュー');
const queryText = queryParts.join('、');
// → "lunchの食事、脂質異常、血圧管理に効果的、50歳向け、japanese料理、健康的な食事メニュー"
完全な検索 SQL(メニュー)
SELECT
id,
menu_name,
meal_type,
cuisine_type,
description,
ingredients,
calories,
estimated_cost,
health_tags,
(
-- ① ベクトル類似度 (70%)
(1 - (embedding <=> $1::vector)) * 0.7
-- ② 健康課題タグ一致 (10%)
+ (SELECT COUNT(*)
FROM unnest(target_conditions) tc
WHERE tc = ANY($7))::float / 2 * 0.1
-- ③ 料理ジャンル一致 (15%)
+ CASE WHEN cuisine_type = $8 THEN 0.15 ELSE 0 END
) AS total_score
FROM menu_masters
WHERE is_active = TRUE
AND embedding IS NOT NULL
AND meal_type = $2 -- 食事タイプ
AND NOT (allergens && $3) -- アレルゲン除外 (配列の積集合)
AND min_age <= $4 AND max_age >= $5 -- 年齢範囲
AND (estimated_cost IS NULL
OR estimated_cost <= $6) -- 予算フィルタ
AND menu_name != ALL($9) -- 使用済みメニュー除外
ORDER BY total_score DESC
LIMIT $10
スコアリング式の詳細
メニュー検索
| 要素 | 重み | 算出方法 |
|---|---|---|
| ベクトル類似度 | 70% | (1 - (embedding <=> query_vector)) * 0.7 |
| 健康課題タグ一致 | 10% | (一致数 / ユーザー条件数) * 0.1 |
| 料理ジャンル一致 | 15% | ジャンル一致で 0.15、不一致で 0
|
<=> は pgvector のコサイン距離演算子で、0(完全一致)〜 2(正反対)の値を返します。1 - distance で類似度(0〜1)に変換しています。
WHERE句の NOT (allergens && $N) は PostgreSQL の配列重複演算子で、「指定アレルゲンが1つも含まれないメニューのみ」にフィルタリングします。
実際の生成結果画面:
具体例:スコア計算の詳細
例1: 50歳、脂質異常+高血圧、乳製品アレルギー、和食好き
入力:
年齢: 50歳
健康診断: LDLコレステロール 180(高)、血圧 150/95(高)
アレルギー: 乳製品
自由記述: 「和食好き、魚多め」
↓ 条件変換
conditionTags = ['lipid_issue', 'blood_pressure_issue']
allergenTags = ['dairy']
cuisinePreference = 'japanese'
preferredIngredients = ['魚']
↓ 検索クエリテキスト構築
"lunchの食事、脂質異常、血圧管理に効果的、50歳向け、
japanese料理、魚を使った、健康的な食事メニュー"
↓ Titan V2 で 1024次元ベクトルに変換 → pgvectorで検索
スコア計算例 — さばの味噌煮定食:
① ベクトル類似度 (70%)
embedding <=> クエリembedding = 0.25 (コサイン距離)
→ (1 - 0.25) × 0.7 = 0.525
② 健康課題タグ一致 (10%)
target_conditions: [lipid_issue, blood_pressure_issue]
ユーザー条件: [lipid_issue, blood_pressure_issue]
→ 2つ中2つ一致 → (2/2) × 0.1 = 0.10
③ 料理ジャンル一致 (15%)
cuisine_type: japanese = ユーザーの好み
→ 0.15
合計: 0.525 + 0.10 + 0.15 = 0.775
WHEREフィルタ通過:
-
allergens: [fish, soy]→ dairy なし → OK -
min_age: 20, max_age: 65→ 50歳 → OK - グラタン(
allergens: [dairy]) →NOT (allergens && ['dairy'])→ 除外
例2: 65歳、腎機能低下+尿酸高値、1食300円以内
入力:
年齢: 65歳
健康診断: クレアチニン 1.5(高)、eGFR 45(低)、尿酸 8.5(高)
自由記述: 「1食300円以内」
↓ 条件変換
conditionTags = ['kidney_issue', 'uric_acid_issue']
budgetPerMeal = 300 (freeTextからパース)
ageTags = ['senior_friendly']
スコア計算例 — 豆腐と野菜のあんかけ:
① ベクトル類似度 (70%)
embedding <=> クエリembedding = 0.20 (コサイン距離)
→ (1 - 0.20) × 0.7 = 0.56
② 健康課題タグ一致 (10%)
target_conditions: [kidney_issue, uric_acid_issue]
→ 2つ中2つ一致 → (2/2) × 0.1 = 0.10
合計: 0.56 + 0.10 = 0.66
WHEREフィルタ通過:
-
estimated_cost: 280→<= 300→ OK -
age_tags: [senior_friendly]→ 年齢一致 - ステーキ定食(
estimated_cost: 650) →estimated_cost <= 300→ 除外
まとめ
本記事で解説したハイブリッド RAG 検索の設計ポイントをまとめます。
| 設計判断 | 理由 |
|---|---|
| pgvector を採用 | 既存の PostgreSQL にベクトル検索機能を追加するだけで済み、専用のベクトルDBを別途管理する必要がない |
| ハイブリッドスコアリング | ベクトル類似度だけでは健康課題の正確なマッチングが不十分。タグ一致率を加算することで精度を補完 |
| PostgreSQL 配列型 + GIN インデックス | アレルゲン除外や健康タグのフィルタリングを正規化テーブルなしで高速に実現 |
| Embedding テキストに属性を明示的に含める |
効果: omega3, high_protein のようにタグもテキスト化することで、検索クエリとの意味的マッチング精度が向上 |
| Embedding 事前計算 + DB保存 | 検索時の API 呼び出しはクエリ変換の1回(~200ms)のみ。高速かつ低コスト |
pgvector を使えば、既存の PostgreSQL だけでベクトル検索+従来のSQLフィルタを1クエリに統合できます。専用のベクトルDBを持たずにRAG検索を始めたい方の参考になれば幸いです。
