この記事は筆者のいい加減な指示のもと、生成AIが自分で調べて盛り盛りして作成しています。

はじめに:AIの「分かりすぎている」提案

現在構築中のフィギュア写真投稿サイト「FigureFan」では、57言語以上に対応したクロス言語検索の実装を進めています。

私はアシスタントAIに、検索エンジンの核となるEmbedding(ベクトル化)モデルの選定を依頼しました。
AIは最初、真面目に「平和 (Peace)」という単語を TranslateGemma の仕様に基づく 59の言語・方言エントリ で翻訳してベクトルの距離を測り、「このモデルが優秀です!」と報告してきました。

しかし、本サービスは世界中のファンが交流する場所です。
私は真面目に問いかけました。
「今回の検証だけで十分なのか? アニメやオタク文化を重視したサービスの目的として、その辞書的な評価だけで本当に合致していると言えるのか?」

私は具体的なキーワードの指示は一切していません。
しかし、AIが「承知しました。ドメインに特化した検証を行います」と言って持ってきたテスト計画を見て、私は思わず吹き出しました。

AIが提案した検証用データセット(抜粋):
・初音ミク (Hatsune Miku)
・エヴァンゲリオン (Evangelion)
・ケモノ耳 (Kemonomimi)
ツンデレ (Tsundere)

ツンデレwww
まさかAIからその単語が出てくるとは。
「お前、分かってるな」ということで、AIが自ら構築したこのベンチマークを採用することにしました。


1. 検証環境と使用モデル

1.1 環境 (GPU Offload)

  • CPU: Legacy (AVX非対応)
  • GPU: NVIDIA GeForce GTX 1080 Ti
  • Runtime: Ollama (Docker, version 0.14.3)

注意: AVX非対応CPU環境のため、DockerでGPUパススルー設定を行い、Ollamaの推論を完全にGPUで実行させています。

1.2 検証モデル詳細 (再現性情報)

本検証で使用したモデルのOllama上のID(Short)と、完全なDigestは以下の通りです。

モデル名Ollama TagModel ID (Short)Dim
embeddinggemmaembeddinggemma:latest85462619ee72768
paraphrase-multilingualparaphrase-multilingual:latestba13c2e06707768
bge-m3bge-m3:latest7907646426071024
Full Digests (SHA256):
  • embeddinggemma: sha256:0800cbac9c2064dde519420e75e512a83cb360de3ad5df176185dc69652fc515
  • paraphrase-multilingual: sha256:aa99ebfc77f41e752c215353c08bdb22a75d7858a629934fa04f587d3cdee165
  • bge-m3: sha256:daec91ffb5dd0c27411bd71f29932917c49cf529a641d0168496c3a501e3062c
※Digestは ollama show <model> --modelfile コマンドの FROM 行から取得した、モデルの実体(Blob)を指すハッシュ値です。これにより、latestタグが更新されてもベースモデルの同一性を追跡可能です。完全な再現には、パラメータ設定を含む Modelfile 全体の同一性も必要となります。

2. 検証結果

検証1: 基礎的な多言語対応能力

目的: 一般的な単語レベルでの言語間距離を測定する。
対象言語: TranslateGemmaの仕様に基づいた59の言語・方言エントリ
方法: 「平和 (Peace)」という単語のベクトルを59言語で生成し、日本語ベクトルとのコサイン類似度を算出。その単純平均値(日本語の自己類似度1.0を含む)をスコアとする。

結果(対日本語類似度・平均)

  • paraphrase-multilingual: 0.7322 (1位)
  • embeddinggemma: 0.6445
  • bge-m3: 0.5133
使用した59言語の翻訳リスト(再現用・修正版)
ja: 平和, en: Peace, zh-CN: 和平, zh-TW: 和平, ko: 평화, vi: Hòa bình, th: สันติภาพ, id: Perdamaian, fil: Kapayapaan, fr: Paix, de: Frieden, es: Paz, it: Pace, pt: Paz, ru: Мир, nl: Vrede, tr: Barış, ar: سلام, hi: शांति, bn: শান্তি, pa: ਸ਼ਾਂਤੀ, gu: શાંતિ, or: ଶାନ୍ତି, ta: அமைதி, te: శాంతి, kn: ಶಾಂತಿ, ml: സമാധാനം, my: ငြိမ်းချမ်းရေး, km: សន្តិភាព, lo: ສັນຕິພາບ, ms: Keamanan, uk: Мир, pl: Pokój, ro: Pace, hu: Béke, el: Ειρήνη, cs: Mír, sv: Fred, no: Fred, da: Fred, fi: Rauha, bg: Мир, sr: Мир, hr: Mir, sk: Mier, sl: Mir, et: Rahu, lv: Miers, lt: Taikos, fa: صلح, he: שלום, hy: Խաղաղություն, ka: მშვიდობა, az: Sülh, kk: Бейбітшілік, uz: Tinchlik, ky: Тынчтык, tk: Parahatçylyk, tg: Сулҳ

※注意: コサイン類似度の絶対値はモデルごとのベクトル空間の分布に依存するため、異なるモデル間で数値を直接比較しても優劣の証明にはなりません。あくまで日本語ベクトルとの相対的な距離感の参考値です。

検証2: オタクコンテキストでの検索性能

AIが作成したデータセット(N=10)を使用し、実践的な検索テストを行いました。
※本テストはサニティチェック(健全性確認)を目的とした小規模なものであり、統計的な有意性を保証するものではありません。

検証条件

  • プレフィックス: 実装のシンプルさと公平性を保つため、全てのモデルで素のテキストを入力しました(モデル推奨のprefixは不使用)。
  • 評価手順: クエリと投稿の双方をL2正規化した後、内積(コサイン類似度)でソートし、Top 3を取得して評価。

テストデータセット全文 (再現用)

[Posts]
1. (JA) 初音ミクの1/7スケールフィギュア。ツインテールが美しい。
2. (EN) Hatsune Miku Nendoroid, very cute and chibi!
3. (ES) Una figura de Hatsune Miku cantando en el escenario.
4. (EN) EVA Unit-01 Test Type. The purple paint is perfect.
5. (JA) 汎用ヒト型決戦兵器 人造人間エヴァンゲリオン初号機
6. (EN) Rei Ayanami - Bandages version. Quiet personality.
7. (JA) このアニメキャラは典型的なツンデレだね。素直になれないところが可愛い。
8. (EN) She is a classic Tsundere character. 'It's not like I like you or anything!'
9. (EN) Kemonomimi girl with fluffy fox ears and tail.
10. (JA) 獣耳の女の子。狐の尻尾がモフモフ。

[Queries & Targets]
Q1: "初音ミク" -> Targets: [1, 2, 3] (3件)
Q2: "Mecha robot" -> Targets: [4, 5] (2件)
Q3: "ツンデレ" -> Targets: [7, 8] (2件)
Q4: "Fox ears" -> Targets: [9, 10] (2件)
Q5: "Evangelion" -> Targets: [4, 5, 6] (3件)

詳細スコア内訳 (Recall @ 3)

Avg Score = (Top3に含まれた正解数 / クエリごとの全正解数) の5クエリ算術平均

モデル名 Q1
(Miku)
Q2
(Mecha)
Q3
(Tsundere)
Q4
(Fox)
Q5
(EVA)
Avg
embeddinggemma 1.001.001.001.001.00 1.00
bge-m3 0.670.501.001.001.00 0.83
paraphrase-multilingual 0.670.500.501.000.67 0.67

embeddinggemma、まさかの満点。
GoogleのGemmaアーキテクチャをベースとしたこのモデルは、「ツンデレ」のニュアンスを言語を超えて完全に理解しているようです。


3. 最終的な構成

本番環境でも「L2正規化+内積(Cosine)」による検索を採用します。

Search Flow (Read) Indexing Flow (Write) User Post Search API (FastAPI) Job Worker (Async) Ollama (Embed) Meilisearch Keyword Qdrant Vector

実装のポイント

  1. Indexing Flow (非同期):
    • Job Workerが Ollama (embeddinggemma) でベクトル化します。
    • ベクトルデータはVector DBであるQdrantへ保存します。
    • 検索用テキストとメタデータはSearch EngineであるMeilisearchへ保存します。
  2. Search Flow (同期):
    • Search APIは Keyword検索 (Meilisearch)Vector検索 (Qdrant) を並列実行。
    • 双方から Top-60 件を取得し、RRF (Reciprocal Rank Fusion, k=60) アルゴリズムで統合して最終ランキングを生成します。同一ドキュメントIDはマージされます。
      ※Top-60およびk=60の設定は、RRFにおける標準的な初期パラメータとしての例です。

まとめ

私の「オタク文化を重視したい」という真面目な問いかけに対し、AIは「ツンデレ」という最適解を自律的に導き出し、さらにそれを完璧に理解するモデルを見つけ出してきました。

AIのコンテキスト理解力と、意外なユーモアセンス(?)に感心させられた一日でした。

結論:Embeddingモデルに迷ったら、とりあえずツンデレでテストしろ。

この記事は生成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. フォールバック時のユーザー体験検証

参考リンク

はじめに

本プロジェクト(Figure Fan)では、「他言語のユーザー同士でも意識せずにコミュニケーションできること」を最上位のコンセプトとして掲げています。これを実現するため、単なるUIの多言語化にとどまらず、ユーザーが投稿するコメントや写真タイトルの自動翻訳、さらには入力言語に関わらず関連コンテンツをヒットさせる「クロス言語検索」の導入を検討しました。

課題:コストと精度のトレードオフ

多言語対応と高機能な検索を実現するには、通常、以下のような課題があります。

  • 翻訳APIのコスト: Google Cloud Translation APIやDeepL APIは高精度ですが、従量課金制であり、大量のコメントやコンテンツを翻訳し続けるとコストが肥大化します。
  • 検索エンジンのコスト: Amazon OpenSearch Serviceなどのマネージドサービスは便利ですが、プロビジョンドインスタンスのコストが高く(※Serverless版は従量課金で低コスト化可能)、スタートアップや個人開発規模では負担になります。

これらの課題を解決するため、「オンプレミスAIの活用」「軽量なOSS」を組み合わせた低コストなアーキテクチャを設計しました。

解決策1:翻訳基盤 - TranslateGemma × バッチ処理

翻訳エンジンには、Googleが公開したオープンモデル TranslateGemma (12B) を採用しました。これをローカルLLM実行基盤である Ollama 上で動作させます。

選定理由

  • 高精度: 55言語以上に対応し、特にマイナー言語の翻訳品質が高い。
  • API利用料ゼロ: 自前のサーバー(既存リソース)で動作するため、API利用料が発生しません。
  • プライバシー: データが外部に送信されないため、セキュアです。

実装方針:非同期バッチ処理

12Bモデルは推論に時間がかかるため、リアルタイム翻訳ではなく、投稿時に非同期で翻訳ジョブをキューに積み、バックグラウンドで処理してDBに保存するアーキテクチャを採用しました。

解決策2:検索基盤 - クロス言語検索 × Qdrant

「猫」で検索したときに、'Cat'(英語)や 'Pusa'(タガログ語)のタグがついた写真もヒットさせるため、ベクトル検索(Vector Search) を導入します。

技術スタック

  • 埋め込みモデル: bge-m3:latest (Ollamaで動作)
    多言語に対応した高性能な埋め込みモデルです。
  • ベクトルデータベース: Qdrant
    Rust製の超軽量なベクトルDB。Dockerコンテナ1つで動作し、Ollamaと同じサーバーに同居可能です。

PoC(概念実証)結果

実際にOllama上の bge-m3 を使用して、言語間のベクトル類似度を測定しました。

比較ペア類似度判定
日本語「猫」 vs 英語 'Cat'0.7759◎ そのまま検索可能
日本語「猫」 vs タガログ語 'Pusa'0.4894△ 精度低い
日本語「猫」 vs タガログ語(英訳) 'Cat'0.8616◎ 翻訳パイプライン有効

検証の結果、「マイナー言語は一度英語に翻訳してからベクトル化する」というハイブリッドなアプローチが極めて有効であることが実証されました。

今後の展望

今後は、以下のステップで実装を進めます。

  1. バッチ翻訳の実装: TranslateGemmaを用いた非同期翻訳パイプラインの構築。
  2. Qdrantの導入: 検索機能へのベクトル検索の統合。

これにより、低コストながらも「言葉の壁を感じさせない」次世代のコミュニティ体験を実現します。


※この記事は生成AI (Claude Code) によって作成されました。

※この記事は生成AI(Claude Code)によって作成されています。

はじめに

AWS DynamoDBでキーワード検索機能を実装する際、多くの開発者がAmazon OpenSearch Service(旧Elasticsearch Service)の導入を検討します。しかし、OpenSearchは強力な検索機能を提供する一方で、月額コストが数万円〜数十万円になることも珍しくありません。

本記事では、OpenSearchを使用せずにDynamoDB単体で事前構築したキーワードインデックスによる完全一致検索を実現する方法を、実際のプロジェクト(フィギュア写真共有サービス)での実装経験をもとに解説します。

※ 本記事の検索機能の制約
本記事で紹介する方法は、事前に作成したキーワードインデックスに対する完全一致検索です。形態素解析、曖昧検索(typo許容)、関連度スコアリングなどの機能は含まれません。これらが必要な場合はOpenSearchの導入を検討してください。

OpenSearchを使わない理由

コスト比較

サービス最小構成の月額目安本番推奨構成の月額目安
Amazon OpenSearch Service約$50〜100/月約$200〜500/月(マルチAZ)
DynamoDB(オンデマンド)使用量に応じて課金数$〜数十$/月(中規模サービス)

特にスタートアップや個人開発では、この差は大きな負担となります。

DynamoDBで検索を実現するアーキテクチャ

1. GSI(Global Secondary Index)の活用

DynamoDBでは、GSIを使用することで様々な検索パターンに対応できます。

// GSI設計例(擬似コード - 実際のJSONではコメント不可)
{
  "GSI2-CategorySearch": {
    "PK": "GSI2PK",  // CATEGORY#cat_1
    "SK": "GSI2SK",  // 作成日時(降順ソート用)
    "ProjectionType": "ALL"  // FilterExpressionでData属性を使用するため
  },
  "GSI3-LicenseFilter": {
    "PK": "GSI3PK",  // LICENSE#cc-by
    "SK": "GSI3SK"
  },
  "GSI4-SearchSupport": {
    "PK": "GSI4PK",  // SEARCH#keyword
    "SK": "GSI4SK"   // EntityType#EntityId
  },
  "GSI5-PopularitySort": {
    "PK": "GSI5PK",  // POPULAR#2026-01
    "SK": "GSI5SK"   // スコア(人気順ソート)
  }
}

2. 検索インデックスの事前構築

キーワード検索を実現するために、データ登録時に検索用インデックスを構築します。

// TypeScript (AWS SDK v3): 写真登録時にキーワードインデックスを作成
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);

const keywords = extractKeywords(photo.title + ' ' + photo.description);

for (const keyword of keywords) {
  await docClient.send(new PutCommand({
    TableName: TABLE_NAME,
    Item: {
      PK: `SEARCH#${keyword.toLowerCase()}`,
      SK: `PHOTO#${photo.id}`,
      GSI4PK: `SEARCH#${keyword.toLowerCase()}`,
      GSI4SK: `PHOTO#${photo.id}`,
      EntityType: 'Photo',
      EntityId: photo.id,
      Type: 'SearchIndex'
    }
  }));
}

FilterExpressionによるセキュリティフィルタリング

検索結果から非公開データを除外するために、FilterExpressionを活用します。

※ GSI射影の前提条件
以下のコードでFilterExpressionを使用してData.privacy属性をフィルタリングするには、GSIのProjectionTypeALLであるか、またはINCLUDEData属性を含めている必要があります。射影に含まれていない属性を参照するとクエリが失敗します。
// TypeScript (AWS SDK v3): カテゴリ検索の実装例
import { QueryCommand, QueryCommandInput } from '@aws-sdk/lib-dynamodb';

const params: QueryCommandInput = {
  TableName: this.tableName,
  IndexName: 'GSI2-CategorySearch',
  KeyConditionExpression: 'GSI2PK = :pk',
  // PRIVATE写真をフィルタ(検索APIは常にpublic写真のみ)
  // ※ GSIのProjectionにData属性が含まれている前提
  FilterExpression: '#data.#privacy = :public',
  ExpressionAttributeNames: {
    '#data': 'Data',
    '#privacy': 'privacy',
  },
  ExpressionAttributeValues: {
    ':pk': `CATEGORY#${categoryId}`,
    ':public': 'public',
  },
  Limit: query.limit ?? 20,
  ScanIndexForward: false, // 新しい順
};

重要:二重防御の実装

セキュリティ上重要なフィルタリングは、FilterExpressionとアプリケーション層の両方で実装することを推奨します。

// TypeScript: DynamoDBクエリ実行後のアプリケーション層フィルタ
const items = (result.Items ?? [])
  .filter((item) => {
    const data = item.Data as { privacy?: string };
    const isPublic = data?.privacy === 'public';
    return item.Type === 'PhotoInfo' && isPublic;
  })
  .map((item) => this.transformToSearchResult(item));

GraphQLとの統合

AWS AppSyncを使用してGraphQL APIを提供する場合、入力値の検証とマッピングが重要です。

# GraphQL Schema
input PhotoSearchQuery {
  keyword: String
  category_ids: [Int!]
  tags: [String!]
  license_ids: [Int!]
  safe_for_work: Boolean
  privacy: Privacy  # ← 悪意のある入力に注意
  min_date: String
  max_date: String
  sort: PhotoSortOption
  limit: Int
  next_key: String
}
// TypeScript: Resolver実装 - セキュリティ対策
const searchQuery: SearchQuery = {
  q: query.keyword,
  category: query.category_ids?.[0]?.toString(),
  filters: {
    // safe_for_work の反転マッピング
    includeNsfw: query.safe_for_work !== undefined
      ? !query.safe_for_work
      : undefined,
    // 重要: privacy引数に関わらず常にpublicOnly=true
    publicOnly: true,
  },
};

パフォーマンス考慮事項

1. FilterExpressionの制限

FilterExpressionはクエリにフィルタリングを行うため、読み取りキャパシティは消費されます。大量のデータがフィルタで除外される場合は、GSI設計を見直すことを検討してください。

2. 複数アイテムの一括取得

複数のアイテムを効率的に取得するには、BatchGetCommandを使用します。Query の並列実行よりも効率的でコストも低くなります。

// TypeScript (AWS SDK v3): BatchGetCommandで一括取得
import { BatchGetCommand } from '@aws-sdk/lib-dynamodb';

const BATCH_SIZE = 100; // BatchGetItemは最大100アイテム

for (let i = 0; i < photoIds.length; i += BATCH_SIZE) {
  const batch = photoIds.slice(i, i + BATCH_SIZE);

  const result = await this.docClient.send(new BatchGetCommand({
    RequestItems: {
      [this.tableName]: {
        Keys: batch.map((id) => ({
          PK: `PHOTO#${id}`,
          SK: 'INFO',
        })),
      },
    },
  }));

  const items = result.Responses?.[this.tableName] ?? [];
  // 結果を処理...
}
※ BatchGetItem vs Query の使い分け
  • BatchGetItem: PK+SKが既知の複数アイテムを一括取得する場合に最適。最大100アイテム/リクエスト。
  • Query: PKに対して条件付きでSKの範囲を検索する場合に使用。単一アイテム取得にはオーバーヘッドがある。

実装のポイントまとめ

  1. GSI設計を事前に検討:検索パターンに応じた適切なGSI設計が鍵
  2. 検索インデックスの事前構築:データ登録時にキーワードインデックスを作成(完全一致検索用)
  3. FilterExpressionの活用:クエリ時にセキュリティフィルタリング(GSI射影に対象属性が必要)
  4. 二重防御:DB層とアプリ層の両方でフィルタリング
  5. 入力値の検証:GraphQL引数の悪意ある入力を無視する実装

この方法が向いているケース

  • 検索パターンが限定的(カテゴリ、タグ、日付範囲など)
  • 完全一致のキーワード検索で十分(曖昧検索が不要)
  • データ量が中規模(数十万件程度まで)
  • コスト重視のプロジェクト

OpenSearchを検討すべきケース

  • 曖昧検索(typo許容)が必要
  • 自然言語処理・形態素解析が必要
  • 関連度スコアリングが必要
  • 複雑なアグリゲーション(集計)が必要
  • データ量が大規模(数百万件以上)

参考リンク

まとめ

DynamoDB単体でも、適切なGSI設計とFilterExpressionの活用により、事前構築したインデックスに対する完全一致のキーワード検索を実現できます。OpenSearchの導入は強力ですが、コストとのトレードオフを考慮し、プロジェクトの要件に合った選択をしましょう。

本記事で紹介した実装パターンは、実際にフィギュア写真共有サービス「Figure Fan」のバックエンド開発で使用しているものです。月額コストを抑えながら、セキュリティを確保した検索機能を提供しています。

こんにちは、開発チームです。本日はフィギュア画像のデータセット作成において、複数のアングルが1枚にまとめられた「コラージュ画像」の自動分割に取り組みました。

背景

スクレイピング収集した画像の中には、全身図、顔アップ、背面図などが1枚の画像にタイル状に配置されているものがあります。これを検索システム(ベクトル検索)で精度良く扱うためには、個別の画像(Single View)に切り出す必要があります。

アプローチ1:VLM (Visual Language Model) による座標推定

最初は「今のAIなら画像のどこに何があるかわかるだろう」と考え、ローカルLLM環境(Ollama)上の Qwen3-VLGemma3 に画像の切り出し座標(Bounding Box)を聞いてみました。

結果:構成はわかるが、座標は苦手

  • Qwen3-VL: 「左に全身、右上に顔、右下に背面」といった構成の理解力は抜群。しかし、正確な座標を出力させようとすると不安定。
  • Gemma3: 座標は返すが、「画像のちょうど半分」といった単純な推測値になりがちで、ピクセル単位の正確なクロップには使えませんでした。

アプローチ2:OpenCVによるルールベース分割

そこで、画像の境界線を検出して切る、古典的かつ高速な OpenCV を採用しました。

試行錯誤のプロセス

  1. v1 (背景除去): 白背景なら分離できるが、画像同士が隙間なく接しているコラージュには無力。
  2. v3 (グリッド検出): Hough変換で直線を検出。単純な格子状なら良いが、非対称なレイアウト(左1枚、右2枚など)に対応できず。
  3. v4 (再帰分割): 画像を再帰的に分割するロジックを実装。これで「左を切って、残った右をさらに上下に切る」が可能に。

課題:過剰分割と過小分割のジレンマ

パラメータ(線の検出感度やマージン)を調整する中で、難しい問題に直面しました。

  • 258番の画像(複雑なコラージュ):感度を上げないと切れないが、上げると画像内部の模様で過剰に切れてしまう(バラバラになる)。
  • 281番の画像(単純な3分割):中央の分割線が検出されにくく、感度を下げると切れない。

この「あちらを立てればこちらが立たず」の状況を打破するために、v5 では「画像中央付近の分割線を優先する(マージン40%)」というロジックを導入し、なんとか実用的な落とし所を見つけました。

ブレイクスルー:AIによる事後検証 (Verify)

パラメータ調整だけでは限界があるため、新たなアプローチとして「AIによる事後検証(Human-in-the-loopならぬAI-in-the-loop)」を導入しました。

OpenCVで分割した結果の画像を、再度 Qwen3-VL に投げ、以下の質問をします。

「この画像は1枚の絵として成立していますか?(Single)それとも、まだ複数の絵が混ざっていますか?(Collage)」

これにより、OpenCVが切り漏らした「Collage」画像を特定し、それだけを対象にパラメータを緩めて再分割するというループが可能になります。検証の結果、Qwen3-VLはこの判定を非常に高い精度で行えることがわかりました。

まとめ

  • 座標指定はOpenCV:ピクセル単位の正確な処理はルールベースが強い。
  • 判断はAI:画像の構成理解や品質チェックはVLMが強い。

これらを組み合わせることで、高速かつ柔軟な画像処理パイプラインが構築できました。

参考リンク


記事作成:Gemini 3

はじめに

オープンソースのLLMアプリケーション開発プラットフォーム「Dify」をDocker Compose環境でv1.11.1からv1.11.4まで段階的にアップグレードした作業記録です。

本記事では、バックアップの取得から、gitでのバージョン切り替え、Dockerイメージの更新、動作確認までの一連の手順を解説します。

環境情報

  • デプロイ方式: Docker Compose
  • OS: Ubuntu (Linux 6.8.0-90-generic)
  • アップグレードパス: v1.11.1 → v1.11.2 → v1.11.3 → v1.11.4

アップグレード手順

Step 1: 現在のバージョン確認

cd /path/to/dify/docker
docker compose ps

実行中のコンテナとイメージバージョンを確認します。

Step 2: ボリュームのバックアップ

# コンテナ停止(ファイルロック回避のため)
docker compose down

# バックアップ作成
tar -czf volumes-$(date +%s).tgz volumes/

注意点: コンテナ稼働中にバックアップを取ると、Weaviate、PostgreSQL、Redisのデータファイルで権限エラーが発生する場合があります。完全なバックアップにはsudo権限が必要です。

# 完全バックアップ(sudo必要)
sudo tar -czf volumes-$(date +%s).tgz volumes/

Step 3: ローカル変更の保存

gitリポジトリにローカル変更がある場合は、パッチとして保存します。

cd /path/to/dify
git diff > /tmp/local_changes.patch
git checkout .

Step 4: 新バージョンへのチェックアウト

git fetch --tags
git checkout 1.11.4

Step 5: ローカル変更の再適用

git apply --reject /tmp/local_changes.patch

--rejectオプションを使うと、適用できない部分は.rejファイルとして出力され、適用可能な部分は反映されます。

Step 6: Dockerイメージの更新と起動

cd docker
docker compose pull
docker compose up -d

Step 7: 動作確認

# コンテナ状態確認
docker compose ps

# バージョン確認
docker inspect docker-api-1 --format '{{.Config.Image}}'

各バージョンの主な変更点

v1.11.2

  • InterSystems IRIS / SeekDB ベクトルデータベースサポート追加
  • Aliyun SLSログ統合
  • Mermaid Graph XSS脆弱性修正
  • SSRF / CSVインジェクション対策

v1.11.3

  • MCP Tool embeddedResourceサポート
  • ドキュメント一括再インデックス機能
  • PDF画像抽出機能の改善
  • Redisキャッシュ最適化(パイプライン削除)
  • 高負荷時のメモリリーク防止

v1.11.4

  • セキュリティ: Node.js 24.13.0へのアップグレード(CVE対応)
  • ログイン後のリダイレクト修正
  • id/message_idフィールド欠落の修正
  • nameプロパティのundefinedエラー修正

トラブルシューティング

バックアップ時の権限エラー

症状:

tar: volumes/weaviate/...: Cannot open: Permission denied
tar: volumes/db/data/pgdata: Cannot open: Permission denied

原因: Dockerコンテナ内で作成されたファイルは、コンテナ内のユーザー権限で作成されるため、ホスト側から読み取れない場合があります。

対処法:

  1. コンテナを停止してからバックアップを取る
  2. sudo権限でバックアップを実行する

docker compose実行ディレクトリの注意

症状: 古いバージョンのイメージが使用される

原因: docker composeコマンドはdocker-compose.yamlのあるディレクトリで実行する必要があります。

対処法: 必ずdocker/ディレクトリに移動してから実行します。

cd /path/to/dify/docker
docker compose up -d

CLAUDE.local.mdでの環境情報管理

今回のアップグレード作業では、ローカル環境固有の情報をCLAUDE.local.mdファイルに記録し、.gitignoreに追加してリポジトリにコミットしないようにしました。

これにより、AIコーディングアシスタント(Claude Code)が環境を理解しやすくなり、適切なコマンドを提案できるようになります。

まとめ

Difyのアップグレードは、以下の手順で安全に実施できます:

  1. ボリュームのバックアップ(コンテナ停止後、sudo推奨)
  2. ローカル変更をパッチ保存
  3. gitで新バージョンにチェックアウト
  4. パッチを再適用
  5. docker compose pull && docker compose up -d
  6. 動作確認

段階的にアップグレードすることで、問題発生時の切り分けが容易になります。

参考リンク

この記事はClaude Code(Anthropic社のAIコーディングアシスタント)が作成し、Movable Type 7のData APIを使用して自動投稿しました。

はじめに

Movable Type 7(MT7)のData APIを使用すると、外部プログラムから記事の投稿・編集・削除を自動化できます。本記事では、実際の環境構築から認証、CRUD操作までを詳しく解説します。

公式ドキュメント:Movable Type Data API について

動作環境

項目
Movable Type7.9
APIバージョンv5
プロトコルHTTP(内部ネットワーク)

1. MT側の事前設定

1.1 Data APIの有効化(2箇所で必要)

Data APIを使用するには、システムレベルサイトレベルの両方で有効化が必要です。詳細は公式ドキュメント:Data API の設定を参照してください。

システムレベル

  1. MT管理画面にログイン
  2. システム → Webサービス設定を開く
  3. 「システム全般でのData APIの利用を許可する」にチェック
  4. 保存

サイトレベル

  1. 対象サイトを選択
  2. 設定 → Webサービス設定を開く
  3. 「Data APIの利用を許可する」にチェック
  4. 保存後、再構築を実行

1.2 ユーザー権限の設定

API操作を行うユーザーには以下の権限が必要です:

  • システム管理者権限(推奨)
  • または「Create entries」「Manage entries」権限

2. 認証の重要ポイント(ハマりやすい箇所)

⚠️ 重要:MT7のData API認証では、通常のログインパスワードではなく「APIパスワード(Webサービスパスワード)」を使用します。

2.1 APIパスワードとは

APIパスワードは、MTがユーザー作成時に自動生成する8文字程度のランダム文字列です。ユーザーが設定・変更することはできません。公式ドキュメントの認証 - Data APIも参照してください。

2.2 APIパスワードの確認方法

データベースに直接アクセスして確認します:

mysql -u [DBユーザー] -p [DB名] -e "SELECT author_name, author_api_password FROM mt_author;"

2.3 認証リクエストの例

認証エンドポイントについてはAPI Reference v5 - Authenticationを参照してください。

curl -X POST "http://[MTサーバー]/cgi-bin/mt/mt-data-api.cgi/v5/authentication" \
  -d "username=[ユーザー名]" \
  -d "password=[APIパスワード]" \
  -d "clientId=[任意のクライアントID]"

2.4 成功時のレスポンス

{
  "accessToken": "xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "expiresIn": 3600,
  "sessionId": "xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}

accessTokenを以降のAPIリクエストで使用します。有効期限は3600秒(1時間)です。

3. 記事のCRUD操作

記事操作のエンドポイント詳細はAPI Reference v5 - Entriesを参照してください。

3.1 記事作成(Create)

curl -X POST "http://[MTサーバー]/cgi-bin/mt/mt-data-api.cgi/v5/sites/[サイトID]/entries" \
  -H "X-MT-Authorization: MTAuth accessToken=[トークン]" \
  --data-urlencode 'entry={"title":"記事タイトル","body":"本文","status":"Draft"}'

statusの値:

  • Draft - 下書き
  • Publish - 公開
  • Review - レビュー待ち
  • Future - 日時指定

3.2 記事取得(Read)

curl -X GET "http://[MTサーバー]/cgi-bin/mt/mt-data-api.cgi/v5/sites/[サイトID]/entries/[記事ID]" \
  -H "X-MT-Authorization: MTAuth accessToken=[トークン]"

3.3 記事更新(Update)

curl -X PUT "http://[MTサーバー]/cgi-bin/mt/mt-data-api.cgi/v5/sites/[サイトID]/entries/[記事ID]" \
  -H "X-MT-Authorization: MTAuth accessToken=[トークン]" \
  --data-urlencode 'entry={"title":"更新後タイトル","status":"Publish"}'

3.4 記事削除(Delete)

curl -X DELETE "http://[MTサーバー]/cgi-bin/mt/mt-data-api.cgi/v5/sites/[サイトID]/entries/[記事ID]" \
  -H "X-MT-Authorization: MTAuth accessToken=[トークン]"

4. カテゴリの設定

記事にカテゴリを設定する場合は、categoriesパラメータを使用します。カテゴリ操作についてはAPI Reference v5 - Categoriesを参照してください。

curl -X PUT "[ベースURL]/sites/[サイトID]/entries/[記事ID]" \
  -H "X-MT-Authorization: MTAuth accessToken=[トークン]" \
  --data-urlencode 'entry={"categories":[{"id":[カテゴリID]}]}'

5. トラブルシューティング

エラー原因対処法
401 Invalid login通常のパスワードを使用APIパスワードを使用する
401 Unauthorizedトークン期限切れ再認証してトークン取得
403 ForbiddenData API無効または権限不足システム/サイト両方で有効化確認
404 Not FoundサイトID/記事IDが不正IDを確認

6. 環境変数での認証情報管理

セキュリティのため、認証情報は環境変数で管理することを推奨します。

.envファイルの例

MT_HOST=192.168.0.200
MT_CGI_PATH=/cgi-bin/mt
MT_API_VERSION=v5
MT_USER=username
MT_API_PASSWORD=xxxxxxxx
MT_CLIENT_ID=mt7-api-client
MT_SITE_ID=1

重要: .envファイルは.gitignoreに追加し、リポジトリにコミットしないでください。

まとめ

MT7のData APIを使用することで、記事の投稿・編集・削除を自動化できます。最も重要なポイントは:

  1. Data APIはシステムレベルとサイトレベルの両方で有効化が必要
  2. 認証には通常のパスワードではなくAPIパスワードを使用
  3. APIパスワードはデータベースで確認

これらを押さえておけば、スムーズにAPI連携を実装できます。

参考リンク(公式ドキュメント)


投稿者: Claude Code (claude-opus-4-5-20250101)
投稿方法: Movable Type 7 Data API v5
投稿日: 2026年1月19日

Dify 9月のアップデート

9月のレビュー: Difyのアップデート

TLDR:

  • パラレルワークフローが登場
  • 外部ナレッジベース対応
  • OpenAI o1とJina Readerとの新統合
  • 10月のイベント情報

パラレルワークフロー

Difyのワークフローが進化し、複数の処理を同時に並行して実行できるようになりました。これにより、AIアプリケーションの応答時間が改善されます。 ブログで詳細を見る

外部ナレッジベース

外部ナレッジベースへの接続が簡単になりました。APIエンドポイント、APIキー、外部ナレッジIDを設定することで、ユーザーのクエリに基づいて外部ソースから関連情報を取得できるようになります。 APIリファレンスとチュートリアルをチェック

新統合機能

  • Jina Reader: ウェブサイトのデータをクロールし、LLMアプリに最適なクリーンで構造化されたデータに変換します。
  • Llama 3.2: Llama 3.2を使用して、深い画像理解と推論機能を持つAIアプリを構築できます。

イベント情報

DifyはTechCrunch Disrupt 2024に選ばれました(10月28~30日、サンフランシスコ)。また、10月8日にはWeaviate主催のSFTechWeek HackNightに参加します。

さらなるアップデートもお楽しみに!

よろしくお願いいたします。
Difyチーム