🐼

RAGを自分で実装したくなったらまずこれ見て【ruri-v3 × Faiss】

に公開

この記事はLivetoon Tech Advent Calendar 2025の6日目の記事です。
https://adventar.org/calendars/12157

CTOの長嶋が担当です。
本日は、皆さんよく聞くRAGのお話です。

宣伝

https://kai0.onelink.me/Hogh/AdventCalendar2025

今回のアドベントカレンダーでは、LivetoonのAIキャラクターアプリのkaiwaに関わるエンジニアが、アプリの話からLLM・合成音声・インフラ監視・GPU・OSSまで、幅広くアドベントカレンダーとして書いて行く予定です。
是非、publicationをフォローして、記事を追ってみてください。

はじめに

「RAGってよく聞くけど、結局どう実装すればいいの?」
2025年、RAGはもはやバズワードの一種になりました。自分としてはRAGという言葉が出るか出ないぐらいから実装してきたので、昨今のRAGというワードの流行りをしかと感じています。

一方で、実際に実装してみたことがある人は少ないと思われます。

LangChainLlamaIndexはそういったRAGのツールを提供していますが、ブラックボックス感もありますし、LangChainに至ってはかなりカオスになっておりおすすめできません。今回は自分でRAGの仕組みを理解しながら実装したい——そんな方向けに、最小構成で動くRAG実装とおすすめの手法をお伝えします。

  • 埋め込みモデル: テキストをベクトルに変換
  • ベクトル検索: 類似ベクトルを高速に探す
  • チャンキング: テキストを適切な単位に分割

RAGとは

RAG(Retrieval-Augmented Generation)は、LLMに外部知識を与える手法です。

ユーザーの質問 → 関連文書を検索 → 検索結果と一緒にLLMへ → 回答生成

LLMは学習データにない情報を知りません。RAGを使えば、自社のドキュメントや最新情報をLLMに「読ませて」回答させることができます。

とてもシンプルな考え方ですよね?情報を知らなければとってこれば良いというアイデアです。

検索の種類:なぜベクトル検索なのか

RAGで主に使われる「ベクトル検索」とは何でしょうか?実は検索にもいくつかの種類があります。

完全一致検索(シンプル検索)

最も単純な検索。キーワードが完全に一致するものだけを返します。

クエリ: "心筋梗塞"
文書 ヒット
"心筋梗塞の治療法" ✅ ヒット
"心臓発作の症状" ❌ ヒットしない
"急性心筋梗塞について" ✅ ヒット

「心臓発作」は「心筋梗塞」と医学的にはほぼ同義ですが、文字列が違うのでヒットしません。

全文検索(転置インデックス)

Elasticsearchなどで使われる検索方式。形態素解析でトークン化し、転置インデックスを作成します。

クエリ: "心筋梗塞 症状"
文書 ヒット
"心筋梗塞の治療法" △ 部分一致
"心筋梗塞の典型的な症状" ✅ 高スコア
"心臓発作の兆候" ❌ ヒットしない

「心筋梗塞」「症状」という単語を含む文書が上位に来ます。ただし「心臓発作」「兆候」という同義語や言い換えには対応できません。

ベクトル検索(類似度検索)

テキストを意味ベクトルに変換し、ベクトル間の距離で検索します。これがRAGの核心です。

クエリ: "心筋梗塞の症状"
文書 類似度 ヒット
"心筋梗塞の典型的な症状" 0.95
"心臓発作の兆候" 0.89
"急性冠症候群の臨床像" 0.82
"今日の天気" 0.12

「心臓発作」「急性冠症候群」など、文字列は違うが意味的に近い文書もヒットします。これが「ふわっと検索」できる理由です。

比較まとめ

検索方式 仕組み 得意 苦手
完全一致 文字列比較 高速、確実 表記揺れに弱い
全文検索 転置インデックス 部分一致、AND/OR 同義語、言い換え
ベクトル検索 意味ベクトル 同義語、言い換え、ふわっと検索 計算コスト

RAGでは「ユーザーが何を知りたいか」という意図に近い文書を探す必要があります。だから「ふわっと検索」ができるベクトル検索が最適なのです。

ですので、通常、RAGというとベクトル検索とセットで語られることが多いです。
(本来的にはRAGという単語は別にWeb Searchでもいいわけです。)

アーキテクチャ

PDF → OCR(ページ単位) → 構造化(問題単位) → 埋め込み(ruri-v3) → Faiss検索

1. 埋め込み(Embedding)とは

テキストをベクトルに変換する

「埋め込み」とは、テキストを数百〜数千次元のベクトル(数値の配列)に変換することです。

"心筋梗塞の症状" → [0.12, -0.45, 0.78, ..., 0.33]  # 768次元

なぜベクトルにするのか?ベクトルにすれば「意味の近さ」を「距離の近さ」として計算できるからです。

"心筋梗塞の症状"  ←近い→  "心臓発作の兆候"
"心筋梗塞の症状"  ←遠い→  "今日の天気"

なぜruri-v3なのか

https://huggingface.co/cl-nagoya/ruri-v3-310m

埋め込みモデルは言語によって得意不得意があります。

モデル 次元数 特徴
OpenAI text-embedding-3-large 3072 高精度、有料、次元数大
multilingual-e5-large 1024 多言語対応、以前の定番
cl-nagoya/ruri-v3-310m 768 日本語特化、JMTEB SOTA

OpenAIのtext-embedding-3シリーズは手軽で多言語対応ですが、API呼び出しのたびに課金されます。大量データを扱う場合はコストが馬鹿になりません。また日本語に特化しているわけではないので、現状日本語タスクでの精度はruri-v3に劣ります。

以前はオープンウェイトな埋め込みモデルといえばmultilingual-e5が定番でしたが、今はruri-v3がデファクトだと考えています。

ruri-v3は名古屋大学が開発した日本語埋め込みモデルです。JMTEBベンチマーク(日本語テキスト埋め込みの評価指標)で平均77.2と、既存SOTAの75.5を大きく上回りました。最大8192トークン対応で長文にも強いのが特徴です。

ローカル実行できる=無料・高速・プライバシー保護という点も大きなメリットです。

筆者はruri-v3の登場から、基本これ以外のモデルは使ったことがないというくらいメインのモデルとして運用しています。
弊社のkaiwaというアプリも毎回の対話内容を記憶という形でベクトル化して保持しています。

実装

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("cl-nagoya/ruri-v3-310m", device="cuda")

# バッチ処理で効率化(1件ずつより高速)
embeddings = model.encode(texts, batch_size=8)

2. チャンキング戦略

チャンキングとは

「チャンキング」とは、長いテキストを検索に適した単位(チャンク)に分割することです。

以前は埋め込みモデルの入力上限が512トークン程度だったため、細かくチャンクを切る必要がありました。しかし今は8192トークンに対応したモデルも多く、PDF1ページ分くらいならそのまま入ります。

実際、私もPDF1ページをそのまま1チャンクとして扱うケースが増えています。

とはいえ、意味的なまとまりで区切るという原則は変わりません。1ページに複数の話題が混在している場合は、適切に分割したほうが検索精度は上がります。

本システムのチャンキング

今回は試験問題という特性上、「問題単位」でチャンク化しました。
1つの問題が1つの意味的なまとまりになっているため、これが最適な区切りです。

チャンク設計の考え方

ポイントはドメインに合わせたチャンク設計です。

データ種別 チャンク単位の例
試験問題 問題単位
契約書 条項単位
マニュアル セクション単位
議事録 発言単位 or ページ単位

迷ったらまずPDF1ページ単位で試してみて、精度が出なければ細かく分割する、という進め方でも良いと思います。

テキスト正規化

表記揺れがあると、同じ文書でも類似度が少し下がってしまいますので、正規化で統一しましょう。

import unicodedata

def normalize(s: str) -> str:
    s = unicodedata.normalize('NFKC', s)  # 全角半角統一
    s = s.replace(',', ',').replace('、', ',')
    s = s.replace('.', '.').replace('。', '.')
    return s

3. Faissによるベクトル検索

Faissとは

https://ai.meta.com/tools/faiss
https://github.com/facebookresearch/faiss

Faiss(Facebook AI Similarity Search)は、Metaが開発したベクトル類似検索ライブラリです。

特徴:

  • 高速: 100万ベクトルでもミリ秒単位で検索
  • GPU対応: CUDA対応で更に高速化
  • 柔軟性: 様々なアルゴリズムに対応

インストール

# CPU版(数万件程度ならこれで十分高速です)
pip install faiss-cpu

# GPU版(CUDA環境が必要)
pip install faiss-gpu

インデックス作成

Ruri-v3はコサイン類似度で学習されているため、Faiss側では IndexFlatIP(内積) を使い、ベクトル側を 正規化(長さを1にする) します。

import faiss
import numpy as np
from sentence_transformers import SentenceTransformer

# モデルロード
model = SentenceTransformer("cl-nagoya/ruri-v3-310m")

# Ruri-v3-310mの次元数
dim = 768

# 【重要】コサイン類似度用に「内積 (Inner Product)」のインデックスを作成
index = faiss.IndexFlatIP(dim)

# データの準備
texts = [
    "AIによる医療診断の可能性について...",
    "Faissを使った高速検索の実装...",
    "猫の画像生成に関する技術動向...",
]

# 埋め込み(ベクトル化)
embeddings = model.encode(texts, normalize_embeddings=True)

# Faissはfloat32型が必須
embeddings = embeddings.astype(np.float32)

# インデックスに登録
index.add(embeddings)

検索

検索クエリ側も同様に正規化を行います。

query = "医療AIの診断支援"

# クエリのベクトル化(ここでも正規化が必須)
query_vec = model.encode(query, normalize_embeddings=True)
query_vec = query_vec.reshape(1, -1).astype(np.float32)

# Top-K検索(上位3件を取得)
# distances: コサイン類似度スコア, indices: データのインデックス番号
distances, indices = index.search(query_vec, k=3)

# 結果表示
# IndexFlatIP + 正規化の場合、distances は -1.0 〜 1.0 の範囲になります
# 1.0に近いほど類似度が高いです
print(f"類似度: {distances[0][0]:.4f}") 
print(f"文書: {texts[indices[0][0]]}")

インデックスの種類(アルゴリズム選定)

データ規模と要件に応じて選択しますが、今回のRuriモデル(768次元)の場合の目安です。

IndexFlatIP(完全探索・内積)

index = faiss.IndexFlatIP(dim)

全ベクトルとの内積を計算します。正規化済みベクトル同士なら、これがそのままコサイン類似度検索になります。

  • 精度: 100%(最も正確)
  • 速度: データ量に比例(O(n))
  • 適用: 〜数万件程度
  • 備考: 今回の数千件という規模であれば、最も推奨される選択肢です。

IndexHNSWFlat(グラフベース)

# 内積距離を使用するHNSW
index = faiss.IndexHNSWFlat(dim, 32, faiss.METRIC_INNER_PRODUCT)

高速なグラフ探索アルゴリズムです。データが増えても速度が落ちにくいのが特徴です。

  • 精度: 98-99.9%
  • 速度: 非常に高速(O(log n))
  • 適用: 数万件〜
  • 注意: メモリ消費量が大きい

どれを選ぶか

データ規模 推奨 理由
〜数万件 IndexFlatIP 実装がシンプル、精度100%、学習不要。今回はこれ。
数万件〜 IndexHNSWFlat メモリは食うが爆速。現在の実務のデファクト。
100万件〜 IndexIVFPQ メモリに乗らない場合の選択肢(圧縮利用)。

本番運用するなら:Qdrant

Faissは優秀ですが、ライブラリでありデータベースではありません。本番運用では以下の課題があります:

  • インデックスの永続化を自前で実装する必要がある
  • データの追加・削除・更新が煩雑
  • メタデータでのフィルタリングが弱い

本格的に運用するなら、Qdrantがおすすめです。
https://qdrant.tech/

セルフホスティングしてもいいし、マネージドサービスを使うこともできます。

from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance

client = QdrantClient(host="localhost", port=6333)

# コレクション作成
client.create_collection(
    collection_name="questions",
    vectors_config=VectorParams(size=768, distance=Distance.COSINE)
)

# ベクトル登録(メタデータ付き)
client.upsert(
    collection_name="questions",
    points=[
        {"id": 1, "vector": embedding, "payload": {"year": 2024, "category": "循環器"}}
    ]
)

# フィルタリング検索
client.search(
    collection_name="questions",
    query_vector=query_vec,
    query_filter={"must": [{"key": "year", "range": {"gte": 2020}}]},
    limit=5
)

Rust製で高速、Docker一発で起動、メタデータフィルタリングも強力です。

4. まとめ

PDF → ページ単位OCR → 問題単位チャンク → ruri-v3埋め込み → Faiss検索
要素 選択 理由
埋め込み ruri-v3 日本語SOTA、無料、ローカル実行可
ベクトル検索 Faiss 高速、GPU対応、デファクト
チャンキング 2段階 ページ→ドメイン単位で精度向上

RAGの本質は「適切なチャンキング」と「良質な埋め込みモデル」です。LangChainなどのフレームワークを使う前に、まずはこの基本を押さえておくと、トラブル時に自分で対処できるようになります。

参考

Discussion