SpringBoot 3.5 + AI RAG実践:ベクトル検索からインテリジェントQAまでの6つのプロダクションパターン

AI与大数据

Java開発者のAIジレンマ:コードは書けるが、コードに「知識」を理解させられない

3ヶ月かけて企業ナレッジベースのLLMをトレーニングした。リリース初日、ユーザーがモデルの知らない内部用語を質問すると、モデルは自信たっぷりにでたらめを回答した。

これは冗談ではなく、2026年のJavaチームがAI実装で直面する現実だ。LLMには推論能力があるが企業データはない。データベースにはデータがあるが推論能力はない。 RAG(検索拡張生成)はこの2つを繋ぐブリッジだ。

しかし問題がある——PythonエコシステムのRAGチュートリアルは溢れているが、Javaエコシステムはほぼ空白だ。Spring AI 1.0はGAになったが、ドキュメントのRAG例はまだ「Hello World」レベルだ。プロダクション級のRAGに何が必要か? ベクトルストアの選定、ドキュメントチャンキング戦略、ハイブリッド検索、会話メモリ、パイプラインオーケストレーション、監視・アラート——すべて不可欠だ。

本記事はSpringBoot 3.5 + Spring AI 1.0に基づき、プロダクションで直接使える6つのRAGパターンを提供する。各パターンには完全で実行可能なJavaコードが付属する。

主要な収穫

  • Spring AI + PgVectorベクトルストアの完全な統合ソリューションを習得
  • ドキュメントチャンキングとエンベディング生成のベストプラクティスとパフォーマンスチューニングを理解
  • ベクトル+キーワードハイブリッド検索を実装し、リコール率を40%以上向上
  • 会話メモリ付きのマルチターンQAシステムを構築
  • RAGパイプラインオーケストレーションと本番環境デプロイ監視を学習
  • 5つの最も一般的なRAG実装の落とし穴を回避

目次


RAGアーキテクチャ概要

RAGは単なる「検索してから回答」ではなく、完全なナレッジ処理パイプラインだ:

┌─────────────────────────────────────────────────────────────────────┐
│                 RAG 完全アーキテクチャ (SpringBoot 3.5)               │
│                                                                     │
│  ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐      │
│  │ オフライン │    │          │    │          │    │          │      │
│  │ インデックス│    │          │    │          │    │          │      │
│  │ 段階      │    │          │    │          │    │          │      │
│  │ ドキュメント│───▶│ ドキュメント│───▶│ エンベディング│──▶│ ベクトル  │      │
│  │ 読み込み  │    │ チャンキング│    │ 生成     │    │ ストア    │      │
│  │ PDF/DOCX │    │ 512token │    │ OpenAI   │    │ PgVector │      │
│  │ Markdown │    │ オーバーラップ64│ │ BGE      │    │ Milvus   │      │
│  │ HTML     │    │          │    │          │    │ Chroma   │      │
│  └──────────┘    └──────────┘    └──────────┘    └──────────┘      │
│                                                                     │
│  ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐      │
│  │ オンライン │    │          │    │          │    │          │      │
│  │ クエリ    │    │          │    │          │    │          │      │
│  │ 段階      │    │          │    │          │    │          │      │
│  │ ユーザー  │───▶│ ハイブリッド│───▶│ コンテキスト│──▶│ LLM生成   │      │
│  │ 質問     │    │ 検索     │    │ 組み立て │    │ GPT-4o   │      │
│  │          │    │ Vec+BM25 │    │ プロンプト│    │ DeepSeek │      │
│  │          │    │ Rerank   │    │ テンプレート│   │ Qwen     │      │
│  └──────────┘    └──────────┘    └──────────┘    └──────────┘      │
│         │                                              │           │
│         │              ┌──────────┐                    │           │
│         └─────────────▶│ 会話メモリ │◀───────────────────┘           │
│                        │ Redis    │                                │
│                        │ Window   │                                │
│                        └──────────┘                                │
│                                                                     │
│  ┌─────────────────────────────────────────────────────────────┐    │
│  │                  オブザーバビリティ & ガバナンス層              │    │
│  │  OpenTelemetry · Prometheus · アラート · レート制限 · サーキットブレーカー │
│  └─────────────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────────────┘

なぜSpringBoot 3.5でRAGを構築するのか

特徴 SpringBoot 3.5 Python FastAPI
仮想スレッド ネイティブサポート、IO集約型スループット5倍向上 サポートなし
ベクトルストア抽象化 VectorStore統一インターフェース 各ライブラリのAPIが不統一
依存性注入 自動設定、ゼロボイラープレート 手動ライフサイクル管理
ストリーミングレスポンス WebFlux + Flux SSEの手動実装
エンタープライズセキュリティ Spring Security統合 追加ミドルウェアが必要
監視 Actuator + Micrometer 自己統合が必要

パターン1:Spring AI + PgVectorベクトルストア統合

PgVectorはPostgreSQLのベクトル拡張だ。Javaチームにとって最大の利点は運用の学習コストがゼロであること——既存のPGインスタンスに拡張を追加するだけで動作する。

1.1 環境準備

-- PostgreSQLでpgvector拡張を有効化
CREATE EXTENSION IF NOT EXISTS vector;

-- ベクトルストアテーブルを作成(Spring AIは自動作成可能、ここでは構造を示す)
CREATE TABLE vector_store (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    content TEXT NOT NULL,
    metadata JSONB DEFAULT '{}',
    embedding VECTOR(1536)  -- OpenAI text-embedding-3-smallの次元数
);

-- HNSWインデックスを作成(IVFFlatより本番環境に適している)
CREATE INDEX ON vector_store
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

1.2 Maven依存関係設定

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
        <version>1.0.0</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
        <version>1.0.0</version>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
    </dependency>
</dependencies>

1.3 アプリケーション設定

spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      base-url: ${OPENAI_BASE_URL:https://api.openai.com}
      embedding:
        options:
          model: text-embedding-3-small
    vectorstore:
      pgvector:
        index-type: HNSW
        distance-type: COSINE
        dimensions: 1536
        initialize-schema: true
  datasource:
    url: jdbc:postgresql://localhost:5432/rag_db
    username: ${PG_USERNAME}
    password: ${PG_PASSWORD}
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5

1.4 ベクトルストアサービス

@Service
public class VectorStoreService {

    private final VectorStore vectorStore;
    private final EmbeddingModel embeddingModel;

    public VectorStoreService(VectorStore vectorStore, EmbeddingModel embeddingModel) {
        this.vectorStore = vectorStore;
        this.embeddingModel = embeddingModel;
    }

    public void indexDocument(String content, Map<String, Object> metadata) {
        Document document = new Document(content, metadata);
        vectorStore.add(List.of(document));
    }

    public void indexDocuments(List<Document> documents) {
        vectorStore.add(documents);
    }

    public List<Document> search(String query, int topK) {
        return vectorStore.similaritySearch(
            SearchRequest.builder()
                .query(query)
                .topK(topK)
                .similarityThreshold(0.7)
                .build()
        );
    }

    public List<Document> searchWithFilter(String query, int topK, String filterExpression) {
        return vectorStore.similaritySearch(
            SearchRequest.builder()
                .query(query)
                .topK(topK)
                .similarityThreshold(0.7)
                .filterExpression(filterExpression)
                .build()
        );
    }

    public void deleteDocuments(List<String> ids) {
        vectorStore.delete(ids);
    }
}

1.5 REST API公開

@RestController
@RequestMapping("/api/v1/vectors")
public class VectorStoreController {

    private final VectorStoreService vectorStoreService;

    public VectorStoreController(VectorStoreService vectorStoreService) {
        this.vectorStoreService = vectorStoreService;
    }

    @PostMapping("/index")
    public ResponseEntity<String> indexDocument(@RequestBody IndexRequest request) {
        vectorStoreService.indexDocument(request.content(), request.metadata());
        return ResponseEntity.ok("Document indexed successfully");
    }

    @PostMapping("/search")
    public ResponseEntity<List<SearchResult>> search(@RequestBody SearchRequestDto request) {
        List<Document> results = vectorStoreService.search(request.query(), request.topK());
        List<SearchResult> searchResults = results.stream()
            .map(doc -> new SearchResult(
                doc.getId(),
                doc.getText(),
                doc.getMetadata(),
                (Double) doc.getMetadata().get("distance")
            ))
            .toList();
        return ResponseEntity.ok(searchResults);
    }

    @PostMapping("/search/filtered")
    public ResponseEntity<List<SearchResult>> searchWithFilter(
            @RequestBody FilteredSearchRequest request) {
        List<Document> results = vectorStoreService.searchWithFilter(
            request.query(), request.topK(), request.filterExpression()
        );
        List<SearchResult> searchResults = results.stream()
            .map(doc -> new SearchResult(
                doc.getId(),
                doc.getText(),
                doc.getMetadata(),
                (Double) doc.getMetadata().get("distance")
            ))
            .toList();
        return ResponseEntity.ok(searchResults);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteDocument(@PathVariable String id) {
        vectorStoreService.deleteDocuments(List.of(id));
        return ResponseEntity.noContent().build();
    }
}

record IndexRequest(String content, Map<String, Object> metadata) {}
record SearchRequestDto(String query, int topK) {}
record FilteredSearchRequest(String query, int topK, String filterExpression) {}
record SearchResult(String id, String content, Map<String, Object> metadata, Double distance) {}

1.6 メタデータフィルタリングの実践

Spring AIはSQLスタイルのメタデータフィルタリングをサポートしており、マルチテナントシナリオで非常に重要だ:

@Service
public class MetadataFilterService {

    private final VectorStore vectorStore;

    public MetadataFilterService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    public List<Document> searchByTenant(String query, String tenantId) {
        return vectorStore.similaritySearch(
            SearchRequest.builder()
                .query(query)
                .topK(5)
                .filterExpression("tenantId == '" + tenantId + "'")
                .build()
        );
    }

    public List<Document> searchByDepartmentAndDate(
            String query, String department, String dateAfter) {
        return vectorStore.similaritySearch(
            SearchRequest.builder()
                .query(query)
                .topK(5)
                .filterExpression(
                    "department == '" + department + "' && createdAt >= '" + dateAfter + "'"
                )
                .build()
        );
    }

    public List<Document> searchByTags(String query, List<String> tags) {
        String tagFilter = tags.stream()
            .map(tag -> "tags.contains('" + tag + "')")
            .collect(Collectors.joining(" || "));
        return vectorStore.similaritySearch(
            SearchRequest.builder()
                .query(query)
                .topK(5)
                .filterExpression(tagFilter)
                .build()
        );
    }
}

パターン2:ドキュメントチャンキングとエンベディング生成

チャンキング戦略はRAG効果の上限を直接決定する。チャンクが大きすぎると検索ノイズが増え、小さすぎるとセマンティクスが不完全になる。

2.1 チャンキング戦略の比較

戦略 適用シナリオ メリット デメリット
固定サイズチャンキング 一般的なドキュメント 実装がシンプル、パフォーマンスが安定 セマンティック境界を切断する可能性
再帰的文字チャンキング Markdown/コード 構造的境界を保持 パラメータ調整が必要
セマンティックチャンキング 高品質ドキュメント セマンティクスの完全性が最も良い 計算コストが高い
文ウィンドウチャンキング 正確なQA コンテキストが豊富 ストレージオーバーヘッドが大きい

2.2 Spring AIドキュメント処理パイプライン

@Configuration
public class DocumentProcessingConfig {

    @Bean
    public DocumentTransformer documentTransformer() {
        return new TokenTextSplitter(
            512,    // defaultChunkSize
            64,     // minChunkSizeChars
            64,     // maxNumChunks
            true,   // keepSeparator
            null    // separators カスタムセパレータ
        );
    }

    @Bean
    public DocumentReader markdownReader() {
        return new MarkdownDocumentReader(
            new ClassPathResource("docs/knowledge-base.md"),
            MarkdownDocumentReaderConfig.builder()
                .withHorizontalRuleCreateDocument(true)
                .withIncludeCodeBlock(true)
                .withIncludeBlockquote(true)
                .withAdditionalMetadata("source", "knowledge-base")
                .build()
        );
    }
}

2.3 カスタムチャンキング戦略

@Service
public class CustomChunkingService {

    private final EmbeddingModel embeddingModel;

    public CustomChunkingService(EmbeddingModel embeddingModel) {
        this.embeddingModel = embeddingModel;
    }

    public List<Document> chunkWithOverlap(String content, int chunkSize, int overlap) {
        List<Document> chunks = new ArrayList<>();
        int start = 0;
        int chunkIndex = 0;

        while (start < content.length()) {
            int end = Math.min(start + chunkSize, content.length());
            String chunkText = content.substring(start, end);

            Map<String, Object> metadata = new HashMap<>();
            metadata.put("chunkIndex", chunkIndex);
            metadata.put("startOffset", start);
            metadata.put("endOffset", end);
            metadata.put("totalChunks", (content.length() + chunkSize - 1) / chunkSize);

            chunks.add(new Document(chunkText, metadata));

            start += chunkSize - overlap;
            chunkIndex++;
        }

        return chunks;
    }

    public List<Document> chunkMarkdownByHeaders(String markdownContent) {
        List<Document> chunks = new ArrayList<>();
        String[] sections = markdownContent.split("(?=^#{1,6}\\s)");

        for (String section : sections) {
            if (section.isBlank()) continue;

            String[] lines = section.split("\n", 2);
            String header = lines[0].trim();
            String body = lines.length > 1 ? lines[1].trim() : "";

            if (body.length() > 512) {
                List<Document> subChunks = chunkWithOverlap(body, 512, 64);
                for (Document subChunk : subChunks) {
                    subChunk.getMetadata().put("sectionHeader", header);
                    chunks.add(subChunk);
                }
            } else if (!body.isEmpty()) {
                Map<String, Object> metadata = new HashMap<>();
                metadata.put("sectionHeader", header);
                chunks.add(new Document(body, metadata));
            }
        }

        return chunks;
    }

    public List<Document> chunkWithSemanticBoundary(String text, int maxChunkSize) {
        String[] sentences = text.split("(?<=[。!?.!?])");
        List<Document> chunks = new ArrayList<>();
        StringBuilder currentChunk = new StringBuilder();

        for (String sentence : sentences) {
            if (currentChunk.length() + sentence.length() > maxChunkSize
                    && currentChunk.length() > 0) {
                Map<String, Object> metadata = new HashMap<>();
                metadata.put("chunkStrategy", "semantic");
                metadata.put("sentenceCount", countSentences(currentChunk.toString()));
                chunks.add(new Document(currentChunk.toString().trim(), metadata));
                currentChunk = new StringBuilder();
            }
            currentChunk.append(sentence);
        }

        if (currentChunk.length() > 0) {
            Map<String, Object> metadata = new HashMap<>();
            metadata.put("chunkStrategy", "semantic");
            chunks.add(new Document(currentChunk.toString().trim(), metadata));
        }

        return chunks;
    }

    private int countSentences(String text) {
        return text.split("[。!?.!?]").length;
    }
}

2.4 バッチエンベディング生成とインデックス作成

@Service
public class EmbeddingIndexService {

    private static final int BATCH_SIZE = 100;
    private final VectorStore vectorStore;
    private final DocumentTransformer textSplitter;
    private final CustomChunkingService customChunkingService;

    public EmbeddingIndexService(
            VectorStore vectorStore,
            DocumentTransformer textSplitter,
            CustomChunkingService customChunkingService) {
        this.vectorStore = vectorStore;
        this.textSplitter = textSplitter;
        this.customChunkingService = customChunkingService;
    }

    @Async
    public CompletableFuture<Integer> indexFile(Resource resource, String source) {
        try {
            DocumentReader reader = createReader(resource, source);
            List<Document> documents = reader.get();
            List<Document> splitDocuments = textSplitter.apply(documents);

            for (int i = 0; i < splitDocuments.size(); i += BATCH_SIZE) {
                List<Document> batch = splitDocuments.subList(
                    i, Math.min(i + BATCH_SIZE, splitDocuments.size())
                );
                vectorStore.add(batch);
            }

            return CompletableFuture.completedFuture(splitDocuments.size());
        } catch (Exception e) {
            return CompletableFuture.failedFuture(e);
        }
    }

    public int indexMarkdownContent(String content, String source) {
        List<Document> chunks = customChunkingService.chunkMarkdownByHeaders(content);
        chunks.forEach(doc -> doc.getMetadata().putIfAbsent("source", source));

        for (int i = 0; i < chunks.size(); i += BATCH_SIZE) {
            List<Document> batch = chunks.subList(
                i, Math.min(i + BATCH_SIZE, chunks.size())
            );
            vectorStore.add(batch);
        }

        return chunks.size();
    }

    public int reindexAll(List<Resource> resources) {
        return resources.parallelStream()
            .mapToInt(resource -> {
                try {
                    DocumentReader reader = createReader(resource, resource.getFilename());
                    List<Document> documents = reader.get();
                    List<Document> splitDocuments = textSplitter.apply(documents);
                    vectorStore.add(splitDocuments);
                    return splitDocuments.size();
                } catch (Exception e) {
                    return 0;
                }
            })
            .sum();
    }

    private DocumentReader createReader(Resource resource, String source) {
        String filename = resource.getFilename();
        if (filename != null && filename.endsWith(".md")) {
            return new MarkdownDocumentReader(
                resource,
                MarkdownDocumentReaderConfig.builder()
                    .withAdditionalMetadata("source", source)
                    .build()
            );
        }
        return new TextReader(resource);
    }
}

2.5 エンベディングモデルパフォーマンス比較

モデル 次元数 速度(tokens/s) 品質(MTEB) 価格(/1M tokens)
text-embedding-3-small 1536 15000 62.3% $0.02
text-embedding-3-large 3072 8000 64.5% $0.13
BGE-M3 1024 12000 63.1% 無料(セルフホスト)
bge-large-zh-v1.5 1024 10000 64.2%(中国語) 無料(セルフホスト)

パターン3:ハイブリッド検索(ベクトル+キーワード)

純粋なベクトル検索は、固有名詞、製品番号などの完全一致シナリオでパフォーマンスが低下する。ハイブリッド検索は本番環境での必須選択肢だ。

3.1 ハイブリッド検索アーキテクチャ

┌─────────────┐
│  ユーザークエリ │
└──────┬──────┘
       │
       ├──────────────────┐
       ▼                  ▼
┌──────────────┐   ┌──────────────┐
│  ベクトル検索  │   │  キーワード検索 │
│  PgVector    │   │  Full-Text   │
│  セマンティック │   │  BM25        │
│  類似度      │   │              │
│  topK=10     │   │  topK=10     │
└──────┬───────┘   └──────┬───────┘
       │                  │
       ▼                  ▼
┌─────────────────────────────────┐
│      結果融合 & リランク           │
│   Reciprocal Rank Fusion (RRF)  │
│   または Cohere Rerank API      │
└──────────────┬──────────────────┘
               │
               ▼
        ┌──────────────┐
        │  Top-N 結果   │
        └──────────────┘

3.2 ハイブリッド検索の実装

@Service
public class HybridSearchService {

    private final VectorStore vectorStore;
    private final JdbcTemplate jdbcTemplate;
    private final ChatModel chatModel;

    public HybridSearchService(
            VectorStore vectorStore,
            JdbcTemplate jdbcTemplate,
            ChatModel chatModel) {
        this.vectorStore = vectorStore;
        this.jdbcTemplate = jdbcTemplate;
        this.chatModel = chatModel;
    }

    public List<ScoredDocument> hybridSearch(String query, int topK) {
        List<ScoredDocument> vectorResults = vectorSearch(query, topK * 2);
        List<ScoredDocument> keywordResults = keywordSearch(query, topK * 2);
        List<ScoredDocument> fused = reciprocalRankFusion(vectorResults, keywordResults);
        return fused.stream().limit(topK).toList();
    }

    private List<ScoredDocument> vectorSearch(String query, int topK) {
        List<Document> docs = vectorStore.similaritySearch(
            SearchRequest.builder()
                .query(query)
                .topK(topK)
                .similarityThreshold(0.5)
                .build()
        );
        return docs.stream()
            .map(doc -> new ScoredDocument(
                doc.getId(),
                doc.getText(),
                doc.getMetadata(),
                1.0 - (Double) doc.getMetadata().getOrDefault("distance", 1.0)
            ))
            .toList();
    }

    private List<ScoredDocument> keywordSearch(String query, int topK) {
        String sql = """
            SELECT id, content, metadata,
                   ts_rank_cd(to_tsvector('simple', content),
                              plainto_tsquery('simple', ?)) AS rank
            FROM vector_store
            WHERE to_tsvector('simple', content) @@ plainto_tsquery('simple', ?)
            ORDER BY rank DESC
            LIMIT ?
            """;

        return jdbcTemplate.query(sql, (rs, rowNum) -> {
            Map<String, Object> metadata = new HashMap<>();
            try {
                String metadataJson = rs.getString("metadata");
                metadata = new ObjectMapper().readValue(metadataJson, new TypeReference<>() {});
            } catch (Exception ignored) {}

            return new ScoredDocument(
                rs.getString("id"),
                rs.getString("content"),
                metadata,
                rs.getDouble("rank")
            );
        }, query, query, topK);
    }

    private List<ScoredDocument> reciprocalRankFusion(
            List<ScoredDocument> vectorResults,
            List<ScoredDocument> keywordResults) {
        int k = 60;
        Map<String, ScoredDocument> docMap = new LinkedHashMap<>();
        Map<String, Double> scoreMap = new HashMap<>();

        for (int i = 0; i < vectorResults.size(); i++) {
            ScoredDocument doc = vectorResults.get(i);
            scoreMap.merge(doc.id(), 1.0 / (k + i + 1), Double::sum);
            docMap.putIfAbsent(doc.id(), doc);
        }

        for (int i = 0; i < keywordResults.size(); i++) {
            ScoredDocument doc = keywordResults.get(i);
            scoreMap.merge(doc.id(), 1.0 / (k + i + 1), Double::sum);
            docMap.putIfAbsent(doc.id(), doc);
        }

        return scoreMap.entrySet().stream()
            .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
            .map(entry -> {
                ScoredDocument doc = docMap.get(entry.getKey());
                return new ScoredDocument(doc.id(), doc.text(), doc.metadata(), entry.getValue());
            })
            .toList();
    }

    public String askWithHybridSearch(String question) {
        List<ScoredDocument> results = hybridSearch(question, 5);
        String context = results.stream()
            .map(ScoredDocument::text)
            .collect(Collectors.joining("\n\n---\n\n"));

        String prompt = """
            以下の参考資料に基づいてユーザーの質問に答えてください。
            参考資料に関連情報がない場合は、明確にその旨を伝えてください。

            参考資料:
            %s

            ユーザーの質問:%s

            正確で完全な回答を提供し、情報源を引用してください。
            """.formatted(context, question);

        return chatModel.call(prompt);
    }
}

record ScoredDocument(String id, String text, Map<String, Object> metadata, double score) {}

3.3 全文検索インデックス設定

-- PgVectorテーブルに全文検索サポートを追加
ALTER TABLE vector_store ADD COLUMN IF NOT EXISTS tsv tsvector
    GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED;

CREATE INDEX IF NOT EXISTS idx_vector_store_tsv ON vector_store USING GIN(tsv);

-- 日本語全文検索にはpg_trgm拡張を使用
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX IF NOT EXISTS idx_vector_store_content_trgm ON vector_store
    USING GIN (content gin_trgm_ops);

3.4 クエリリライトによるリコール向上

@Service
public class QueryRewriteService {

    private final ChatModel chatModel;

    public QueryRewriteService(ChatModel chatModel) {
        this.chatModel = chatModel;
    }

    public List<String> rewriteQuery(String originalQuery) {
        String rewritePrompt = """
            ユーザーが以下の質問をしました。検索リコール率を向上させるため、
            意味的に同等だが表現の異なる3つの言い換えバージョンを生成してください。
            1行に1つ、番号なしで出力してください。

            元の質問:%s
            """.formatted(originalQuery);

        String response = chatModel.call(rewritePrompt);
        List<String> rewrites = Arrays.stream(response.split("\n"))
            .map(String::trim)
            .filter(line -> !line.isEmpty())
            .toList();

        List<String> allQueries = new ArrayList<>();
        allQueries.add(originalQuery);
        allQueries.addAll(rewrites);
        return allQueries;
    }

    public String expandWithSynonyms(String query) {
        String synonymPrompt = """
            以下のクエリからキーエンティティと同義語を抽出し、
            検索範囲を拡大してください。形式:1行に1つのキーワードまたは同義語。

            クエリ:%s
            """.formatted(query);

        return chatModel.call(synonymPrompt);
    }
}

パターン4:会話メモリとマルチターンQA

シングルターンQAは単なるおもちゃだ。プロダクション級のRAGはマルチターン会話をサポートし、コンテキスト内の照応や省略を理解できなければならない。

4.1 会話メモリアーキテクチャ

┌──────────┐     ┌──────────────┐     ┌──────────┐
│  ユーザー   │────▶│  コンテキスト  │────▶│  LLM     │
│  メッセージ │     │  マネージャー  │     │  生成    │
│  Nターン目 │     │  ウィンドウ/  │     │          │
└──────────┘     │  サマリー    │     └──────────┘
                 └──────────────┘
                       ▲       │
                       │       ▼
                 ┌──────────────────┐
                 │  会話履歴ストア    │
                 │  Redis / PG      │
                 └──────────────────┘

4.2 Redisベースの会話メモリ

@Configuration
public class ChatMemoryConfig {

    @Bean
    public ChatMemory chatMemory(RedisTemplate<String, String> redisTemplate) {
        return new RedisChatMemory(redisTemplate, 20);
    }
}

public class RedisChatMemory implements ChatMemory {

    private static final String KEY_PREFIX = "chat:memory:";
    private final RedisTemplate<String, String> redisTemplate;
    private final int maxMessages;

    public RedisChatMemory(RedisTemplate<String, String> redisTemplate, int maxMessages) {
        this.redisTemplate = redisTemplate;
        this.maxMessages = maxMessages;
    }

    @Override
    public void add(String conversationId, List<Message> messages) {
        String key = KEY_PREFIX + conversationId;
        for (Message message : messages) {
            String serialized = serializeMessage(message);
            redisTemplate.opsForList().rightPush(key, serialized);
        }
        redisTemplate.opsForList().trim(key, -maxMessages, -1);
        redisTemplate.expire(key, Duration.ofHours(24));
    }

    @Override
    public List<Message> get(String conversationId, int lastN) {
        String key = KEY_PREFIX + conversationId;
        List<String> rawMessages = redisTemplate.opsForList().range(key, -lastN, -1);
        if (rawMessages == null || rawMessages.isEmpty()) {
            return List.of();
        }
        return rawMessages.stream()
            .map(this::deserializeMessage)
            .toList();
    }

    @Override
    public void clear(String conversationId) {
        redisTemplate.delete(KEY_PREFIX + conversationId);
    }

    private String serializeMessage(Message message) {
        try {
            Map<String, String> map = Map.of(
                "role", message.getMessageType().getValue(),
                "content", message.getText()
            );
            return new ObjectMapper().writeValueAsString(map);
        } catch (Exception e) {
            throw new RuntimeException("Failed to serialize message", e);
        }
    }

    private Message deserializeMessage(String json) {
        try {
            Map<String, String> map = new ObjectMapper().readValue(json, new TypeReference<>() {});
            return switch (map.get("role")) {
                case "user" -> new UserMessage(map.get("content"));
                case "assistant" -> new AssistantMessage(map.get("content"));
                case "system" -> new SystemMessage(map.get("content"));
                default -> new UserMessage(map.get("content"));
            };
        } catch (Exception e) {
            throw new RuntimeException("Failed to deserialize message", e);
        }
    }
}

4.3 マルチターンRAG QAサービス

@Service
public class ConversationalRagService {

    private final ChatClient chatClient;
    private final VectorStore vectorStore;
    private final ChatMemory chatMemory;

    public ConversationalRagService(
            ChatClient chatClient,
            VectorStore vectorStore,
            ChatMemory chatMemory) {
        this.chatClient = chatClient;
        this.vectorStore = vectorStore;
        this.chatMemory = chatMemory;
    }

    public String chat(String conversationId, String userMessage) {
        List<Message> history = chatMemory.get(conversationId, 10);

        String contextualizedQuery = buildContextualizedQuery(userMessage, history);

        List<Document> relevantDocs = vectorStore.similaritySearch(
            SearchRequest.builder()
                .query(contextualizedQuery)
                .topK(5)
                .similarityThreshold(0.6)
                .build()
        );

        String context = relevantDocs.stream()
            .map(Document::getText)
            .collect(Collectors.joining("\n\n"));

        String systemPrompt = """
            あなたはプロフェッショナルなナレッジベースアシスタントです。
            提供された参考資料に基づいてユーザーの質問に答えてください。

            ルール:
            1. 参考資料に基づいてのみ回答し、情報を捏造しないでください
            2. 参考資料が不十分な場合は、ユーザーに明確に伝えてください
            3. 具体的な情報源を引用してください
            4. 回答は簡潔かつ正確に保ってください

            参考資料:
            %s
            """.formatted(context);

        String response = chatClient.prompt()
            .system(systemPrompt)
            .messages(history)
            .user(userMessage)
            .call()
            .content();

        chatMemory.add(conversationId, List.of(
            new UserMessage(userMessage),
            new AssistantMessage(response)
        ));

        return response;
    }

    private String buildContextualizedQuery(String currentQuery, List<Message> history) {
        if (history.isEmpty()) {
            return currentQuery;
        }

        String historySummary = history.stream()
            .map(msg -> msg.getMessageType().getValue() + ": " + msg.getText())
            .collect(Collectors.joining("\n"));

        String condensePrompt = """
            会話履歴と現在の質問に基づいて、完全なコンテキストを含む
            スタンドアロンのクエリを生成してください。
            書き直したクエリのみを出力してください。

            会話履歴:
            %s

            現在の質問:%s
            """.formatted(historySummary, currentQuery);

        return chatClient.prompt()
            .user(condensePrompt)
            .call()
            .content();
    }
}

4.4 ストリーミングマルチターン会話

@RestController
@RequestMapping("/api/v1/chat")
public class StreamingChatController {

    private final StreamingRagService streamingRagService;

    public StreamingChatController(StreamingRagService streamingRagService) {
        this.streamingRagService = streamingRagService;
    }

    @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> streamChat(@RequestBody ChatRequest request) {
        return streamingRagService.streamChat(request.conversationId(), request.message());
    }
}

@Service
public class StreamingRagService {

    private final ChatClient chatClient;
    private final VectorStore vectorStore;

    public StreamingRagService(ChatClient chatClient, VectorStore vectorStore) {
        this.chatClient = chatClient;
        this.vectorStore = vectorStore;
    }

    public Flux<String> streamChat(String conversationId, String userMessage) {
        List<Document> docs = vectorStore.similaritySearch(
            SearchRequest.builder()
                .query(userMessage)
                .topK(5)
                .similarityThreshold(0.6)
                .build()
        );

        String context = docs.stream()
            .map(Document::getText)
            .collect(Collectors.joining("\n\n"));

        return chatClient.prompt()
            .system("以下の参考資料に基づいて回答してください:\n" + context)
            .user(userMessage)
            .stream()
            .content();
    }
}

パターン5:RAGパイプラインオーケストレーション

プロダクション級のRAGは単一のAPIコールではなく、オーケストレーション可能で、観測可能で、デグラデーション可能なパイプラインだ。

5.1 パイプラインアーキテクチャ

┌──────────────────────────────────────────────────────────────┐
│                    RAG Pipeline Orchestration                 │
│                                                              │
│  Query ──▶ [Rewrite] ──▶ [Retrieve] ──▶ [Rerank] ──▶ [Generate] │
│              │              │             │            │      │
│              ▼              ▼             ▼            ▼      │
│           [Cache]       [Fallback]    [Score]     [Guard]    │
│              │              │             │            │      │
│              └──────────────┴─────────────┴────────────┘      │
│                             │                                 │
│                             ▼                                 │
│                     [Observability]                           │
│              Tracing · Metrics · Logging                      │
└──────────────────────────────────────────────────────────────┘

5.2 パイプライン定義と実行

public interface RagPipelineStep {
    String getName();
    StepResult execute(StepContext context);
    default int getOrder() { return 0; }
    default boolean isEnabled() { return true; }
}

public record StepResult(boolean success, Map<String, Object> data, String error) {
    public static StepResult success(Map<String, Object> data) {
        return new StepResult(true, data, null);
    }

    public static StepResult failure(String error) {
        return new StepResult(false, Map.of(), error);
    }
}

public class StepContext {
    private final Map<String, Object> data = new ConcurrentHashMap<>();
    private String originalQuery;
    private String rewrittenQuery;
    private List<Document> retrievedDocuments;
    private List<ScoredDocument> rerankedDocuments;
    private String generatedAnswer;
    private long startTimeMs;

    public StepContext(String query) {
        this.originalQuery = query;
        this.startTimeMs = System.currentTimeMillis();
    }

    public Map<String, Object> getData() { return data; }
    public String getOriginalQuery() { return originalQuery; }
    public void setRewrittenQuery(String q) { this.rewrittenQuery = q; }
    public String getEffectiveQuery() { return rewrittenQuery != null ? rewrittenQuery : originalQuery; }
    public void setRetrievedDocuments(List<Document> docs) { this.retrievedDocuments = docs; }
    public List<Document> getRetrievedDocuments() { return retrievedDocuments; }
    public void setRerankedDocuments(List<ScoredDocument> docs) { this.rerankedDocuments = docs; }
    public List<ScoredDocument> getRerankedDocuments() { return rerankedDocuments; }
    public void setGeneratedAnswer(String answer) { this.generatedAnswer = answer; }
    public String getGeneratedAnswer() { return generatedAnswer; }
    public long getElapsedTimeMs() { return System.currentTimeMillis() - startTimeMs; }
}

5.3 具体的なステップ実装

@Component
public class QueryRewriteStep implements RagPipelineStep {

    private final ChatModel chatModel;

    public QueryRewriteStep(ChatModel chatModel) {
        this.chatModel = chatModel;
    }

    @Override
    public String getName() { return "query-rewrite"; }

    @Override
    public int getOrder() { return 1; }

    @Override
    public StepResult execute(StepContext context) {
        String query = context.getOriginalQuery();
        String rewritePrompt = """
            以下のクエリを検索に適した形式に書き直してください。
            コアセマンティクスを保持し、必要なコンテキストを補完してください。
            書き直したクエリのみを出力してください。

            元のクエリ:%s
            """.formatted(query);

        String rewritten = chatModel.call(rewritePrompt);
        context.setRewrittenQuery(rewritten.trim());

        return StepResult.success(Map.of(
            "originalQuery", query,
            "rewrittenQuery", rewritten.trim()
        ));
    }
}

@Component
public class VectorRetrieveStep implements RagPipelineStep {

    private final VectorStore vectorStore;

    public VectorRetrieveStep(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    @Override
    public String getName() { return "vector-retrieve"; }

    @Override
    public int getOrder() { return 2; }

    @Override
    public StepResult execute(StepContext context) {
        List<Document> docs = vectorStore.similaritySearch(
            SearchRequest.builder()
                .query(context.getEffectiveQuery())
                .topK(10)
                .similarityThreshold(0.5)
                .build()
        );

        context.setRetrievedDocuments(docs);

        return StepResult.success(Map.of(
            "documentCount", docs.size(),
            "query", context.getEffectiveQuery()
        ));
    }
}

@Component
public class RerankStep implements RagPipelineStep {

    private final ChatModel chatModel;

    public RerankStep(ChatModel chatModel) {
        this.chatModel = chatModel;
    }

    @Override
    public String getName() { return "rerank"; }

    @Override
    public int getOrder() { return 3; }

    @Override
    public StepResult execute(StepContext context) {
        List<Document> docs = context.getRetrievedDocuments();
        if (docs == null || docs.isEmpty()) {
            return StepResult.failure("No documents to rerank");
        }

        String query = context.getEffectiveQuery();
        List<ScoredDocument> scored = docs.stream()
            .map(doc -> {
                double relevanceScore = computeRelevance(query, doc.getText());
                return new ScoredDocument(doc.getId(), doc.getText(), doc.getMetadata(), relevanceScore);
            })
            .sorted(Comparator.comparingDouble(ScoredDocument::score).reversed())
            .limit(5)
            .toList();

        context.setRerankedDocuments(scored);

        return StepResult.success(Map.of("rerankedCount", scored.size()));
    }

    private double computeRelevance(String query, String documentText) {
        Set<String> queryTerms = Arrays.stream(query.toLowerCase().split("\\s+"))
            .collect(Collectors.toSet());
        Set<String> docTerms = Arrays.stream(documentText.toLowerCase().split("\\s+"))
            .collect(Collectors.toSet());
        long overlap = queryTerms.stream().filter(docTerms::contains).count();
        return (double) overlap / queryTerms.size();
    }
}

@Component
public class GenerateStep implements RagPipelineStep {

    private final ChatClient chatClient;

    public GenerateStep(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    @Override
    public String getName() { return "generate"; }

    @Override
    public int getOrder() { return 4; }

    @Override
    public StepResult execute(StepContext context) {
        List<ScoredDocument> docs = context.getRerankedDocuments();
        if (docs == null || docs.isEmpty()) {
            return StepResult.failure("No documents available for generation");
        }

        String contextText = docs.stream()
            .map(ScoredDocument::text)
            .collect(Collectors.joining("\n\n---\n\n"));

        String answer = chatClient.prompt()
            .system("""
                あなたはプロフェッショナルなナレッジベースアシスタントです。
                参考資料に基づいて回答してください。資料が不十分な場合は明確に伝えてください。
                具体的な情報源を引用してください。

                参考資料:
                %s
                """.formatted(contextText))
            .user(context.getOriginalQuery())
            .call()
            .content();

        context.setGeneratedAnswer(answer);

        return StepResult.success(Map.of("answerLength", answer.length()));
    }
}

5.4 パイプラインオーケストレーター

@Service
public class RagPipelineOrchestrator {

    private final List<RagPipelineStep> steps;
    private final MeterRegistry meterRegistry;

    public RagPipelineOrchestrator(
            List<RagPipelineStep> steps,
            MeterRegistry meterRegistry) {
        this.steps = steps.stream()
            .filter(RagPipelineStep::isEnabled)
            .sorted(Comparator.comparingInt(RagPipelineStep::getOrder))
            .toList();
        this.meterRegistry = meterRegistry;
    }

    public RagPipelineResult execute(String query) {
        StepContext context = new StepContext(query);
        List<StepExecutionRecord> records = new ArrayList<>();

        for (RagPipelineStep step : steps) {
            long stepStart = System.currentTimeMillis();
            try {
                StepResult result = step.execute(context);
                long stepDuration = System.currentTimeMillis() - stepStart;

                records.add(new StepExecutionRecord(
                    step.getName(), true, stepDuration, result.error()
                ));

                meterRegistry.counter("rag.pipeline.step.success",
                    "step", step.getName()).increment();
                meterRegistry.timer("rag.pipeline.step.duration",
                    "step", step.getName())
                    .record(stepDuration, TimeUnit.MILLISECONDS);

                if (!result.success()) {
                    break;
                }
            } catch (Exception e) {
                long stepDuration = System.currentTimeMillis() - stepStart;
                records.add(new StepExecutionRecord(
                    step.getName(), false, stepDuration, e.getMessage()
                ));
                meterRegistry.counter("rag.pipeline.step.failure",
                    "step", step.getName()).increment();
                break;
            }
        }

        return new RagPipelineResult(
            context.getGeneratedAnswer(),
            context.getElapsedTimeMs(),
            records
        );
    }
}

record StepExecutionRecord(String stepName, boolean success, long durationMs, String error) {}
record RagPipelineResult(String answer, long totalDurationMs, List<StepExecutionRecord> steps) {}

パターン6:本番環境デプロイと監視

RAGアプリケーションのリリース後の運用複雑さは、一般的なCRUDサービスをはるかに超える。専用の監視とデグラデーション戦略が不可欠だ。

6.1 Docker Composeデプロイ

version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/rag_db
      - SPRING_DATASOURCE_USERNAME=rag_user
      - SPRING_DATASOURCE_PASSWORD=${PG_PASSWORD}
      - SPRING_DATA_REDIS_HOST=redis
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 30s
      timeout: 10s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 1G
          cpus: '2'

  postgres:
    image: pgvector/pgvector:pg16
    environment:
      - POSTGRES_DB=rag_db
      - POSTGRES_USER=rag_user
      - POSTGRES_PASSWORD=${PG_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U rag_user -d rag_db"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml

  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}

volumes:
  pgdata:

6.2 RAG専用監視メトリクス

@Configuration
public class RagMetricsConfig {

    @Bean
    public MeterRegistryCustomizer<MeterRegistry> ragMetrics() {
        return registry -> registry.config()
            .meterFilter(MeterFilter.deny(id ->
                id.getName().startsWith("jvm.") || id.getName().startsWith("process.")
            ));
    }
}

@Service
public class RagMetricsService {

    private final MeterRegistry meterRegistry;

    public RagMetricsService(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }

    public void recordRetrievalLatency(long durationMs, String storeType) {
        meterRegistry.timer("rag.retrieval.latency", "store", storeType)
            .record(durationMs, TimeUnit.MILLISECONDS);
    }

    public void recordEmbeddingLatency(long durationMs, int tokenCount) {
        meterRegistry.timer("rag.embedding.latency")
            .record(durationMs, TimeUnit.MILLISECONDS);
        meterRegistry.counter("rag.embedding.tokens").increment(tokenCount);
    }

    public void recordGenerationLatency(long durationMs, int inputTokens, int outputTokens) {
        meterRegistry.timer("rag.generation.latency")
            .record(durationMs, TimeUnit.MILLISECONDS);
        meterRegistry.counter("rag.generation.input.tokens").increment(inputTokens);
        meterRegistry.counter("rag.generation.output.tokens").increment(outputTokens);
    }

    public void recordRetrievalQuality(int retrievedCount, double avgSimilarity) {
        meterRegistry.gauge("rag.retrieval.document.count", retrievedCount);
        meterRegistry.gauge("rag.retrieval.avg.similarity", avgSimilarity);
    }

    public void recordCacheHit(boolean hit) {
        meterRegistry.counter("rag.cache",
            "result", hit ? "hit" : "miss").increment();
    }
}

6.3 デグラデーションとサーキットブレーカー

@Configuration
public class ResilienceConfig {

    @Bean
    public CircuitBreaker ragCircuitBreaker() {
        CircuitBreakerConfig config = CircuitBreakerConfig.custom()
            .failureRateThreshold(50)
            .waitDurationInOpenState(Duration.ofSeconds(30))
            .slidingWindowSize(10)
            .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
            .build();

        return CircuitBreaker.of("ragCircuitBreaker", config);
    }
}

@Service
public class ResilientRagService {

    private final VectorStore vectorStore;
    private final ChatClient chatClient;
    private final CircuitBreaker circuitBreaker;
    private final RagMetricsService metricsService;

    public ResilientRagService(
            VectorStore vectorStore,
            ChatClient chatClient,
            CircuitBreaker circuitBreaker,
            RagMetricsService metricsService) {
        this.vectorStore = vectorStore;
        this.chatClient = chatClient;
        this.circuitBreaker = circuitBreaker;
        this.metricsService = metricsService;
    }

    public String ask(String question) {
        return circuitBreaker.executeSupplier(() -> {
            try {
                long start = System.currentTimeMillis();

                List<Document> docs = vectorStore.similaritySearch(
                    SearchRequest.builder()
                        .query(question)
                        .topK(5)
                        .similarityThreshold(0.6)
                        .build()
                );

                metricsService.recordRetrievalLatency(
                    System.currentTimeMillis() - start, "pgvector"
                );

                if (docs.isEmpty()) {
                    return "申し訳ありませんが、ナレッジベースに関連情報が見つかりませんでした。別の表現でお試しください。";
                }

                String context = docs.stream()
                    .map(Document::getText)
                    .collect(Collectors.joining("\n\n"));

                long genStart = System.currentTimeMillis();
                String answer = chatClient.prompt()
                    .system("参考資料に基づいて回答してください:\n" + context)
                    .user(question)
                    .call()
                    .content();
                metricsService.recordGenerationLatency(
                    System.currentTimeMillis() - genStart, 0, 0
                );

                return answer;

            } catch (Exception e) {
                metricsService.recordRetrievalLatency(-1, "error");
                return getFallbackAnswer(question);
            }
        });
    }

    private String getFallbackAnswer(String question) {
        return """
            申し訳ありませんが、AIサービスは一時的に利用できません。原因として以下が考えられます:
            1. ベクトルデータベースの接続タイムアウト
            2. LLMサービスの過負荷
            3. ネットワークの変動

            後でもう一度お試しいただくか、テクニカルサポートにお問い合わせください。
            """;
    }
}

6.4 Prometheusアラートルール

groups:
  - name: rag-alerts
    rules:
      - alert: RAGRetrievalLatencyHigh
        expr: histogram_quantile(0.95, rag_retrieval_latency_seconds) > 2
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "RAG検索レイテンシが高すぎます"
          description: "P95検索レイテンシが2秒を超えています。現在の値: {{ $value }}s"

      - alert: RAGGenerationLatencyHigh
        expr: histogram_quantile(0.95, rag_generation_latency_seconds) > 10
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "RAG生成レイテンシが高すぎます"
          description: "P95生成レイテンシが10秒を超えています"

      - alert: RAGCircuitBreakerOpen
        expr: resilience4j_circuitbreaker_state{name="ragCircuitBreaker",state="open"} == 1
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "RAGサーキットブレーカーがオープンです"
          description: "RAGサービスのサーキットブレーカーがOPEN状態です。すべてのリクエストはフォールバックパスを使用します"

      - alert: RAGEmbeddingErrorRateHigh
        expr: rate(rag_pipeline_step_failure_total{step="vector-retrieve"}[5m]) > 0.1
        for: 3m
        labels:
          severity: critical
        annotations:
          summary: "ベクトル検索エラー率が高すぎます"

6.5 Kubernetesデプロイ設定

apiVersion: apps/v1
kind: Deployment
metadata:
  name: rag-service
  labels:
    app: rag-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: rag-service
  template:
    metadata:
      labels:
        app: rag-service
    spec:
      containers:
        - name: rag-service
          image: registry.example.com/rag-service:latest
          ports:
            - containerPort: 8080
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: "prod"
            - name: OPENAI_API_KEY
              valueFrom:
                secretKeyRef:
                  name: rag-secrets
                  key: openai-api-key
          resources:
            requests:
              memory: "512Mi"
              cpu: "500m"
            limits:
              memory: "1Gi"
              cpu: "2000m"
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 60
            periodSeconds: 30
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
  name: rag-service
spec:
  selector:
    app: rag-service
  ports:
    - port: 80
      targetPort: 8080
  type: ClusterIP

5つのよくある落とし穴と解決策

落とし穴1:エンベディング次元の不一致

現象ERROR: expected 1536 dimensions, not 768

原因:エンベディングモデルを切り替えたが、ベクトルテーブルの次元設定を更新していない

解決策

@Configuration
public class EmbeddingDimensionGuard {

    @Value("${spring.ai.openai.embedding.options.model:text-embedding-3-small}")
    private String embeddingModel;

    @Bean
    public ApplicationRunner validateDimensions(JdbcTemplate jdbcTemplate) {
        return args -> {
            int expectedDim = getExpectedDimension(embeddingModel);
            Integer actualDim = jdbcTemplate.queryForObject(
                "SELECT atttypmod FROM pg_attribute WHERE attrelid = 'vector_store'::regclass AND attname = 'embedding'",
                Integer.class
            );
            if (actualDim != null && actualDim != expectedDim + 4) {
                throw new IllegalStateException(
                    "Embedding dimension mismatch! Expected: " + expectedDim +
                    ", Actual: " + (actualDim - 4)
                );
            }
        };
    }

    private int getExpectedDimension(String model) {
        return switch (model) {
            case "text-embedding-3-small" -> 1536;
            case "text-embedding-3-large" -> 3072;
            case "text-embedding-ada-002" -> 1536;
            default -> 1536;
        };
    }
}

落とし穴2:大規模ドキュメントのOOM

現象:100MBのPDFをロードするとJVMがOOM

原因:DocumentReaderがドキュメント全体を一度にメモリにロードする

解決策

@Service
public class SafeDocumentLoader {

    private static final long MAX_FILE_SIZE = 50 * 1024 * 1024;

    public List<Document> loadSafely(Resource resource) {
        try {
            if (resource.contentLength() > MAX_FILE_SIZE) {
                return loadInChunks(resource);
            }
            DocumentReader reader = new TextReader(resource);
            return reader.get();
        } catch (IOException e) {
            throw new RuntimeException("Failed to load document", e);
        }
    }

    private List<Document> loadInChunks(Resource resource) {
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
            List<Document> allChunks = new ArrayList<>();
            StringBuilder buffer = new StringBuilder();
            String line;

            while ((line = reader.readLine()) != null) {
                buffer.append(line).append("\n");
                if (buffer.length() > 100000) {
                    Map<String, Object> metadata = Map.of(
                        "source", resource.getFilename(),
                        "chunkStrategy", "streaming"
                    );
                    allChunks.add(new Document(buffer.toString(), metadata));
                    buffer = new StringBuilder();
                }
            }

            if (buffer.length() > 0) {
                allChunks.add(new Document(buffer.toString(),
                    Map.of("source", resource.getFilename())));
            }

            return allChunks;
        } catch (IOException e) {
            throw new RuntimeException("Failed to stream document", e);
        }
    }
}

落とし穴3:検索結果がすべて無関係

現象:類似度閾値0.7以下で大量のノイズが返される

原因:閾値設定が不適切 + エンベディングモデルの特定言語サポートが不十分

解決策

spring:
  ai:
    vectorstore:
      pgvector:
        distance-type: COSINE
@Service
public class AdaptiveThresholdService {

    private final VectorStore vectorStore;

    public AdaptiveThresholdService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    public List<Document> searchWithAdaptiveThreshold(String query, int topK) {
        double[] thresholds = {0.8, 0.7, 0.6, 0.5};

        for (double threshold : thresholds) {
            List<Document> results = vectorStore.similaritySearch(
                SearchRequest.builder()
                    .query(query)
                    .topK(topK)
                    .similarityThreshold(threshold)
                    .build()
            );
            if (!results.isEmpty()) {
                return results;
            }
        }

        return List.of();
    }
}

落とし穴4:並行インデックスによる重複ベクトル

現象:同じドキュメントが複数回インデックスされ、検索結果に重複が発生

原因:冪等性の保証がない

解決策

@Service
public class IdempotentIndexService {

    private final VectorStore vectorStore;
    private final JdbcTemplate jdbcTemplate;

    public IdempotentIndexService(VectorStore vectorStore, JdbcTemplate jdbcTemplate) {
        this.vectorStore = vectorStore;
        this.jdbcTemplate = jdbcTemplate;
    }

    @Transactional
    public void indexWithDedup(List<Document> documents, String sourceId) {
        jdbcTemplate.update(
            "DELETE FROM vector_store WHERE metadata->>'sourceId' = ?",
            sourceId
        );

        documents.forEach(doc ->
            doc.getMetadata().put("sourceId", sourceId)
        );

        vectorStore.add(documents);
    }
}

落とし穴5:LLMハルシネーションの制御不能

現象:ナレッジベースに関連情報がない場合でもモデルが回答を捏造する

原因:System Promptの制約が不十分 + グラウンディング検証の欠如

解決策

@Service
public class GroundedRagService {

    private final ChatClient chatClient;
    private final VectorStore vectorStore;

    public GroundedRagService(ChatClient chatClient, VectorStore vectorStore) {
        this.chatClient = chatClient;
        this.vectorStore = vectorStore;
    }

    public GroundedAnswer askWithGrounding(String question) {
        List<Document> docs = vectorStore.similaritySearch(
            SearchRequest.builder()
                .query(question)
                .topK(5)
                .similarityThreshold(0.65)
                .build()
        );

        if (docs.isEmpty()) {
            return new GroundedAnswer(
                "ナレッジベースに関連情報が見つかりませんでした。この質問には回答できません。",
                false,
                List.of()
            );
        }

        String context = docs.stream()
            .map(doc -> "[情報源:" + doc.getMetadata().get("source") + "]\n" + doc.getText())
            .collect(Collectors.joining("\n\n"));

        String systemPrompt = """
            厳格なルール:
            1. 提供された参考資料に基づいてのみ回答してください
            2. すべての事実的記述には情報源番号[情報源:xxx]を引用してください
            3. 参考資料が不十分な場合は、どの部分に根拠がないかを明確に示してください
            4. 参考資料にない情報を絶対に捏造しないでください

            参考資料:
            %s
            """.formatted(context);

        String answer = chatClient.prompt()
            .system(systemPrompt)
            .user(question)
            .call()
            .content();

        boolean isGrounded = validateGrounding(answer, docs);

        return new GroundedAnswer(answer, isGrounded,
            docs.stream().map(d -> (String) d.getMetadata().get("source")).toList());
    }

    private boolean validateGrounding(String answer, List<Document> sources) {
        String sourceTexts = sources.stream()
            .map(Document::getText)
            .collect(Collectors.joining(" "));

        String validationPrompt = """
            以下の回答が与えられた参考資料に完全に基づいているかどうかを判断してください。
            YES または NO のみで答えてください。

            参考資料:%s

            回答:%s
            """.formatted(sourceTexts.substring(0, Math.min(2000, sourceTexts.length())),
                          answer);

        String result = chatClient.prompt()
            .user(validationPrompt)
            .call()
            .content();

        return result.trim().toUpperCase().startsWith("YES");
    }
}

record GroundedAnswer(String answer, boolean isGrounded, List<String> sources) {}

10のよくあるエラートラブルシューティング

# エラーメッセージ 原因 解決策
1 ERROR: operator does not exist: vector <=> vector pgvector拡張がインストールされていない CREATE EXTENSION vector; を実行しアプリを再起動
2 EmbeddingModel bean not found embedding starter依存関係が不足 spring-ai-openai-spring-boot-starter を追加
3 Connection refused: localhost:5432 PostgreSQLが起動していない docker compose up -d postgres を実行
4 429 Too Many Requests OpenAI APIのレート制限 レートリミッターを追加、並行度を下げる、ローカルモデルを使用
5 expected 1536 dimensions, not 768 エンベディングモデルの次元不一致 エンベディングモデルを統一するかベクトルテーブルを再構築
6 OutOfMemoryError: Java heap space 大規模ドキュメントを一括ロード ストリーミングローダーを使用、ファイルサイズを制限
7 CircuitBreaker 'ragCircuitBreaker' is OPEN 下流サービスの継続的障害 LLM/ベクトルストアの接続性を確認、サーキット回復を待機
8 RedisConnectionFailureException Redisが利用不可 Redisの健全性を確認、インメモリメモリにデグレード
9 Empty search results for threshold 0.8 類似度閾値が高すぎる 閾値を下げるかアダプティブ閾値戦略を使用
10 JsonProcessingException: metadata メタデータJSONフォーマットエラー メタデータフィールドを確認、シリアライズ可能性を確保

高度な最適化のヒント

1. キャッシュ層:重複エンベディング計算の削減

@Service
public class EmbeddingCacheService {

    private final Cache<String, float[]> embeddingCache;
    private final EmbeddingModel embeddingModel;

    public EmbeddingCacheService(EmbeddingModel embeddingModel) {
        this.embeddingModel = embeddingModel;
        this.embeddingCache = Caffeine.newBuilder()
            .maximumSize(10000)
            .expireAfterWrite(Duration.ofHours(24))
            .build();
    }

    public float[] getEmbedding(String text) {
        String cacheKey = DigestUtils.md5Hex(text);
        return embeddingCache.get(cacheKey, key -> {
            float[] embedding = embeddingModel.embed(text);
            return embedding;
        });
    }

    public void preloadCache(List<String> texts) {
        texts.parallelStream().forEach(text -> {
            String cacheKey = DigestUtils.md5Hex(text);
            embeddingCache.put(cacheKey, embeddingModel.embed(text));
        });
    }
}

2. 非同期インデックス:メインフローをブロックしない

@Service
public class AsyncIndexingService {

    private final VectorStore vectorStore;
    private final DocumentTransformer textSplitter;
    private final TaskExecutor indexExecutor;

    public AsyncIndexingService(
            VectorStore vectorStore,
            DocumentTransformer textSplitter) {
        this.vectorStore = vectorStore;
        this.textSplitter = textSplitter;
        this.indexExecutor = Executors.newVirtualThreadPerTaskExecutor();
    }

    @Async("indexExecutor")
    public CompletableFuture<IndexResult> indexAsync(Resource resource, String sourceId) {
        long start = System.currentTimeMillis();
        try {
            DocumentReader reader = new TextReader(resource);
            List<Document> documents = reader.get();
            List<Document> split = textSplitter.apply(documents);

            split.forEach(doc -> doc.getMetadata().put("sourceId", sourceId));
            vectorStore.add(split);

            return CompletableFuture.completedFuture(
                new IndexResult(true, split.size(), System.currentTimeMillis() - start, null)
            );
        } catch (Exception e) {
            return CompletableFuture.completedFuture(
                new IndexResult(false, 0, System.currentTimeMillis() - start, e.getMessage())
            );
        }
    }
}

record IndexResult(boolean success, int documentCount, long durationMs, String error) {}

3. マルチモデルルーティング:コストと品質のバランス

@Service
public class ModelRoutingService {

    private final Map<String, ChatModel> models;
    private final MeterRegistry meterRegistry;

    public ModelRoutingService(
            @Qualifier("openAiChatModel") ChatModel openAiModel,
            @Qualifier("deepseekChatModel") ChatModel deepseekModel,
            @Qualifier("qwenChatModel") ChatModel qwenModel,
            MeterRegistry meterRegistry) {
        this.models = Map.of(
            "gpt-4o", openAiModel,
            "deepseek-v3", deepseekModel,
            "qwen-max", qwenModel
        );
        this.meterRegistry = meterRegistry;
    }

    public String routeAndChat(String question, String priority) {
        ChatModel selectedModel = switch (priority) {
            case "quality" -> models.get("gpt-4o");
            case "cost" -> models.get("deepseek-v3");
            case "chinese" -> models.get("qwen-max");
            default -> models.get("deepseek-v3");
        };

        meterRegistry.counter("rag.model.routing",
            "model", getModelName(selectedModel)).increment();

        return selectedModel.call(question);
    }

    private String getModelName(ChatModel model) {
        for (Map.Entry<String, ChatModel> entry : models.entrySet()) {
            if (entry.getValue().equals(model)) {
                return entry.getKey();
            }
        }
        return "unknown";
    }
}

比較分析:3つのベクトルデータベースソリューション

次元 PgVector Milvus Chroma
デプロイ方式 PG拡張、追加運用なし 独立クラスタ、Zookeeperが必要 組み込み/サーバーの2モード
適用スケール <100万ベクトル 1億+ベクトル <50万ベクトル
インデックスタイプ HNSW/IVFFlat HNSW/IVF_FLAT/IVF_PQ8 HNSW
クエリレイテンシ(P99) 50-200ms 10-50ms 30-100ms
フィルタクエリ SQLネイティブサポート 式エンジン メタデータフィルタリング
トランザクションサポート ACID 結果整合性 なし
Javaエコシステム Spring AIネイティブ Spring AI + Milvus SDK Spring AIネイティブ
運用複雑さ 低い(PGを再利用) 高い(分散クラスタ) 低い(組み込み)
コスト 低い(PGインスタンスを再利用) 中(専用クラスタが必要) 低い(組み込みは無料)
推奨シナリオ 既存PGがある企業、中小規模 大規模ベクトル検索 プロトタイプ検証、小規模

選定ガイド

  • 既存のPostgreSQLがある企業 → PgVector、運用コストゼロ
  • ベクトル規模が500万を超える → Milvus、分散スケーリング
  • 迅速なプロトタイプ検証 → Chroma、5分でセットアップ完了

ベクトルデータベースの詳細な比較については、ベクトルデータベースセマンティック検索実践を参照してください。


おすすめオンラインツール

RAGアプリケーションの構築において、以下のオンラインツールは生産性を大幅に向上させます:

ツール 用途 リンク
JSONフォーマッター ベクトルストアのメタデータJSONの処理 JSONフォーマッター
ハッシュ計算 キャッシュと重複排除のためのドキュメントフィンガープリント生成 ハッシュ計算
Curl→コード変換 LLM API呼び出しコードの迅速生成 Curl→コード変換
Base64エンコード/デコード ドキュメントコンテンツのエンコーディング変換 Base64エンコード/デコード
正規表現テスター ドキュメントチャンキングの正規表現ルールの検証 正規表現テスター

まとめ

SpringBoot 3.5 + Spring AIは、Java開発者にプロダクション級のRAGの完全なソリューションを遂に提供した。6つのパターンはベクトルストレージからインテリジェントQAまでの全チェーンをカバーしている:PgVector統合はインフラストラクチャ、ドキュメントチャンキングは効果の上限を決定し、ハイブリッド検索はリコール率を向上させ、会話メモリはQAをよりインテリジェントにし、パイプラインオーケストレーションは信頼性を確保し、監視とデグラデーションはプロダクションの安定性を守る。覚えておいてほしい:RAGは銀の弾丸ではないが、2026年のJava AI実装における最も実用的なアプローチだ。

関連記事

ブラウザローカルツールを無料で試す →

#SpringBoot#Spring AI#RAG#向量数据库#Java#大模型集成#2026#AI与大数据