mybook

AI 時代の FDE — エージェント、RAG、Eval の最前線

帝都生命の会議室

「ソウタさん、来週から帝都生命のプロジェクトに入ってもらいます」

Arclight AI のオフィスで、カイがプロジェクト資料を広げた。国内有数の生命保険会社「帝都生命」が、保険金請求処理を AI で自動化したいという。年間 80 万件の請求書類を、現在は 300 人の査定担当者が手作業で処理している。

「PoC はもう終わってるんですよね?デモ動画も見ました。かなり精度が高そうでしたが」

ソウタの言葉に、カイは苦笑した。

「AI の PoC は誰でも作れる。本番で動かし続けるのが地獄だ」

カイはホワイトボードに「PoC」と「Production」の間に巨大な谷を描いた。

「この谷を"死の谷"と呼ぶ。AI プロジェクトの 90% 以上がここで死ぬ」

WARNING

Gartner の調査によると、AI プロジェクトの 85% が本番投入に至らない。さらに本番投入できたプロジェクトの半数が 1 年以内に運用停止される。技術的な問題よりも、業務プロセスとの不整合やエッジケースの処理不足が主因だ。


PoC と本番の間にある "死の谷"

カイはソウタに、過去の失敗プロジェクトの事例を見せた。

「PoC では準備されたデータで 95% の精度が出る。でも本番データは汚い。手書きの文字は潰れているし、FAX で送られてきた書類はノイズだらけ。"その他"欄に自由記述で書かれた内容を AI が誤読して、本来 100 万円の保険金を 1,000 万円と判定したこともある」

Loading diagram...

FDE が埋めるラストマイル

PoC と本番の間のギャップは、純粋な技術の問題ではない。顧客のデータ、業務プロセス、エッジケースを深く理解した上で、AI を「使い物になる」レベルまで磨き上げる必要がある。

  • データの現実を知る -- 顧客が持つデータは PoC 用のクリーンなデータとはまるで違う
  • 業務フローに組み込む -- AI の出力を既存の承認プロセスにどう接続するか
  • エッジケースを潰す -- 「99% 正しい」では保険業務は回らない。残り 1% が訴訟リスクになる
  • 運用を設計する -- モデルの劣化検知、再学習パイプライン、障害時のフォールバック

INFO

AI 時代の FDE は「ML エンジニア」ではない。最先端の AI 技術を、顧客の泥臭い現実で動くようにする「技術と現実の翻訳者」だ。


RAG アーキテクチャの設計

帝都生命の最初の要件は「過去の査定事例を検索して、類似案件の判断根拠を提示する」機能だった。ソウタは RAG(Retrieval-Augmented Generation)アーキテクチャの設計に取りかかった。

Loading diagram...

チャンキング戦略

「まず文書をどう分割するかが最初の関門だ」とカイが言った。保険約款は数百ページに及ぶ法律文書だ。単純に 500 文字で区切ると、条項の途中で切れてしまい意味が失われる。

# app/services/document_chunker.rb
class DocumentChunker
  STRATEGIES = { fixed: FixedChunker, semantic: SemanticChunker,
                 recursive: RecursiveChunker }.freeze
 
  def initialize(strategy: :recursive, chunk_size: 512, overlap: 64)
    @strategy = STRATEGIES.fetch(strategy)
    @chunk_size = chunk_size
    @overlap = overlap
  end
 
  def chunk(document)
    @strategy.new(chunk_size: @chunk_size, overlap: @overlap)
             .call(document.content)
  end
end
 
# 再帰的チャンキング -- 構造を保持しながら分割
class RecursiveChunker
  SEPARATORS = ["\n## ", "\n### ", "\n\n", "\n", "。", ""].freeze
 
  def initialize(chunk_size:, overlap:)
    @chunk_size = chunk_size
    @overlap = overlap
  end
 
  def call(text)
    split_recursive(text, SEPARATORS)
  end
 
  private
 
  def split_recursive(text, separators)
    return [text] if text.length <= @chunk_size || separators.empty?
 
    sep = separators.first
    parts = text.split(sep).reject(&:empty?)
    chunks = []
    current = ""
 
    parts.each do |part|
      candidate = current.empty? ? part : "#{current}#{sep}#{part}"
      if candidate.length <= @chunk_size
        current = candidate
      else
        chunks << current unless current.empty?
        current = part
      end
    end
    chunks << current unless current.empty?
 
    chunks.flat_map do |chunk|
      chunk.length > @chunk_size ? split_recursive(chunk, separators[1..]) : chunk
    end
  end
end

「保険約款のような法律文書には再帰的チャンキングが効く。見出し、段落、文の順に分割を試みるから、意味の区切りが保たれる」とカイは説明した。


ベクトル DB の選定

「帝都生命は既に PostgreSQL を使っている。pgvector から始めよう」とカイが判断した。

INFO

Rails エンジニアにとって pgvector は最も自然な選択肢だ。既存の PostgreSQL にエクステンションを追加するだけで、Active Record からベクトル検索が使える。数百万件規模まではこれで十分に戦える。

# db/migrate/20260601000000_create_document_chunks.rb
class CreateDocumentChunks < ActiveRecord::Migration[8.0]
  def up
    enable_extension "vector"
 
    create_table :document_chunks do |t|
      t.references :document, null: false, foreign_key: true
      t.text :content, null: false
      t.integer :position, null: false
      t.column :embedding, :vector, limit: 1024
      t.jsonb :metadata, default: {}
      t.timestamps
    end
 
    add_index :document_chunks, :embedding,
              using: :hnsw, opclass: :vector_cosine_ops,
              name: "index_document_chunks_on_embedding"
  end
end
# app/models/document_chunk.rb
class DocumentChunk < ApplicationRecord
  belongs_to :document
  has_neighbors :embedding  # pgvector の neighbor gem
 
  scope :search_by_similarity, ->(query_embedding, limit: 5) {
    nearest_neighbors(:embedding, query_embedding, distance: "cosine")
      .limit(limit)
  }
end

ソウタは将来のスケーリングに備えて、他のベクトル DB も調査した。

Loading chart...
  • pgvector -- 既存の PostgreSQL に追加するだけ。Rails との親和性が最高。数百万件まで
  • Pinecone -- フルマネージド。スケーリングに強いが、ベンダーロックインあり
  • Weaviate -- ハイブリッド検索(ベクトル + キーワード)が強力。自前運用も可能
  • Qdrant -- 高性能でフィルタリング機能が充実。Rust 製で軽量

RAG クエリサービスの実装

ソウタは RAG のコア部分を Rails のサービスオブジェクトとして実装した。Embedding 生成には AWS Bedrock の Titan モデルを使い、回答生成には Claude を利用する。

# app/services/rag_query_service.rb
class RagQueryService
  MAX_CONTEXT_TOKENS = 4000
 
  def initialize(embedding_service: EmbeddingService.new,
                 llm_client: BedrockLlmClient.new)
    @embedding_service = embedding_service
    @llm_client = llm_client
  end
 
  def query(question, filters: {})
    query_embedding = @embedding_service.embed(question)
    chunks = DocumentChunk.search_by_similarity(query_embedding, limit: 20)
    chunks = chunks.where("metadata @> ?", filters.to_json) if filters.any?
 
    ranked = rerank(question, chunks.first(10))
    context = build_context(ranked.first(5))
    answer = generate(question, context)
 
    Conversation.create!(
      question: question, context_used: context,
      answer: answer, chunk_ids: ranked.map(&:id),
      model_version: @llm_client.model_id
    )
    answer
  end
 
  private
 
  def rerank(question, chunks)
    chunks.sort_by { |c| -compute_relevance_score(question, c.content) }
  end
 
  def build_context(chunks)
    parts, total = [], 0
    chunks.each do |chunk|
      tokens = chunk.content.length / 2  # 日本語の概算
      break if total + tokens > MAX_CONTEXT_TOKENS
      parts << "[出典: #{chunk.document.title}]\n#{chunk.content}"
      total += tokens
    end
    parts.join("\n\n---\n\n")
  end
 
  def generate(question, context)
    @llm_client.generate(
      system: system_prompt,
      messages: [{ role: "user",
                   content: "参照文書:\n#{context}\n\n質問: #{question}" }]
    )
  end
 
  def system_prompt
    <<~PROMPT
      あなたは帝都生命の保険査定アシスタントです。
      1. 参照文書に記載されている情報のみに基づいて回答する
      2. 参照文書にない情報は「該当する情報が見つかりません」と回答する
      3. 金額や日付は参照文書の記載を正確に引用する
      4. 法的な判断や最終的な査定結果は提示しない
      5. 回答の根拠となる出典を必ず明記する
    PROMPT
  end
end

Agent オーケストレーション

帝都生命の要件が複雑化していった。請求書類の種類を判定し、必要な情報を抽出し、過去の類似事例を検索し、査定基準に照らして判断を提示する -- 複数のステップを連携させる必要がある。

「これは Agent の出番だ」とカイが言った。

# app/services/claims_agent_orchestrator.rb
class ClaimsAgentOrchestrator
  def initialize(claim)
    @claim = claim
    @context = { claim_id: claim.id, steps: [] }
  end
 
  def process
    doc_type   = ClassifyDocumentAgent.new(@claim.document).call
    extracted  = ExtractInformationAgent.new(
      document: @claim.document, doc_type: doc_type,
      schema: extraction_schema_for(doc_type)
    ).call
 
    query_embedding = EmbeddingService.new.embed(build_search_query(extracted))
    similar = DocumentChunk.search_by_similarity(query_embedding, limit: 10)
 
    assessment = GenerateAssessmentAgent.new(
      claim_data: extracted, similar_cases: similar,
      guidelines: load_guidelines
    ).call
 
    validated = GuardrailService.new.validate(assessment, context: @context)
 
    ClaimAssessmentResult.create!(
      claim: @claim,
      assessment: validated[:assessment],
      confidence: validated[:confidence],
      similar_case_ids: similar.map(&:id),
      requires_human_review: validated[:confidence] < 0.85,
      processing_context: @context
    )
  end
end

「ポイントは requires_human_review だ。AI が自信のない案件は必ず人間に回す。保険業務で AI が暴走したら取り返しがつかない」


Eval フレームワーク -- AI を「テスト」する

ソウタが最も衝撃を受けたのは、カイが Eval(評価)に割く時間の多さだった。

「Eval は AI プロジェクトで最も重要で、最も軽視されている部分だ」

従来のソフトウェアテストは「入力 A に対して出力 B が返ること」を検証する。だが AI の出力は毎回異なる。同じ質問をしても、微妙に違う文章が返ってくる。

# app/services/eval_runner.rb
class EvalRunner
  EVAL_PROMPT = <<~PROMPT
    あなたは AI の回答品質を評価する審査員です。
    0.0〜1.0 のスコアを付けてください。
 
    ## 評価基準
    - faithfulness: 参照文書に忠実か
    - relevance: 質問に適切に答えているか
    - completeness: 必要な情報が網羅されているか
    - safety: 不適切な内容を含んでいないか
 
    質問: %{question}
    参照文書: %{context}
    AI の回答: %{answer}
 
    JSON で回答: {"faithfulness":0.0,"relevance":0.0,"completeness":0.0,"safety":0.0,"reasoning":""}
  PROMPT
 
  def initialize(llm_client: BedrockLlmClient.new)
    @llm_client = llm_client
  end
 
  def evaluate(question:, context:, answer:)
    prompt = format(EVAL_PROMPT, question: question,
                    context: context, answer: answer)
    scores = JSON.parse(@llm_client.generate(prompt))
 
    EvalResult.create!(
      question: question, context: context, answer: answer,
      faithfulness: scores["faithfulness"],
      relevance: scores["relevance"],
      completeness: scores["completeness"],
      safety: scores["safety"],
      reasoning: scores["reasoning"]
    )
  end
 
  def run_suite(test_cases)
    results = test_cases.map do |tc|
      answer = RagQueryService.new.query(tc[:question])
      evaluate(question: tc[:question], context: tc[:context], answer: answer)
    end
    EvalSuiteReport.new(results)
  end
end

WARNING

Eval は「一度作って終わり」ではない。顧客の業務が変わるたびにテストケースも更新が必要だ。FDE は顧客と定期的にレビューして、Eval スイートを進化させ続ける。これが AI の品質を維持する唯一の方法だ。


AWS Bedrock + OpenSearch のエンタープライズ RAG 構成

帝都生命は AWS を全面採用している。ソウタはエンタープライズグレードの RAG アーキテクチャを設計した。

Loading diagram...
# app/clients/bedrock_llm_client.rb
class BedrockLlmClient
  MODEL_ID = "anthropic.claude-3-5-sonnet-20241022-v2:0"
 
  def initialize(region: "ap-northeast-1")
    @client = Aws::BedrockRuntime::Client.new(region: region)
  end
 
  def model_id = MODEL_ID
 
  def generate(system: nil, messages:, max_tokens: 2048)
    body = { anthropic_version: "bedrock-2023-05-31",
             max_tokens: max_tokens, messages: messages }
    body[:system] = system if system
 
    response = @client.invoke_model(
      model_id: MODEL_ID, content_type: "application/json",
      accept: "application/json", body: body.to_json
    )
    JSON.parse(response.body.read).dig("content", 0, "text")
  end
end

文書がアップロードされると、バックグラウンドジョブがチャンキング、Embedding 生成、インデックス登録を自動的に行う。

# app/jobs/document_indexing_job.rb
class DocumentIndexingJob < ApplicationJob
  queue_as :ai_indexing
  retry_on Aws::BedrockRuntime::Errors::ThrottlingException,
           wait: :polynomially_longer, attempts: 5
 
  def perform(document_id)
    document = Document.find(document_id)
    document.update!(indexing_status: "processing")
 
    chunks = DocumentChunker.new(strategy: :recursive).chunk(document)
    service = EmbeddingService.new(provider: :bedrock)
 
    chunks.each_with_index do |text, i|
      DocumentChunk.create!(
        document: document, content: text, position: i,
        embedding: service.embed(text),
        metadata: { source: document.filename,
                    section: detect_section(text) }
      )
    end
 
    document.update!(indexing_status: "completed",
                     chunk_count: chunks.size, indexed_at: Time.current)
  rescue StandardError => e
    document.update!(indexing_status: "failed", indexing_error: e.message)
    raise
  end
end

AI の安全装置 -- ガードレールの実装

「AI が暴走しないための仕組みを、ソウタなら何重に作る?」

カイの問いに、ソウタは考え込んだ。保険の査定に AI を使う以上、万が一の誤判定は許されない。

# app/services/guardrail_service.rb
class GuardrailService
  CONFIDENCE_THRESHOLD = 0.85
  AMOUNT_REVIEW_THRESHOLD = 500_000  # 50万円以上は人間レビュー必須
  PROHIBITED = [/確実に支払われます/, /絶対に/, /保証します/, /法的責任/].freeze
 
  def validate(assessment, context: {})
    violations = []
    violations.concat(check_prohibited(assessment[:text]))
    violations << { type: "low_confidence" } if assessment[:confidence] < CONFIDENCE_THRESHOLD
    if assessment[:estimated_amount]&.> AMOUNT_REVIEW_THRESHOLD
      violations << { type: "high_amount", amount: assessment[:estimated_amount] }
    end
    violations.concat(check_hallucination(assessment[:text], context))
 
    AuditLog.create!(event_type: "guardrail_check", claim_id: context[:claim_id],
                     details: { confidence: assessment[:confidence],
                                violations: violations })
 
    { assessment: assessment[:text], confidence: assessment[:confidence],
      violations: violations, requires_human_review: violations.any? }
  end
 
  private
 
  def check_prohibited(text)
    PROHIBITED.filter_map do |pat|
      { type: "prohibited_content", pattern: pat.source } if text.match?(pat)
    end
  end
 
  def check_hallucination(text, context)
    numbers = text.scan(/[\d,]+(?:円|万円|%)/)
    ctx = context[:similar_cases]&.map(&:content)&.join(" ") || ""
    numbers.filter_map do |num|
      { type: "potential_hallucination", value: num } unless ctx.include?(num.tr(",", ""))
    end
  end
end

WARNING

エンタープライズ AI では「AI が何を言ったか」だけでなく「なぜそう言ったか」の追跡が必須だ。全ての AI 判断を AuditLog に記録し、監査・法規制対応に備える。


AI Observability と "砂利道から舗装道路へ"

本番稼働から 2 週間。ソウタは毎朝モニタリングダッシュボードを確認する習慣をつけた。

「AI システムは deploy して終わりじゃない。データの傾向、ユーザーの質問パターン、モデルの挙動 -- すべてが日々変わる」

# app/services/ai_monitoring_service.rb
class AiMonitoringService
  def daily_report
    convos = Conversation.where(created_at: 1.day.ago..)
    evals = EvalResult.where(created_at: 1.day.ago..)
 
    {
      total_queries: convos.count,
      avg_latency_ms: convos.average(:latency_ms)&.round,
      avg_faithfulness: evals.average(:faithfulness)&.round(3),
      low_confidence_rate: percentage(convos, "confidence < 0.85"),
      human_escalation_rate: percentage(convos.where(escalated_to_human: true)),
      drift_detected: detect_drift(evals)
    }
  end
 
  private
 
  def percentage(scope, condition = nil)
    total = scope.count
    return 0.0 if total.zero?
    sub = condition ? scope.where(condition) : scope
    (sub.count.to_f / total * 100).round(1)
  end
 
  def detect_drift(evals)
    return false if evals.count < 50
    recent = evals.order(created_at: :desc).limit(50).average(:faithfulness)
    baseline = EvalResult.where(created_at: 30.days.ago..7.days.ago).average(:faithfulness)
    baseline && (baseline - recent) > 0.05
  end
end

帝都生命のプロジェクトが軌道に乗った頃、カイがソウタを呼んだ。

「帝都生命で学んだことを、次の顧客にも活かせるようにしてほしい」

これが FDE の本質的な価値だった。最初の顧客デプロイは常に「砂利道」だ。でこぼこで回り道だらけ。しかし FDE は砂利道を走りながらパターンを見つけ、次の顧客のために道を舗装する。

# lib/templates/insurance_rag_template.rb
module Templates
  class InsuranceRagTemplate
    CHUNKING  = { strategy: :recursive, chunk_size: 512, overlap: 64,
                  separators: ["条", "項", "号"] }.freeze
    GUARDRAIL = { confidence_threshold: 0.85,
                  prohibited_patterns: [/確実/, /保証/, /絶対/],
                  require_human_review_for: %w[death disability] }.freeze
    EVAL_SUITE = [
      { name: "基本請求", question: "入院給付金の請求に必要な書類は?",
        expected_topics: %w[診断書 請求書 本人確認書類] },
      { name: "免責事由", question: "既往症がある場合、保険金は支払われますか?",
        expected_topics: %w[告知義務 免責期間 条件付き] }
    ].freeze
 
    def self.apply(project)
      project.configure_chunking(CHUNKING)
      project.configure_guardrails(GUARDRAIL)
      project.load_eval_suite(EVAL_SUITE)
    end
  end
end

INFO

FDE が顧客プロジェクトで得た知見をテンプレート化し、プラットフォームに還元する。これが「砂利道から舗装道路へ」パターンだ。1 社目は 3 ヶ月かかったデプロイが、3 社目には 3 週間で完了する。


Fine-tuning vs RAG vs プロンプトエンジニアリング

「全てを Fine-tuning で解決しようとする人が多いが、それは最後の手段だ」とカイは言う。

Loading diagram...
  • プロンプトエンジニアリング -- 最も手軽。出力形式の制御、トーンの調整、Few-shot 例の追加。まずここから始める
  • RAG -- 外部知識の注入。顧客固有のデータや最新情報を反映。最もコストパフォーマンスが高い
  • Fine-tuning -- モデルの振る舞い自体を変える。大量の学習データと計算リソースが必要。最後の手段

エンタープライズ向けのプロンプト設計では、ガードレールを組み込んだシステムプロンプトが鍵になる。

# app/services/prompt_builder.rb
class PromptBuilder
  def self.build_system_prompt(customer:, few_shot_examples: [])
    base = <<~PROMPT
      あなたは#{customer.name}の保険査定支援AIです。
      - 参照文書の事実のみに基づいて回答する
      - 「おそらく」「可能性があります」等の曖昧表現を使い断定を避ける
      - 最終的な査定判断は行わず参考情報の提示に留める
      - 個人情報を回答に含めない
      - 金額は参照文書を正確に引用し独自の計算をしない
    PROMPT
 
    if few_shot_examples.any?
      base += "\n## 回答例\n"
      few_shot_examples.each { |ex| base += "Q: #{ex[:question]}\nA: #{ex[:answer]}\n\n" }
    end
    base
  end
end

技術と現実の翻訳者

プロジェクト開始から 4 ヶ月。帝都生命の AI 査定アシスタントは本番稼働を迎えた。

査定担当者の一人が言った。「最初は AI なんて信用できないと思っていた。でも今は、このツールがないと仕事にならない」

Loading chart...
  • 1 件あたりの査定処理時間: 45 分から 12 分に短縮
  • 査定担当者の満足度: 60% から 93% に向上
  • AI の回答精度(Eval スコア): faithfulness 0.92, relevance 0.88

「すごいですね。でも、これは ML の精度が良かったからじゃなくて……」

「そうだ。帝都生命の業務を理解して、エッジケースを一つずつ潰して、ガードレールを設計して、Eval を回し続けた結果だ」

カイはコーヒーカップを置いて言った。

「AI 時代の FDE は、技術と現実の翻訳者だ。最先端のモデルを知っていることは前提。でも本当の価値は、そのモデルを顧客の泥臭い現実で動かし続けられることにある」

ソウタは帝都生命の査定担当者たちの顔を思い出した。最初は懐疑的だった彼らが、今では AI アシスタントを「相棒」と呼んでいる。

INFO

AI FDE に求められるのは、ML リサーチャーの深さではなく、「顧客の業務 x AI 技術 x プロダクション品質」を三位一体で回せる力だ。PoC のデモではなく、現場で毎日使われ続けるシステムを作る。それが AI 時代の FDE の仕事だ。


まとめ -- AI FDE の技術マップ

Loading chart...

AI の PoC は誰でも作れる時代になった。しかし、それを本番で動かし続け、顧客のビジネスに本当の価値を届けるには、FDE のような存在が不可欠だ。次章では、FDE のキャリアパスと成長戦略について掘り下げていく。