株式会社renue
AI導入・DXの悩みをプロに相談してみませんか?
AIやDXに関する悩みがありましたら、お気軽にrenueの無料相談をご利用ください。 renueのAI支援実績、コンサルティングの方針や進め方をご紹介します。
ChatGPT/Claude以降、社内ドキュメント・議事録・ガイドラインをLLMに与えるRAG(Retrieval-Augmented Generation)の実装ニーズが急増している。しかし多くの実装は「pgvectorを使う」「FAISSを使う」といったベクトル検索基盤前提で語られ、既存のMySQL/PostgreSQL運用にRAGを組み込みたい現場のニーズに応えていない。本記事ではRAG基盤の一般的な実装例をもとに、OpenAI text-embedding-3-small + MySQL BLOBカラム + Python struct.packという「追加インフラゼロ」のRAGアーキテクチャを解説する。
なぜ専用ベクトルDBを使わないのか
RAGの検索基盤にはPinecone/Weaviate/Chroma等のベクトルDBのほか、PostgreSQL拡張のpgvectorや検索ライブラリのFAISSがある。中小規模RAG(例えば〜10万件)でも、以下の運用条件と実測性能をもとに既存DBへの保存を検討できる。
- 運用コスト: 新規DBの導入は監視・バックアップ・アップグレードを増やす
- データ整合性: 本体データ(ガイドライン/議事録)と埋め込みを別DBで管理すると削除・更新の整合性が崩れる
- トランザクション: 本体レコードと埋め込みを同一トランザクションで書き込みたい
- スケール閾値: 10万件でも、次元数・CPU・並列数・DB読み込みを含む範囲で総当たりcosine計算を実測して判断する
社内文書を既存MySQLにVARBINARYカラムで格納し、Python側でcosine計算している。この設計が1536次元×float32 = 6144バイト/行となる。専用DBが必要かは、DB読み込みを含む検索応答時間と同時実行数で判断する。
レイヤー1: Embedding生成(text-embedding-3-small)
OpenAIのtext-embedding-3-smallは既定1536次元で、2024年の発表単価は$0.02/1M tokensだった。最新料金と検索品質を比較して選ぶ(発表時の単価、モデル仕様)。L2正規化済み(単位ベクトル)なのでcosine類似度はドット積だけで計算できる。
この構成では litellm 経由の呼び出しを想定する。OpenAI SDK直叩きではなくLiteLLMを噛ませることで、後でAzure OpenAI/Vertex AI等に切り替え可能なベンダーロックイン回避レイヤーを確保している。
複数テキストのまとめ生成
1回のAPIコールに複数テキストを配列で渡すと、呼び出し回数を減らせる。ただし通常APIの料金は入力トークン数に基づくため、まとめるだけで単価が下がるわけではない。入力配列の上限2048件に加え、各入力8192トークン、1リクエスト合計300,000トークンの制限を確認する。バッチサイズ100は試す候補の1つとし、入力長とタイムアウトを測って調整する(入力仕様)。別機能の非同期Batch APIは通常APIより50%安く、24時間以内の処理を前提とする(Batch API)。
レイヤー2: MySQL BLOBへの保存(struct.pack)
埋め込みをDBに保存する際、多くのチュートリアルはJSON.stringifyでテキスト化して保存する。JSONの保存サイズは各数値の桁数・表記に依存し、固定の倍率ではない。また読み込み時にJSONのparse処理が必要になる。
サイズ比較
1536次元のベクトル本体を比較する。float32は1要素4バイト、float16は2バイトなので、それぞれ1536×4、1536×2で計算できる。総サイズは10万件を乗じた値で、DBの行管理・インデックス等の領域は含めない(Python structの型サイズ)。
| 保存方式 | 1行あたりサイズ | 10万件の総サイズ | 読み込みコスト |
|---|---|---|---|
| JSON text | 数値の桁数・表記で変動 | 実測した平均行サイズ×10万件 | 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計算しておく。
性能特性
10万件の検索では、Python単純ループとNumPyベクトル化を同じ条件で比較する。NumPyのドット積は利用可能なら最適化されたBLASを使うが、所要時間はCPU・メモリ・次元数等に依存する。DBからの読み込み、デシリアライズ、計算を分けて測定する(NumPy dotの仕様)。具体的には一括デシリアライズ後にnp.dot(matrix, query)を一発で計算する。
レイヤー4: 検索対象テキストの整形(最重要)
RAG実装で最も精度を左右するのが「何を埋め込むか」だ。生のcontent丸ごとは長すぎてノイズを含む。renueではtitle + summary + keywordsの3要素に絞って埋め込む設計を採用している。
なぜcontent全文を埋め込まないのか
- Context dilution問題: 長文を埋め込むと主要概念が希釈されて類似度が低下する
- トークンコスト: embedding-3-smallは$0.02/1M tokenだが全文だと数倍になる
- chunk境界問題: chunking前提だと「どこで切るか」の設計が複雑化する
- title/summaryの情報密度: 執筆者が要約時点で情報を圧縮済み — 検索軸としてはこれで十分
議事録版の実装
議事録は「会議タイトル + プロジェクト名 + アジェンダ冒頭300字 + LLM要約500字」の順で結合している。プロジェクト名は検索文脈の補助に使うが、それだけでテナント/案件を跨ぐアクセスは防げない。検索対象は認証済み利用者のテナントID等で絞り込み、アクセス権を別途検証する(RAGのアクセス制御)のが実装上の勘所。
レイヤー5: LLMのJSON要約を安全に取り出す
議事録要約はLLMが生成したJSON文字列になっていることが多い。生JSONをそのまま埋め込むと{"summary":のような構造ノイズが混入するため、要約フィールドだけ安全に抽出する必要がある。
ポイントは「JSON parseに失敗してもプレーンテキストとして截断するフォールバック」。LLM出力の揺らぎに対する防御としてこのパターンは他のRAG実装にも転用できる。
スケール戦略: いつ専用ベクトルDBに移行するか
| データ件数 | 検討する構成 | 検索レイテンシ(P95)の確認条件 |
|---|---|---|
| 〜1万件 | MySQL BLOB + Python NumPy | DB読み込みと計算を含め実測 |
| 1-10万件 | MySQL BLOB + NumPy一括計算 | 一括読み込み・メモリ常駐の違いを実測 |
| 10-100万件 | pgvector + HNSW index | HNSW設定・絞り込み・再現率と併せて実測 |
| 100万件超 | Pinecone/Qdrant/Weaviate | 製品構成・同時実行数・検索条件で実測 |
10万件を含む対象規模で、今回の構成が応答時間・メモリ・同時実行数の要件を満たすか確認する。SaaS向けの社内ドキュメント検索/ガイドライン検索/議事録検索では、チャンク数と増加見込みも含めて規模を見積もる。
まとめ: 追加インフラゼロでRAGを始める
- litellm + text-embedding-3-smallで1536次元ベクトルを生成
- struct.pack(float32)でMySQL BLOBに6KB/行で格納
- title + summary + keywordsの3要素に絞って埋め込む(content丸ごとはNG)
- NumPy一括計算で検索し、10万件を含む対象規模でDB読み込み込みの性能を測定
- LLMのJSON出力は安全にsummary抽出してフォールバック
この構成は社内ナレッジ検索の用途で十分に機能する。専用ベクトルDB導入の前に、まずはこの「既存DB + BLOB + Python」構成を試してから判断してほしい。




