【検討メモ】オンプレサーバーでEmbedding・類似検索を構築するか?AWS連携とコスト最適化の考察

この記事は生成AI(Claude Code)が作成しています。技術的な内容については公式ドキュメント等で最新情報をご確認ください。

はじめに:なぜオンプレを検討するのか

現在、AWSのサーバーレス構成(DynamoDB + AppSync + GraphQL)でWebサービスを運用している。しかし、類似検索やEmbedding生成を本格的に導入しようとすると、AWSのOpenSearch等はコストが高い。

そこで浮上したのが「オンプレサーバーでGPUを使ったEmbedding生成・再ランキング、そして全文検索エンジンを動かす」という選択肢だ。本記事では、この構成を検討する際のポイントを整理する。

検討の前提条件

  • データ件数:約1万件
  • 検索対象:タイトル・短文・タグ
  • 要件:多言語対応、リアルタイム検索
  • 現行構成:AWS DynamoDB + AppSync + GraphQL
  • 目的:コスト削減とGPU活用

1. Ollamaで使えるEmbeddingモデルの選び方

ローカルでEmbeddingを生成する場合、Ollamaは手軽な選択肢だ。Embeddingモデルを選ぶ基準を整理する。

選定基準

基準説明推奨
多言語対応日本語・英語など複数言語を扱う場合は必須qwen3-embedding, bge-m3, granite-embedding
次元数高次元ほど精度が上がるがストレージ・計算コスト増384〜1024次元が実用的
モデルサイズGPUメモリとのトレードオフ8GB VRAM なら 4B以下のモデル
検索タスク適性検索・類似度計算に特化したモデルを選ぶMTEB Retrievalスコアを参考に

Ollamaで利用可能な多言語Embeddingモデル

2025年以降、Ollamaで利用可能な多言語対応Embeddingモデルが充実してきた。以下は日本語対応で実績のあるモデル。

モデルサイズ言語特徴
qwen3-embedding0.6B / 4B / 8B100+言語MTEB多言語1位(スコア70.58)、次元数32-4096で柔軟
bge-m3567M100+言語dense/sparse/multi-vector対応、8Kトークン
granite-embedding:278m278M12言語(日本語明記)IBM製、軽量で高速、日本語サポート明記
nomic-embed-text-v2-moe475M(305M active)100言語(日本語含む)MoE構成、MIRACL 65.80、mC4で学習
nomic-embed-text137M英語のみ768次元、8Kコンテキスト、軽量(v1/v1.5は英語専用)
mxbai-embed-large335M英語中心1024次元、検索精度が高い
# 多言語対応モデルのダウンロード例
ollama pull qwen3-embedding:4b    # バランス型(推奨)
ollama pull bge-m3                # 多機能型
ollama pull granite-embedding:278m # 軽量型(日本語明記)

# Embedding生成の例
curl http://localhost:11434/api/embed -d '{
  "model": "qwen3-embedding:4b",
  "input": "日本語のテキストをベクトル化"
}'

本件の推奨:タイトル・短文・タグの類似検索で多言語対応が必要な場合、qwen3-embedding:4b(精度重視)またはgranite-embedding:278m(軽量・日本語明記)がおすすめ。

参考:Ollama Embedding Models

2. キーワード検索との併用:ハイブリッド検索の実装

Embedding(ベクトル検索)だけでなく、キーワード検索を併用するハイブリッド検索が効果的だ。特に短いクエリや専門用語の検索ではキーワード検索が有利な場合がある。

ハイブリッド検索の基本構成

クエリ 全文検索 (Meilisearch) Embedding生成 (Ollama/GPU) ベクトル検索 (pgvector) スコア統合 (RRF / 加重平均)

スコア統合の方法

# Reciprocal Rank Fusion (RRF) の実装例
def rrf_score(keyword_rank: int, vector_rank: int, k: int = 60) -> float:
    """RRFスコアを計算"""
    return 1 / (k + keyword_rank) + 1 / (k + vector_rank)

# 加重平均の例
def weighted_score(keyword_score: float, vector_score: float, 
                   alpha: float = 0.5) -> float:
    """キーワード検索とベクトルスコアの加重平均"""
    return alpha * keyword_score + (1 - alpha) * vector_score

3. 全文検索エンジンの比較

オンプレで使える全文検索エンジンを比較する。

エンジン特徴メモリ使用量運用難易度推奨ケース
Meilisearch高速、タイポ耐性、日本語対応、独自ランキング低〜中★☆☆(簡単)1万件程度の軽量用途
OpenSearchElasticsearch互換、BM25、k-NN対応★★★(複雑)大規模・多機能が必要
Elasticsearch業界標準、BM25、豊富なエコシステム★★★(複雑)既存資産がある場合
TantivyRust製、BM25、軽量高速★★☆(中程度)カスタム実装したい場合

補足:MeilisearchはBM25ではなく独自のランキングアルゴリズムを使用している。厳密なBM25が必要な場合はOpenSearch/Elasticsearch/Tantivyを検討すること。

1万件でリアルタイム検索なら

Meilisearchがおすすめ。理由は以下の通り。

  • インストール・設定が簡単(Docker一発)
  • 1万件程度なら十分高速(数ミリ秒で応答)
  • 日本語トークナイザー内蔵
  • REST APIでAWSからも呼び出しやすい
# Meilisearchのインストール(Docker)
docker run -d --name meilisearch \
  -p 7700:7700 \
  -v $(pwd)/meili_data:/meili_data \
  getmeili/meilisearch:latest

4. AWS サーバーレスとオンプレの連携構成

AWSのサーバーレス構成とオンプレ検索サーバーを連携させる方法を検討する。

構成案

AWS Cloud AppSync GraphQL Lambda DynamoDB (マスタDB) DynamoDB Streams VPN / Direct Connect オンプレサーバー Meilisearch (全文検索) Ollama (Embedding/GPU) pgvector (ベクトルDB) FastAPI (検索API) 検索リクエスト データ同期

連携パターン

パターン1:Lambda から直接呼び出し

  • VPNやDirect Connect経由でオンプレにアクセス
  • Lambdaがオンプレの検索APIを呼び出す
  • レイテンシ:VPN経由で50〜100ms程度追加

パターン2:API Gateway + オンプレ

  • オンプレにAPIサーバーを立て、パブリック公開
  • API Gateway経由でルーティング
  • 認証はAPI Keyまたは署名検証
# Lambda から Meilisearch を呼び出す例
import os
import requests

def lambda_handler(event, context):
    query = event['arguments']['query']
    
    # オンプレの Meilisearch にリクエスト
    response = requests.post(
        f"{os.environ['MEILI_HOST']}/indexes/articles/search",
        json={"q": query, "limit": 20},
        headers={"Authorization": f"Bearer {os.environ['MEILI_KEY']}"}
    )
    
    return response.json()['hits']

5. データ同期:DynamoDB → オンプレ

DynamoDBのデータをオンプレの検索インデックスに同期する方法。

方法1:DynamoDB Streams + Lambda

# DynamoDB Streams のイベントを処理してオンプレに同期
import os
import requests

MEILI_HOST = os.environ.get('MEILI_HOST', 'http://localhost:7700')

def extract_tags(tags_attr):
    """DynamoDB AttributeValue の List を文字列配列に変換"""
    if not tags_attr or 'L' not in tags_attr:
        return []
    return [tag['S'] for tag in tags_attr['L'] if 'S' in tag]

def sync_to_onprem(event, context):
    for record in event['Records']:
        if record['eventName'] in ('INSERT', 'MODIFY'):
            item = record['dynamodb']['NewImage']
            doc = {
                'id': item['id']['S'],
                'title': item['title']['S'],
                'body': item['body']['S'],
                'tags': extract_tags(item.get('tags'))
            }
            # Meilisearch に追加/更新
            requests.put(
                f"{MEILI_HOST}/indexes/articles/documents",
                json=[doc]
            )
        elif record['eventName'] == 'REMOVE':
            doc_id = record['dynamodb']['Keys']['id']['S']
            requests.delete(
                f"{MEILI_HOST}/indexes/articles/documents/{doc_id}"
            )

方法2:定期バッチ同期

  • EventBridge で定期実行(5分〜1時間間隔)
  • DynamoDB Scan で全件取得し、差分更新
  • 1万件なら数秒で完了

6. オンプレ障害時の対応(フォールバック)

オンプレサーバーがダウンした場合の対策は重要だ。

フォールバック戦略

戦略実装方法コスト
DynamoDB直接検索オンプレ障害時はDynamoDBのScan/Queryで代替
CloudSearchへフォールバックAWS CloudSearchを待機系として維持
キャッシュ活用検索結果をElastiCacheにキャッシュ低〜中
オンプレ冗長化オンプレサーバーを2台構成

実装例:サーキットブレーカーパターン

# サーキットブレーカーでオンプレ障害を検知
import os
import requests
import boto3
from circuitbreaker import circuit

MEILI_HOST = os.environ.get('MEILI_HOST', 'http://localhost:7700')

@circuit(failure_threshold=3, recovery_timeout=60)
def search_onprem(query: str):
    """オンプレ検索(3回失敗で遮断、60秒後に再試行)"""
    return requests.post(
        f"{MEILI_HOST}/indexes/articles/search",
        json={"q": query}
    )

def search_handler(event, context):
    query = event['arguments']['query']
    
    try:
        # まずオンプレで検索
        return search_onprem(query).json()
    except Exception:
        # フォールバック:DynamoDBで検索
        dynamodb = boto3.resource('dynamodb')
        table = dynamodb.Table('articles')
        response = table.scan(
            FilterExpression='contains(title, :q)',
            ExpressionAttributeValues={':q': query}
        )
        return response['Items']

7. コスト比較

AWS OpenSearch vs オンプレの概算コスト比較(1万件、リアルタイム検索の場合)。

項目AWS OpenSearchオンプレ構成
初期費用なしGPU搭載サーバー 15〜30万円
月額費用約$150〜300/月(t3.small.search×2)電気代 約3,000〜5,000円/月
年間コスト約25〜40万円初年度:20〜35万円、2年目以降:4〜6万円
スケーラビリティ高(自動スケーリング)低(手動増設)
運用負荷低(マネージド)高(自己管理)

結論:1万件程度で長期運用するなら、オンプレは2年で元が取れる計算。ただし運用負荷とのトレードオフ。

8. 推奨構成まとめ

今回の要件(1万件、多言語、リアルタイム、コスト重視)に対する推奨構成。

オンプレサーバー構成

  • CPU:Ryzen 5以上
  • GPU:RTX 3060 12GB または RTX 4060 Ti 16GB
  • メモリ:32GB以上
  • ストレージ:NVMe SSD 512GB以上

ソフトウェア構成

  • Embedding:Ollama + qwen3-embedding:4b(精度重視)または granite-embedding:278m(軽量)
  • 全文検索:Meilisearch(独自ランキング)
  • ベクトルDB:pgvector(PostgreSQL)
  • API:FastAPI でラップ

AWS連携

  • 同期:DynamoDB Streams + Lambda
  • 通信:Site-to-Site VPN
  • フォールバック:DynamoDB直接検索 + サーキットブレーカー

次のステップ

今後、以下を検証予定。

  1. Meilisearchの日本語検索精度の実測
  2. qwen3-embedding / granite-embedding の日本語ベンチマーク
  3. VPN経由のレイテンシ測定
  4. フォールバック時のユーザー体験検証

参考リンク