ChatGPT/Claude以降、社内ドキュメント・議事録・ガイドラインをLLMに与えるRAG(Retrieval-Augmented Generation)の実装ニーズが急増している。しかし多くの実装は「pgvectorを使う」「FAISSを使う」といった専用ベクトルDB前提で語られ、既存のMySQL/PostgreSQL運用にRAGを組み込みたい現場のニーズに応えていない。本記事ではrenueが自社で運用しているRAG基盤の実装コードをもとに、OpenAI text-embedding-3-small + MySQL BLOBカラム + Python struct.packという「追加インフラゼロ」のRAGアーキテクチャを解説する。
なぜ専用ベクトルDBを使わないのか
RAGチュートリアルの多くはpgvector/Pinecone/Weaviate/Chroma等の専用ベクトルDBを前提とするが、実際の中小規模RAG(〜10万件)では以下の理由で既存DBへの埋め込みが現実解となる。
- 運用コスト: 新規DBの導入は監視・バックアップ・アップグレードを増やす
- データ整合性: 本体データ(ガイドライン/議事録)と埋め込みを別DBで管理すると削除・更新の整合性が崩れる
- トランザクション: 本体レコードと埋め込みを同一トランザクションで書き込みたい
- スケール閾値: 10万件程度ならPythonでの総当たりcosine計算でも数百ms
renueでは会社ガイドライン(110件超)・議事録(数千件)・プロジェクトメモ等を既存MySQLにVARBINARYカラムで格納し、Python側でcosine計算している。この設計が1536次元×float32 = 6144バイト/行で済み、専用DB不要で十分な性能を出している。
レイヤー1: Embedding生成(text-embedding-3-small)
OpenAIのtext-embedding-3-smallは1536次元・$0.02/1M tokensで現時点で最もコスパが良い。L2正規化済み(単位ベクトル)なのでcosine類似度はドット積だけで計算できる。
from litellm import embedding
EMBEDDING_MODEL = "text-embedding-3-small"
EMBEDDING_DIMENSION = 1536
def generate_embedding_sync(text: str) -> List[float]:
response = embedding(model=EMBEDDING_MODEL, input=[text])
return response.data[0]["embedding"]
注目点は litellm 経由で呼んでいる点。OpenAI SDK直叩きではなくLiteLLMを噛ませることで、後でAzure OpenAI/Vertex AI等に切り替え可能なベンダーロックイン回避レイヤーを確保している。
バッチ生成の実装
def batch_generate_embeddings_sync(texts: List[str]) -> List[List[float]]:
# OpenAI APIは一度に最大2048個まで処理可能
batch_size = 100
all_embeddings = []
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
response = embedding(model=EMBEDDING_MODEL, input=batch)
all_embeddings.extend([item["embedding"] for item in response.data])
return all_embeddings
1回のAPIコールで複数テキストをまとめて送ることでレイテンシとコストを10倍以上削減できる。バッチサイズ100は「API上限2048」と「1リクエストのタイムアウトリスク」のバランス値。
レイヤー2: MySQL BLOBへの保存(struct.pack)
埋め込みをDBに保存する際、多くのチュートリアルはJSON.stringifyでテキスト化して保存する。これは6倍以上サイズが膨らむかつ読み込み時にparse負荷がかかる。
import struct
def embedding_to_bytes(embedding_data: List[float]) -> bytes:
# float32で1536次元 = 6144バイト
return struct.pack(f"{len(embedding_data)}f", *embedding_data)
def bytes_to_embedding(data: bytes) -> List[float]:
count = len(data) // 4 # float32 = 4 bytes
return list(struct.unpack(f"{count}f", data))
サイズ比較
| 保存方式 | 1行あたりサイズ | 10万件の総サイズ | 読み込みコスト |
|---|---|---|---|
| JSON text | 約35,000 bytes | 約3.5 GB | JSON.parse必要 |
| struct.pack (float32) | 6,144 bytes | 約614 MB | unpack一発 |
| float16量子化 | 3,072 bytes | 約307 MB | 精度低下あり |
MySQLではVARBINARY(6144)またはBLOBカラムを使えば十分。PostgreSQLならbytea。SQLAlchemyモデル側ではColumn(LargeBinary)で受け取る。
レイヤー3: コサイン類似度検索
embedding-3-smallは正規化済みなので、厳密にはドット積で良いのだが、保守性のため明示的にcosine計算しておく。
import numpy as np
def cosine_similarity(a: List[float], b: List[float]) -> float:
a_np = np.array(a)
b_np = np.array(b)
return float(np.dot(a_np, b_np) / (np.linalg.norm(a_np) * np.linalg.norm(b_np)))
def find_similar_embeddings(
query_embedding: List[float],
embeddings: List[Tuple[int, bytes]],
threshold: float = 0.7,
limit: int = 10,
) -> List[Tuple[int, float]]:
results = []
for item_id, embedding_bytes in embeddings:
if embedding_bytes is None:
continue
item_embedding = bytes_to_embedding(embedding_bytes)
similarity = cosine_similarity(query_embedding, item_embedding)
if similarity >= threshold:
results.append((item_id, similarity))
results.sort(key=lambda x: x[1], reverse=True)
return results[:limit]
性能特性
10万件でPython単純ループだと数秒かかるが、NumPyベクトル化で数十msに短縮できる。具体的には一括デシリアライズ後にnp.dot(matrix, query)を一発で計算する。
# 高速化版(NumPyベクトル化)
def find_similar_fast(query_embedding, embeddings_list):
# (N, 1536) の行列を一度に作る
matrix = np.array([bytes_to_embedding(b) for _, b in embeddings_list])
query = np.array(query_embedding)
# 正規化済みならnp.dotだけで類似度
similarities = matrix @ query
top_indices = np.argsort(-similarities)[:10]
return [(embeddings_list[i][0], similarities[i]) for i in top_indices]
レイヤー4: 検索対象テキストの整形(最重要)
RAG実装で最も精度を左右するのが「何を埋め込むか」だ。生のcontent丸ごとは長すぎてノイズを含む。renueではtitle + summary + keywordsの3要素に絞って埋め込む設計を採用している。
def prepare_guideline_text_for_embedding(
title: str,
content: str,
summary: Optional[str] = None,
keywords: Optional[List[str]] = None,
) -> str:
parts = [title]
if summary:
parts.append(summary)
else:
# summaryがない場合はcontentの先頭200文字を使用
parts.append(content[:200])
if keywords:
parts.append(" ".join(keywords))
return "
".join(parts)
なぜcontent全文を埋め込まないのか
- Context dilution問題: 長文を埋め込むと主要概念が希釈されて類似度が低下する
- トークンコスト: embedding-3-smallは$0.02/1M tokenだが全文だと数倍になる
- chunk境界問題: chunking前提だと「どこで切るか」の設計が複雑化する
- title/summaryの情報密度: 執筆者が要約時点で情報を圧縮済み — 検索軸としてはこれで十分
議事録版の実装
def prepare_meeting_text_for_embedding(
title: Optional[str] = None,
summarized_text: Optional[str] = None,
memo: Optional[str] = None,
company_department_name: Optional[str] = None,
) -> str:
parts = []
if title:
parts.append(title)
if company_department_name:
parts.append(company_department_name)
if memo:
parts.append(memo[:300])
if summarized_text:
summary = _extract_json_summary(summarized_text, max_chars=500)
if summary:
parts.append(summary)
return "
".join(parts)
議事録は「会議タイトル + プロジェクト名 + アジェンダ冒頭300字 + LLM要約500字」の順で結合している。プロジェクト名を含めることでテナント/案件を跨いだ混同を防ぐのが実装上の勘所。
レイヤー5: LLMのJSON要約を安全に取り出す
議事録要約はLLMが生成したJSON文字列になっていることが多い。生JSONをそのまま埋め込むと{"summary":のような構造ノイズが混入するため、要約フィールドだけ安全に抽出する必要がある。
def _extract_json_summary(raw: Optional[str], max_chars: int = 500) -> str:
# JSONテキストからsummaryフィールドを抽出、失敗したらプレーンテキストとして截断
if not raw or not isinstance(raw, str):
return ""
raw = raw.strip()
if not raw:
return ""
try:
import json
parsed = json.loads(raw)
if isinstance(parsed, dict):
summary = parsed.get("summary", "")
if isinstance(summary, str) and summary.strip():
return summary.strip()[:max_chars]
except (ValueError, TypeError):
pass
return raw[:max_chars]
ポイントは「JSON parseに失敗してもプレーンテキストとして截断するフォールバック」。LLM出力の揺らぎに対する防御としてこのパターンは他のRAG実装にも転用できる。
スケール戦略: いつ専用ベクトルDBに移行するか
| データ件数 | 推奨構成 | 検索レイテンシ(P95) |
|---|---|---|
| 〜1万件 | MySQL BLOB + Python NumPy | 50-150ms |
| 1-10万件 | MySQL BLOB + NumPy一括計算 | 100-500ms |
| 10-100万件 | pgvector + HNSW index | 10-50ms |
| 100万件超 | Pinecone/Qdrant/Weaviate | 5-30ms |
10万件までは今回の構成で十分戦える。SaaS向けの社内ドキュメント検索/ガイドライン検索/議事録検索は多くの場合この範囲に収まる。
まとめ: 追加インフラゼロでRAGを始める
- litellm + text-embedding-3-smallで1536次元ベクトルを生成
- struct.pack(float32)でMySQL BLOBに6KB/行で格納
- title + summary + keywordsの3要素に絞って埋め込む(content丸ごとはNG)
- NumPy一括計算で10万件を数十msで検索
- LLMのJSON出力は安全にsummary抽出してフォールバック
この構成は本番運用中で、110件超の社内ガイドラインと数千件の議事録から瞬時に関連情報をRAG取得できている。専用ベクトルDB導入の前に、まずはこの「既存DB + BLOB + Python」構成を試してから判断してほしい。
よくある質問
Q. text-embedding-3-largeではダメですか?
smallは3072次元・$0.13/1M tokenで精度は確かに高いが、社内ドキュメント検索の精度向上は誤差レベル。6倍のストレージと6倍のコストを正当化するユースケースは少ない。multi-language/multi-lingualな大規模検索なら検討価値あり。
Q. Azure OpenAI版でも動きますか?
litellm経由で書いているためazure/text-embedding-3-smallにモデル指定を変えるだけで動く。認証情報は環境変数切り替えで対応。
Q. 日本語の精度は?
text-embedding-3-smallは多言語対応。日本語の社内ドキュメント検索でも実用的な精度が出る。英日混在ドキュメントも問題なく扱える。
