2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

pgvector + RAGで健康診断から食事を自動提案(AI Agent②)

2
Last updated at Posted at 2026-03-02

はじめに

前回の記事では、健康診断書の画像を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/リクエスト)      │  │                                 │
└─────────────────────────┘  └─────────────────────────────────┘

データフロー:

  1. ユーザー設定 + 健康診断結果から 条件タグ を生成
  2. 条件タグと自然言語テキストから 検索クエリ を構築
  3. AWS Bedrock で検索クエリを 1024次元ベクトル に変換
  4. pgvector のコサイン類似度 + タグ一致率 + ジャンル一致で ハイブリッドスコアリング
  5. 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検索を始めたい方の参考になれば幸いです。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?