Home 論文解説: Ruri — 日本語汎用テキスト埋め込みモデル
投稿
キャンセル

📄 論文解説: Ruri — 日本語汎用テキスト埋め込みモデル

論文概要(Abstract)

Ruriは、国立情報学研究所(NII)のTsukagoshiらによって提案された日本語汎用テキスト埋め込みモデルである。英語圏で発展した大規模埋め込みモデルの手法を日本語に適用し、弱教師あり事前学習・LLM合成データによる中間学習・教師あり微調整の3段階パイプラインで学習する。著者らは同時に日本語埋め込み評価基盤JMTEB(Japanese Massive Text Embedding Benchmark)を構築・公開しており、Ruriモデル群がJMTEBの複数タスクで既存の多言語モデルを上回ると報告している。

この記事は Zenn記事: MTEB×JMTEBで選ぶEmbeddingモデル:精度評価の実践ガイド の深掘りです。

情報源

  • arXiv ID: 2409.07737
  • URL: https://arxiv.org/abs/2409.07737
  • 著者: Hayato Tsukagoshi, Shuhei Kurita, Ikki Ohmukai(National Institute of Informatics)
  • 発表年: 2024
  • 分野: cs.CL(Computation and Language)

背景と動機(Background & Motivation)

テキスト埋め込みモデルは、検索(RAG)、分類、クラスタリング、意味類似度計算など幅広いNLPタスクの基盤技術である。英語圏ではE5、GTE、BGEなどの高性能モデルが公開され、MTEBベンチマークによる体系的な評価が行われてきた。一方で日本語には以下の課題があった。

  1. 日本語特化モデルの不足: 多言語モデル(multilingual-e5等)は日本語で利用可能だが、日本語に最適化されたモデルは限られていた
  2. 評価基盤の欠如: 英語のMTEBに相当する包括的な日本語ベンチマークが存在しなかった
  3. 学習データの制約: 日本語の大規模高品質ペアデータは英語と比較して少ない

Ruriはこれらの課題に対し、LLMによる合成データ生成と体系的な多段階学習を組み合わせることで、日本語に特化した高性能埋め込みモデルを実現している。

主要な貢献(Key Contributions)

  • 日本語特化埋め込みモデル群: small(~33M)、base(~111M)、large(~337M)の3サイズを公開し、用途に応じた精度-速度トレードオフを提供
  • 3段階学習パイプライン: 弱教師あり事前学習→LLM合成データ中間学習→教師あり微調整の段階的手法を確立
  • JMTEB構築: 検索、STS、分類、クラスタリング、再ランキング、ペア分類の6カテゴリ・15以上のタスクからなる日本語ベンチマークを公開

技術的詳細(Technical Details)

アーキテクチャ

Ruriのエンコーダは日本語BERT系モデルをベースとするEncoder-only Transformerである。

モデルパラメータ数埋め込み次元ベースモデル
ruri-small~33M256日本語BERT-small系
ruri-base~111M768日本語BERT-base系
ruri-large~337M1024日本語BERT-large系

入力テキストをトークナイズし、エンコーダの[CLS]トークン出力をセンテンス埋め込みとして使用する。推論時にはInstruction Prefix("クエリ: ""パッセージ: ")を入力先頭に付与し、タスクを区別する。

3段階学習パイプライン

Stage 1: 弱教師あり事前学習

大規模ウェブコーパス(CC-100日本語部分、OSCAR等)およびWikipedia日本語版から、(タイトル, 本文)ペアなどの弱い対応関係を持つペアを収集する。これらをMultiple Negatives Ranking Loss(InfoNCE)で学習し、埋め込み空間を初期化する。

\[\mathcal{L}_{\text{InfoNCE}} = -\log \frac{\exp(\text{sim}(q, d^{+}) / \tau)}{\sum_{j=1}^{B} \exp(\text{sim}(q, d_j) / \tau)}\]

ここで、

  • $q$: クエリの埋め込み
  • $d^{+}$: 正例ドキュメントの埋め込み
  • $B$: バッチ内のサンプル数(In-batch negatives)
  • $\tau$: 温度パラメータ(0.02〜0.05)
  • $\text{sim}(\cdot, \cdot)$: コサイン類似度

この段階ではノイジーなペアを大量に使うため、埋め込み空間の粗い構造を形成する。

Stage 2: 合成データによる中間学習

LLM(GPT-4等)を使い、既存の日本語コーパスのパッセージに対して疑似クエリを生成する。例えば、Wikipediaの記事やウェブテキストを入力として、「このパッセージに対して自然な検索クエリを生成せよ」という指示でクエリを合成する。生成した数百万件のペアに対して、Hard Negative Mining(HN Mining)を適用する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def hard_negative_mining(
    query: str,
    positive: str,
    corpus: list[str],
    model: SentenceTransformer,
    bm25_index: BM25,
    top_k: int = 50,
    hard_neg_count: int = 5,
) -> list[str]:
    """BM25 + 埋め込みモデルによるHard Negative Mining

    Args:
        query: クエリテキスト
        positive: 正例パッセージ
        corpus: 全パッセージコーパス
        model: 中間段階のエンコーダ
        bm25_index: BM25インデックス
        top_k: BM25候補数
        hard_neg_count: 最終的なHN数

    Returns:
        Hard negativeパッセージのリスト
    """
    # Step 1: BM25で候補取得
    bm25_candidates = bm25_index.get_top_n(query, corpus, n=top_k)

    # Step 2: エンコーダで再ランキング
    query_emb = model.encode(query)
    cand_embs = model.encode(bm25_candidates)
    scores = cosine_similarity(query_emb, cand_embs)

    # Step 3: 正例を除外し、上位をHNとして選択
    hard_negatives = []
    for idx in scores.argsort()[::-1]:
        if bm25_candidates[idx] != positive:
            hard_negatives.append(bm25_candidates[idx])
        if len(hard_negatives) >= hard_neg_count:
            break

    return hard_negatives

このステップでは、BM25で表層的に類似するがセマンティクスが異なる文書を「難しい負例」として学習に組み込む。これにより、モデルは表層的な一致ではなく意味的な関連性を学習する。

Stage 3: 教師あり微調整

MIRACL-ja(多言語情報検索)、Mr. TyDi-ja(多言語QA)、JQaRA(日本語QA検索)、JSNLI(日本語NLI)等のラベル付きデータを使い、検索・QA・NLI・STSのマルチタスクで微調整する。

Instruction Prefix

Ruriは推論時にタスク種別を示すプレフィックスを入力に付与する。

1
2
3
4
5
6
7
8
9
10
11
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("cl-nagoya/ruri-large")

# 検索タスク: クエリとパッセージで異なるプレフィックス
query = "クエリ: 日本語の埋め込みモデルの比較方法"
passage = "パッセージ: テキスト埋め込みモデルは文章をベクトルに変換する技術です。"

q_emb = model.encode(query)
p_emb = model.encode(passage)
similarity = q_emb @ p_emb / (np.linalg.norm(q_emb) * np.linalg.norm(p_emb))

このプレフィックスを省略すると性能が低下するため、利用時には注意が必要である。

実装のポイント(Implementation)

学習時のハイパーパラメータ:

  • バッチサイズ: 1024〜2048(In-batch Negativesの効果を最大化するため大きめに設定)
  • 学習率: 1e-5〜3e-5
  • 温度パラメータ $\tau$: 0.02〜0.05
  • エポック数: タスクに応じて1〜5

推論時の注意点:

  • Instruction Prefix("クエリ: "/"パッセージ: ")の付与を忘れないこと。E5系モデルの"query: "/"passage: "に対応する日本語版である
  • L2正規化をコサイン類似度計算前に適用する
  • sentence-transformersライブラリ経由での利用が推奨される
  • HuggingFace Hub: cl-nagoya/ruri-large, cl-nagoya/ruri-base, cl-nagoya/ruri-small

ライセンス: Apache 2.0(商用利用可)

Production Deployment Guide

AWS実装パターン(コスト最適化重視)

Ruriモデルを日本語RAGシステムのRetrieverとしてデプロイする構成を示す。

トラフィック量別の推奨構成:

構成トラフィック月額コスト概算サービス構成
Small~100 req/日$50-150Lambda + OpenSearch Serverless + S3
Medium~1000 req/日$300-800ECS Fargate + OpenSearch + ElastiCache
Large10000+ req/日$2,000-5,000EKS + Spot + OpenSearch + ElastiCache

Small構成の詳細:

  • Lambda: ARM64, 1024MB RAM, 30秒タイムアウト(ruri-baseの推論に十分)
  • OpenSearch Serverless: 2 OCU(インデックス用+検索用)、HNSW kNN
  • S3: モデル重みキャッシュ、EFS経由でLambdaにマウント
  • 月額: Lambda $5 + OpenSearch Serverless $50 + EFS $10 + その他 = $65-100

Large構成の詳細:

  • EKS: m5.xlarge × 2(コントロールプレーン)、g5.xlarge Spot(GPU推論ノード)
  • OpenSearch: r6g.large.search × 2(マルチAZ)
  • ElastiCache: r6g.large(埋め込みキャッシュ)
  • 月額: EKS $150 + EC2 Spot $300 + OpenSearch $500 + ElastiCache $200 + その他 = $1,500-3,000

コスト削減テクニック:

  • Spot Instances活用(g5.xlargeでオンデマンド比最大70%削減)
  • 埋め込みキャッシュ(ElastiCache)で同一テキストの再計算を回避
  • ruri-small/baseによる精度と推論速度のトレードオフ調整

注意: 上記コストはAWS ap-northeast-1(東京)リージョンの2026年2月時点の概算値です。実際のコストはトラフィックパターン、バースト使用量により変動します。最新料金はAWS料金計算ツールで確認してください。

Terraformインフラコード

Small構成(Serverless):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# Ruri埋め込みモデル Serverless構成
# Lambda + EFS + OpenSearch Serverless

terraform {
  required_version = ">= 1.9"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.80"
    }
  }
}

provider "aws" {
  region = "ap-northeast-1"
}

# VPC(NAT Gateway不使用でコスト削減)
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  tags = { Name = "ruri-embedding-vpc" }
}

resource "aws_subnet" "private" {
  count             = 2
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.${count.index + 1}.0/24"
  availability_zone = data.aws_availability_zones.available.names[count.index]
  tags = { Name = "ruri-private-${count.index}" }
}

data "aws_availability_zones" "available" {
  state = "available"
}

# EFS(モデル重み格納)
resource "aws_efs_file_system" "model_store" {
  encrypted  = true
  kms_key_id = aws_kms_key.main.arn
  tags       = { Name = "ruri-model-weights" }
}

resource "aws_efs_mount_target" "model" {
  count           = 2
  file_system_id  = aws_efs_file_system.model_store.id
  subnet_id       = aws_subnet.private[count.index].id
  security_groups = [aws_security_group.efs.id]
}

# KMS暗号化キー
resource "aws_kms_key" "main" {
  description             = "Ruri embedding service encryption"
  deletion_window_in_days = 7
}

# Lambda関数(ARM64でコスト最適化)
resource "aws_lambda_function" "ruri_encoder" {
  function_name = "ruri-text-encoder"
  runtime       = "python3.12"
  handler       = "handler.encode"
  architectures = ["arm64"]
  memory_size   = 1024
  timeout       = 30

  role = aws_iam_role.lambda_role.arn

  file_system_config {
    arn              = aws_efs_access_point.model.arn
    local_mount_path = "/mnt/model"
  }

  vpc_config {
    subnet_ids         = aws_subnet.private[*].id
    security_group_ids = [aws_security_group.lambda.id]
  }

  environment {
    variables = {
      MODEL_NAME = "cl-nagoya/ruri-base"
      MODEL_PATH = "/mnt/model"
    }
  }
}

# IAMロール(最小権限)
resource "aws_iam_role" "lambda_role" {
  name = "ruri-lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action    = "sts:AssumeRole"
      Effect    = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_vpc" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
}

# CloudWatchアラーム(コスト監視)
resource "aws_cloudwatch_metric_alarm" "lambda_duration" {
  alarm_name          = "ruri-lambda-high-duration"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 3
  metric_name         = "Duration"
  namespace           = "AWS/Lambda"
  period              = 300
  statistic           = "Average"
  threshold           = 20000
  alarm_actions       = [aws_sns_topic.alerts.arn]
  dimensions = {
    FunctionName = aws_lambda_function.ruri_encoder.function_name
  }
}

resource "aws_sns_topic" "alerts" {
  name = "ruri-embedding-alerts"
}

Large構成(Container):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# EKS + Karpenter + Spot Instances
module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 20.31"

  cluster_name    = "ruri-embedding-cluster"
  cluster_version = "1.31"
  vpc_id          = aws_vpc.main.id
  subnet_ids      = aws_subnet.private[*].id

  cluster_endpoint_public_access = false
  enable_irsa                    = true
}

# Karpenter Provisioner(Spot優先)
resource "kubectl_manifest" "karpenter_provisioner" {
  yaml_body = yamlencode({
    apiVersion = "karpenter.sh/v1beta1"
    kind       = "NodePool"
    metadata   = { name = "ruri-gpu-spot" }
    spec = {
      template = {
        spec = {
          requirements = [
            { key = "karpenter.sh/capacity-type", operator = "In", values = ["spot", "on-demand"] },
            { key = "node.kubernetes.io/instance-type", operator = "In", values = ["g5.xlarge", "g5.2xlarge"] },
          ]
          nodeClassRef = { name = "default" }
        }
      }
      limits   = { cpu = "32", memory = "128Gi" }
      disruption = { consolidationPolicy = "WhenUnderutilized" }
    }
  })
}

# AWS Budgets(予算アラート)
resource "aws_budgets_budget" "monthly" {
  name         = "ruri-monthly-budget"
  budget_type  = "COST"
  limit_amount = "3000"
  limit_unit   = "USD"
  time_unit    = "MONTHLY"

  notification {
    comparison_operator       = "GREATER_THAN"
    threshold                 = 80
    threshold_type            = "PERCENTAGE"
    notification_type         = "FORECASTED"
    subscriber_email_addresses = ["ops@example.com"]
  }
}

運用・監視設定

CloudWatch Logs Insights — 推論レイテンシ分析:

1
2
3
4
5
6
7
8
fields @timestamp, @message
| filter @message like /encode_duration_ms/
| stats avg(encode_duration_ms) as avg_latency,
        percentile(encode_duration_ms, 95) as p95,
        percentile(encode_duration_ms, 99) as p99,
        count(*) as request_count
  by bin(1h) as hour
| sort hour desc

CloudWatch アラーム設定(Python):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import boto3

cloudwatch = boto3.client("cloudwatch", region_name="ap-northeast-1")

cloudwatch.put_metric_alarm(
    AlarmName="ruri-high-latency-p95",
    MetricName="EncodeDurationMs",
    Namespace="RuriEmbedding",
    Statistic="p95",
    Period=300,
    EvaluationPeriods=3,
    Threshold=5000,
    ComparisonOperator="GreaterThanThreshold",
    AlarmActions=["arn:aws:sns:ap-northeast-1:123456789:ruri-alerts"],
)

X-Rayトレーシング設定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from aws_xray_sdk.core import xray_recorder, patch_all

patch_all()

@xray_recorder.capture("encode_text")
def encode_text(text: str, model_name: str = "ruri-base") -> list[float]:
    """テキストを埋め込みベクトルに変換"""
    subsegment = xray_recorder.current_subsegment()
    subsegment.put_annotation("model", model_name)
    subsegment.put_metadata("text_length", len(text))

    embedding = model.encode(f"クエリ: {text}")
    subsegment.put_metadata("embedding_dim", len(embedding))
    return embedding.tolist()

Cost Explorer日次レポート:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import boto3
from datetime import date, timedelta

ce = boto3.client("ce", region_name="us-east-1")

def get_daily_cost() -> dict:
    """日次コストレポート取得"""
    end = date.today().isoformat()
    start = (date.today() - timedelta(days=1)).isoformat()

    response = ce.get_cost_and_usage(
        TimePeriod={"Start": start, "End": end},
        Granularity="DAILY",
        Metrics=["UnblendedCost"],
        Filter={
            "Tags": {
                "Key": "Project",
                "Values": ["ruri-embedding"],
            }
        },
    )
    return response["ResultsByTime"][0]

コスト最適化チェックリスト

アーキテクチャ選択:

  • トラフィック量を測定し、Serverless/Hybrid/Containerを選択
  • ruri-small/base/largeのモデルサイズをタスク精度要件に応じて選定
  • GPU推論が必要か検討(ruri-baseまではCPU推論で十分な場合あり)

リソース最適化:

  • EC2: Spot Instances優先(g5.xlargeでオンデマンド比70%削減)
  • Reserved Instances: 安定ワークロードには1年コミット
  • Savings Plans: コンピュートワークロード全体で検討
  • Lambda: ARM64アーキテクチャ選択(x86比20%コスト削減)
  • Lambda: メモリサイズをPower Tuningで最適化

埋め込み固有のコスト削減:

  • 埋め込みキャッシュ(ElastiCache/DynamoDB)で同一テキストの再計算回避
  • バッチエンコード(複数テキストを一括処理)でスループット向上
  • ruri-smallでの初期フィルタリング → ruri-largeでの精密リランキング(2段階検索)
  • ONNX Runtime変換によるCPU推論の高速化

監視・アラート:

  • AWS Budgets: 月額予算アラート設定
  • CloudWatch: 推論レイテンシP95監視
  • Cost Anomaly Detection: 日次コスト異常検知
  • 日次コストレポート: SNS通知

リソース管理:

  • 未使用OpenSearchインデックスの定期削除
  • EFSライフサイクルポリシー(非アクセスファイルのIA移行)
  • タグ戦略: Project/Environment/Modelタグ必須
  • 開発環境: 夜間・週末のEKSノード停止
  • CloudTrail/Config有効化で監査証跡確保

実験結果(Results)

JMTEBベンチマーク

著者らが構築したJMTEBは6カテゴリ・15以上のタスクで構成される。論文Table 2〜4より、主要モデルとの比較を示す。

モデルパラメータRetrieval (NDCG@10)STS (Spearman)Classification
ruri-large~337M~73-75%~85-88%上位
ruri-base~111M上位--
multilingual-e5-large~560M~68-72%~82-85%比較対象
text-embedding-ada-002非公開~65-70%-比較対象

著者らの報告によると、ruri-largeはJMTEB全体スコアで既存の日本語・多言語モデルの中で最高水準の性能を示した。Retrievalタスクでは、パラメータ数が多いmultilingual-e5-largeを上回り、OpenAIのtext-embedding-ada-002と比較しても同等以上の結果である。

一方、STSタスクの一部では多言語モデルに劣る場合がある。著者らはこれをSTS特化の事前学習データが不足していることに起因すると分析している。

モデルサイズと精度のトレードオフ

ruri-small(~33M)はリソース制約環境向けで推論速度が速いが、精度はruri-largeと比較して低下する。ruri-base(~111M)は精度と速度のバランスが取れており、多くのプロダクション用途に適する。

実運用への応用(Practical Applications)

日本語RAGシステムのRetriever: Ruriは日本語検索タスクに特化しているため、日本語文書を対象としたRAGパイプラインのRetrieverとして活用できる。Zenn記事で紹介されているJMTEBベンチマークのRetrievalスコアが示す通り、多言語モデルよりも日本語検索精度が高い。

2段階検索: ruri-smallで高速に候補を絞り込み、ruri-largeで精密にリランキングする構成が考えられる。これにより、大規模コーパスでも精度を維持しながらレイテンシを抑制できる。

sentence-transformers互換: SentenceTransformer("cl-nagoya/ruri-large")で即座に利用可能であり、既存のPythonパイプラインへの統合が容易である。Apache 2.0ライセンスにより商用利用も可能。

注意点: 英語や多言語コンテンツが混在する環境では、multilingual-e5-largeなどの多言語モデルが適する場合がある。Ruriは日本語に特化しているため、対象コーパスの言語構成を考慮して選定すべきである。

関連研究(Related Work)

  • E5 (Wang et al., 2022): テキスト埋め込みのInstruction Tuning手法。Ruriの"クエリ: "プレフィックスはE5の"query: "に相当する日本語版である
  • MTEB (Muennighoff et al., 2022): 英語の大規模埋め込みベンチマーク。JMTEBはこの日本語版として設計された(別記事で解説: MTEB論文解説
  • BGE (Xiao et al., 2023): 中国語・英語のバイリンガル埋め込みモデル。Ruriと同様にLLM合成データを活用した学習パイプラインを採用している

まとめと今後の展望

Ruriは日本語に特化した汎用テキスト埋め込みモデルとして、JMTEBベンチマークの複数タスクで既存モデルを上回る性能を示した。3段階学習パイプラインとLLM合成データの組み合わせは、学習データが限られる言語での埋め込みモデル構築に有効なアプローチである。

今後の課題として、著者らはSTS性能の改善や専門ドメイン(医療・法律等)への対応を挙げている。また、Matryoshka Representation Learning(別記事で解説: MRL論文解説)との統合による可変次元埋め込みの実現も有望な方向性である。

参考文献

この投稿は CC BY 4.0 でライセンスされています。