取り組みの背景 — RAG の概念を整理しつつ、なぜ今必要なのか
LLM(大規模言語モデル)は汎用的な知識を持っていますが、自社固有の情報——社内ドキュメント、API仕様書、ナレッジベース、顧客対応履歴——には対応できません。ファインチューニングは高コストで、データが更新されるたびに再学習が必要です。
RAG(Retrieval-Augmented Generation) はこの課題を解決するアーキテクチャです。ユーザーの質問に関連するドキュメントをベクトル検索で取得し、その情報をコンテキストとして LLM に渡すことで、正確で最新の回答を生成します。
Antigravity の AI 支援を使いながら、RAG パイプラインをゼロから組み立てていきます。想定読者は、Python の基礎があり、自分たちのデータの上で LLM を動かしたいエンジニアの方です。プロンプト設計に不安が残る場合は、先にAntigravity プロンプトエンジニアリング上級ガイド へ目を通していただくと、後半の生成フェーズが読みやすくなります。
ひとつ先にお伝えしておきたいことがあります。本記事のコードは Embedding モデルに gemini-embedding-001 を使用しています。長らく RAG のサンプルコードで定番だった text-embedding-004 は 2026年1月14日に停止済みで、いま呼び出しても 404 が返るだけです。移行はモデル名を1行書き換えるだけに見えて、次元数・正規化・タスクタイプの3点でつまずきます。私が実際に踏んだ順に、記事の後半で節を立ててまとめました。
RAG アーキテクチャの全体設計
RAG システムは大きく3つのフェーズで構成されます。
インジェストフェーズ(データ取り込み)
ドキュメントを読み込み、適切なサイズのチャンクに分割し、Embedding モデルでベクトル化してデータベースに格納します。
# Antigravity で生成した RAG インジェストパイプライン
# document_ingestor.py
import hashlib
import math
import os
from pathlib import Path
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import (
DirectoryLoader,
TextLoader,
PyPDFLoader,
UnstructuredMarkdownLoader,
)
from langchain_google_genai import GoogleGenerativeAIEmbeddings
import chromadb
from chromadb.config import Settings
# --- 設定 ---
CHROMA_PERSIST_DIR = "./chroma_db"
COLLECTION_NAME = "company_knowledge_v2" # 次元数を変えたら名前も変える
CHUNK_SIZE = 800 # トークン数ではなく文字数
CHUNK_OVERLAP = 200 # チャンク間のオーバーラップ
EMBED_MODEL = "gemini-embedding-001"
EMBED_DIM = 1536 # 推奨値は 768 / 1536 / 3072。3072 以外は自前で正規化する
def load_documents (source_dir: str ) -> list :
"""複数形式のドキュメントを一括読み込み"""
loaders = {
"**/*.txt" : TextLoader,
"**/*.md" : UnstructuredMarkdownLoader,
"**/*.pdf" : PyPDFLoader,
}
documents = []
for glob_pattern, loader_cls in loaders.items():
loader = DirectoryLoader(
source_dir,
glob = glob_pattern,
loader_cls = loader_cls,
show_progress = True ,
)
documents.extend(loader.load())
return documents
def chunk_documents (documents: list ) -> list :
"""ドキュメントを意味的に適切なサイズに分割"""
splitter = RecursiveCharacterTextSplitter(
chunk_size = CHUNK_SIZE ,
chunk_overlap = CHUNK_OVERLAP ,
separators = [ " \n ## " , " \n ### " , " \n\n " , " \n " , "。" , "." , " " ],
length_function = len ,
)
return splitter.split_documents(documents)
def l2_normalize (vec: list[ float ]) -> list[ float ]:
"""3072 次元未満を要求すると正規化されていないベクトルが返る"""
norm = math.sqrt( sum (v * v for v in vec))
return [v / norm for v in vec] if norm else vec
def chunk_id (chunk) -> str :
"""ソースと本文から安定した ID を作る(再実行しても重複しない)"""
source = chunk.metadata.get( "source" , "unknown" )
seed = f " { source } :: { chunk.page_content } " .encode( "utf-8" )
return hashlib.sha1(seed).hexdigest()
def create_embeddings_and_store (chunks: list ):
"""Embedding を生成し ChromaDB に格納"""
embeddings = GoogleGenerativeAIEmbeddings(
model = EMBED_MODEL ,
google_api_key = os.getenv( "GOOGLE_API_KEY" ),
output_dimensionality = EMBED_DIM ,
)
client = chromadb.PersistentClient(
path = CHROMA_PERSIST_DIR ,
settings = Settings( anonymized_telemetry = False ),
)
collection = client.get_or_create_collection(
name = COLLECTION_NAME ,
metadata = { "hnsw:space" : "cosine" }, # コサイン類似度
)
batch_size = 100 # Gemini API の1リクエスト上限
for i in range ( 0 , len (chunks), batch_size):
batch = chunks[i:i + batch_size]
texts = [chunk.page_content for chunk in batch]
metadatas = [chunk.metadata for chunk in batch]
ids = [chunk_id(chunk) for chunk in batch]
# 文書側は RETRIEVAL_DOCUMENT を明示する
vectors = embeddings.embed_documents(
texts,
task_type = "RETRIEVAL_DOCUMENT" ,
)
if EMBED_DIM != 3072 :
vectors = [l2_normalize(v) for v in vectors]
# add ではなく upsert。同じ本文を二度入れても増えない
collection.upsert(
ids = ids,
documents = texts,
embeddings = vectors,
metadatas = metadatas,
)
print ( f " 格納完了: { i + len (batch) } / { len (chunks) } チャンク" )
print ( f "✅ 全 { len (chunks) } チャンクを ChromaDB に反映しました" )
# --- 実行 ---
if __name__ == "__main__" :
docs = load_documents( "./knowledge_base" )
print ( f "📄 { len (docs) } ドキュメントを読み込みました" )
chunks = chunk_documents(docs)
print ( f "✂️ { len (chunks) } チャンクに分割しました" )
create_embeddings_and_store(chunks)
期待される出力:
📄 47 ドキュメントを読み込みました
✂️ 312 チャンクに分割しました
格納完了: 100/312 チャンク
格納完了: 200/312 チャンク
格納完了: 300/312 チャンク
格納完了: 312/312 チャンク
✅ 全 312 チャンクを ChromaDB に反映しました
ID を連番ではなく本文のハッシュにしている点を補足させてください。連番だと、ドキュメントを1件追加しただけで以降のチャンクの ID がすべてずれます。同じ本文が別 ID で二重登録され、検索結果の上位が同一内容で埋まる、という壊れ方をします。ハッシュにしておけば、内容が変わらないチャンクは何度流しても同じ ID に落ち着きます。add ではなく upsert を使っているのも同じ理由です。
差分更新を実装するときも、この ID 設計がそのまま土台になります。前回のハッシュ一覧を保存しておき、今回の一覧との差だけを upsert、消えた ID を delete する。それだけでインクリメンタルインジェストが成立します。
検索フェーズ(リトリーバル)
ユーザーの質問をベクトル化し、類似度の高いチャンクを検索します。ここでの精度が RAG 全体の品質を左右します。
生成フェーズ(ジェネレーション)
検索結果をプロンプトに組み込み、LLM が回答を生成します。
チャンク戦略の設計 — RAG 品質の80%はここで決まる
RAG の品質を最も大きく左右するのは、ドキュメントをどうチャンクに分割するかです。Antigravity のエージェントに「チャンク戦略を最適化して」と依頼すると、以下のような高度な分割ロジックを提案してくれます。
セマンティックチャンキング
単純な文字数分割ではなく、文章の意味的なまとまりを保った分割を行います。
# semantic_chunker.py — 意味的なまとまりを保つ高度なチャンク分割
from langchain.text_splitter import RecursiveCharacterTextSplitter # 後半の親子分割で使用
from langchain_experimental.text_splitter import SemanticChunker
from langchain_google_genai import GoogleGenerativeAIEmbeddings
import os
def create_semantic_chunks (documents: list ) -> list :
"""Embedding ベースのセマンティックチャンキング"""
embeddings = GoogleGenerativeAIEmbeddings(
model = "gemini-embedding-001" ,
google_api_key = os.getenv( "GOOGLE_API_KEY" ),
output_dimensionality = 1536 ,
)
# 文の意味的な距離が閾値を超えたところで分割
chunker = SemanticChunker(
embeddings,
breakpoint_threshold_type = "percentile" ,
breakpoint_threshold_amount = 90 , # 上位10%の距離で分割
)
chunks = []
for doc in documents:
split = chunker.create_documents(
[doc.page_content],
metadatas = [doc.metadata],
)
chunks.extend(split)
return chunks
# --- 親子チャンク戦略(Parent-Child Chunking)---
class ParentChildChunker :
"""
大きな「親チャンク」で文脈を保持し、
小さな「子チャンク」で検索精度を上げる二段構え
"""
def __init__ (self, parent_size = 2000 , child_size = 400 , overlap = 100 ):
self .parent_splitter = RecursiveCharacterTextSplitter(
chunk_size = parent_size,
chunk_overlap = 0 ,
)
self .child_splitter = RecursiveCharacterTextSplitter(
chunk_size = child_size,
chunk_overlap = overlap,
)
def split (self, documents: list ) -> tuple :
"""親チャンクと子チャンクを同時に生成"""
parent_chunks = []
child_chunks = []
for doc in documents:
parents = self .parent_splitter.split_documents([doc])
for idx, parent in enumerate (parents):
parent.metadata[ "parent_id" ] = f " { doc.metadata.get( 'source' , 'unknown' ) } _ { idx } "
parent_chunks.append(parent)
# 親チャンクをさらに小さく分割
children = self .child_splitter.split_documents([parent])
for child in children:
child.metadata[ "parent_id" ] = parent.metadata[ "parent_id" ]
child_chunks.append(child)
return parent_chunks, child_chunks
# 期待される動作:
# - 子チャンクで精密な検索を行い
# - ヒットした子チャンクの親チャンクをコンテキストとして LLM に渡す
# → 検索精度と文脈の豊かさを両立
ParentChildChunker が RecursiveCharacterTextSplitter を使っている点に注意してください。このクラスだけを別ファイルへ切り出すと、import を書き忘れて NameError: name 'RecursiveCharacterTextSplitter' is not defined で止まります。エージェントに生成させたコードを分割・移動するときに起きがちな失敗です。上のスニペット冒頭で import を明示しているのはそのためです。
チャンクサイズの選び方
チャンクサイズは一律に決められるものではなく、データの性質によって最適解が異なります。
Antigravity のエージェントに各パターンのベンチマークコードを生成させ、実データで比較するのが最も確実です。一般的な目安として、技術ドキュメント(API リファレンス等)は 400〜600 文字の小さめのチャンクが有効です。コードの文脈は短い範囲で完結することが多いためです。一方、ナラティブなドキュメント(社内Wiki、議事録等)は 800〜1200 文字のやや大きめのチャンクが適しています。文脈が長い文章では、短すぎるチャンクだと意味が失われます。法務・契約文書のように厳密性が求められる場合は、1500〜2000 文字の大きなチャンクを使い、条項全体の文脈を保持する点が肝心です。
リトリーバルの高度化 — ハイブリッド検索とリランキング
ベクトル検索だけでは不十分なケースがあります。キーワードの完全一致が重要な場面(エラーコード検索、商品型番の特定など)では、BM25 などのキーワード検索と組み合わせたハイブリッド検索 が効果的です。
# hybrid_retriever.py — ベクトル検索 + BM25 のハイブリッド検索
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import Chroma
from langchain_google_genai import GoogleGenerativeAIEmbeddings
import os
def create_hybrid_retriever (chunks: list , k: int = 5 ):
"""ベクトル検索と BM25 を組み合わせたハイブリッドリトリーバー"""
# ここでは task_type をあえて指定しません。
# ライブラリ側が embed_documents に RETRIEVAL_DOCUMENT、
# embed_query に RETRIEVAL_QUERY を自動で割り当てるためです(後述)。
embeddings = GoogleGenerativeAIEmbeddings(
model = "gemini-embedding-001" ,
google_api_key = os.getenv( "GOOGLE_API_KEY" ),
output_dimensionality = 1536 ,
)
# 1. ベクトル検索リトリーバー
vectorstore = Chroma.from_documents(
documents = chunks,
embedding = embeddings,
collection_name = "hybrid_search" ,
)
vector_retriever = vectorstore.as_retriever(
search_type = "mmr" , # Maximal Marginal Relevance
search_kwargs = { "k" : k, "fetch_k" : k * 3 },
)
# 2. BM25 キーワード検索リトリーバー
bm25_retriever = BM25Retriever.from_documents(
chunks,
k = k,
)
# 3. アンサンブル(重み付け統合)
ensemble = EnsembleRetriever(
retrievers = [vector_retriever, bm25_retriever],
weights = [ 0.6 , 0.4 ], # ベクトル検索を少し重視
)
return ensemble
# --- リランキング(Cohere Reranker を使用)---
from langchain.retrievers import ContextualCompressionRetriever
from langchain_cohere import CohereRerank
def add_reranking (base_retriever, top_n: int = 3 ):
"""検索結果をリランカーで再スコアリング"""
reranker = CohereRerank(
model = "rerank-v3.5" ,
cohere_api_key = os.getenv( "COHERE_API_KEY" ),
top_n = top_n,
)
return ContextualCompressionRetriever(
base_compressor = reranker,
base_retriever = base_retriever,
)
# 使用例:
# retriever = create_hybrid_retriever(chunks, k=10)
# reranked_retriever = add_reranking(retriever, top_n=3)
# results = reranked_retriever.invoke("Stripe Webhook の署名検証方法は?")
#
# 期待される動作:
# → BM25 が「Stripe」「Webhook」「署名検証」のキーワードマッチで候補を取得
# → ベクトル検索が意味的に近い文書も取得
# → リランカーが上位3件に絞り込み、最も関連性の高い文書を返す
MMR(Maximal Marginal Relevance)による多様性確保
ベクトル検索で search_type="mmr" を指定すると、類似度だけでなく結果の多様性も考慮されます。同じような内容のチャンクばかりが返されることを防ぎ、LLM に多角的な情報を提供できます。
生成フェーズ — プロンプト設計とガードレール
検索で取得したコンテキストを LLM に適切に渡すプロンプト設計が、回答品質の鍵を握ります。
# rag_chain.py — RAG チェーンの構築
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
# 生成モデルは1箇所に集約しておく。廃止告知が出たときの作業がここだけで済む
CHAT_MODEL = "gemini-2.5-pro" # 2026年10月16日以降の廃止が予告済み
def build_rag_chain (retriever):
"""検索結果を元に回答を生成する RAG チェーン"""
# --- プロンプトテンプレート ---
template = ChatPromptTemplate.from_messages([
( "system" , """あなたは社内ナレッジに基づいて正確に回答するアシスタントです。
以下のルールを厳守してください:
1. 提供されたコンテキストの情報のみに基づいて回答する
2. コンテキストに情報がない場合は「この情報は社内ナレッジベースに見つかりませんでした」と明示する
3. 推測や外部知識を混ぜない
4. 回答の根拠となったドキュメントのソースを明記する
5. 技術的な内容にはコード例を含める
コンテキスト:
{context} """ ),
( "human" , " {question} " ),
])
# --- LLM ---
llm = ChatGoogleGenerativeAI(
model = CHAT_MODEL ,
temperature = 0.1 , # 事実に基づく回答のため低めに設定
max_output_tokens = 4096 ,
)
# --- チェーン構築 ---
def format_docs (docs):
"""検索結果を整形してコンテキスト文字列にする"""
formatted = []
for i, doc in enumerate (docs, 1 ):
source = doc.metadata.get( "source" , "不明" )
formatted.append(
f "[ソース { i } : { source } ] \n{ doc.page_content } "
)
return " \n\n --- \n\n " .join(formatted)
chain = (
{
"context" : retriever | format_docs,
"question" : RunnablePassthrough(),
}
| template
| llm
| StrOutputParser()
)
return chain
# --- ストリーミング対応版 ---
def query_with_streaming (chain, question: str ):
"""ストリーミングで回答を表示"""
print ( f " \n 📝 質問: { question }\n " )
print ( "=" * 60 )
for chunk in chain.stream(question):
print (chunk, end = "" , flush = True )
print ( " \n " + "=" * 60 )
# 使用例:
# chain = build_rag_chain(reranked_retriever)
# query_with_streaming(chain, "新規プロジェクトのセットアップ手順を教えてください")
#
# 期待される出力:
# 📝 質問: 新規プロジェクトのセットアップ手順を教えてください
# ============================================================
# 社内ナレッジベースに基づくと、新規プロジェクトのセットアップは
# 以下の手順で行います:
# 1. GitHub でリポジトリを作成し...
# [ソース: docs/setup-guide.md]
# ============================================================
評価とモニタリング — RAG の品質を定量化する
RAG システムは「動いている」だけでは不十分です。回答品質を定量的に測定し、継続的に改善する仕組みが必要です。
# rag_evaluator.py — RAG パイプラインの評価フレームワーク
from dataclasses import dataclass
from langchain_google_genai import ChatGoogleGenerativeAI
from pydantic import BaseModel, Field
@dataclass
class EvalResult :
question: str
expected: str
actual: str
faithfulness: float # コンテキストに忠実か(0-1)
relevance: float # 質問に関連しているか(0-1)
class Verdict ( BaseModel ):
"""判定モデルに返させるスキーマ"""
score: float = Field( description = "0.0 から 1.0 のスコア" )
reason: str = Field( description = "そのスコアにした理由" )
class RAGEvaluator :
"""LLM-as-a-Judge パターンで RAG 回答を評価"""
def __init__ (self):
# 素の応答を json.loads すると、```json フェンス付きで返ってきた瞬間に落ちます。
# 構造化出力に寄せれば、パース処理そのものが不要になります。
self .judge = ChatGoogleGenerativeAI(
model = "gemini-2.5-pro" ,
temperature = 0 ,
).with_structured_output(Verdict)
def evaluate_faithfulness (
self, context: str , answer: str
) -> float :
"""回答がコンテキストに忠実かを評価"""
prompt = f """以下の回答が、提供されたコンテキストの情報のみに基づいているか評価してください。
コンテキスト:
{ context }
回答:
{ answer }
0.0(完全にコンテキスト外)〜 1.0(完全にコンテキストに忠実)で採点してください。"""
return self .judge.invoke(prompt).score
def evaluate_relevance (
self, question: str , answer: str
) -> float :
"""回答が質問に関連しているかを評価"""
prompt = f """以下の回答が質問に対して適切に回答しているか評価してください。
質問: { question }
回答: { answer }
0.0(無関係)〜 1.0(完全に的確)で採点してください。"""
return self .judge.invoke(prompt).score
def run_eval_suite (
self, chain, retriever, test_cases: list[ dict ]
) -> list[EvalResult]:
"""テストケース一覧で評価を実行"""
results = []
for case in test_cases:
# 検索結果を取得
docs = retriever.invoke(case[ "question" ])
context = " \n " .join(d.page_content for d in docs)
# 回答を生成
answer = chain.invoke(case[ "question" ])
# 評価
faithfulness = self .evaluate_faithfulness(context, answer)
relevance = self .evaluate_relevance(case[ "question" ], answer)
results.append(EvalResult(
question = case[ "question" ],
expected = case.get( "expected" , "" ),
actual = answer,
faithfulness = faithfulness,
relevance = relevance,
))
# サマリー出力
avg_faith = sum (r.faithfulness for r in results) / len (results)
avg_rel = sum (r.relevance for r in results) / len (results)
print ( f " \n 📊 評価結果サマリー( { len (results) } ケース)" )
print ( f " 忠実性(Faithfulness): { avg_faith :.2f } " )
print ( f " 関連性(Relevance): { avg_rel :.2f } " )
return results
# テストケース例:
# test_cases = [
# {"question": "デプロイ手順は?", "expected": "wrangler deploy を実行..."},
# {"question": "API キーの発行方法は?", "expected": "管理画面から..."},
# ]
# evaluator = RAGEvaluator()
# results = evaluator.run_eval_suite(chain, retriever, test_cases)
本番運用のベストプラクティス
RAG システムを本番環境で安定運用するためのポイントをまとめます。
インクリメンタルインジェスト : ドキュメントが更新されたら差分だけをベクトルDBに反映します。ファイルのハッシュ値を記録し、変更があったファイルのみ再 Embedding を実行する設計にすると、コストと処理時間を大幅に削減できます。
キャッシュ戦略 : 同じ質問に対する回答をキャッシュすることで、レイテンシとAPI費用を削減します。質問のベクトル類似度が閾値(例: 0.95)以上の場合にキャッシュヒットとみなす「セマンティックキャッシュ」が効果的です。
フォールバック設計 : ベクトル検索の結果が低スコア(例: コサイン類似度 0.3 未満)の場合、「関連情報が見つかりませんでした」と正直に回答する方が、ハルシネーションを含む回答より遥かに信頼性が高くなります。
監視メトリクス : 本番環境では以下の指標を常時モニタリングすべきです。検索レイテンシ(P95 が 500ms 以下を目標)、回答生成レイテンシ(P95 が 3秒以下を目標)、検索結果の平均類似度スコア(低下傾向はデータの陳腐化を示唆)、ユーザーフィードバック率(👍/👎 の比率)。
text-embedding-004 からの移行でつまずいた3点
Embedding モデルの差し替えは、コードの上ではモデル名を1行書き換えるだけの作業です。それでも私の手元では、まともに動くまでに3回つまずきました。個人開発で回している数百チャンク規模の小さなパイプラインでさえ、この3点は例外なく踏んでいます。順に共有いたします。
次元数が変わります。 text-embedding-004 は 768 次元固定でした。対して gemini-embedding-001 の既定値は 3072 次元です。既存のコレクションへそのまま追記しようとすれば、ChromaDB は次元不一致で拒否します。エラーは出るので気づけますが、原因にたどり着くまでに時間を溶かしました。モデルを変えるときはコレクション名も変える、と決めてしまうのが結局いちばん安全です。本記事のコードで COLLECTION_NAME へ _v2 を付けているのはそのためです。
3072 次元以外は正規化されていません。 output_dimensionality に 768 や 1536 を渡すと、Matryoshka Representation Learning によって先頭 N 次元が切り出されます。このとき返ってくるベクトルのノルムは 1 ではありません。ChromaDB を hnsw:space: cosine で使っている限り、コサイン類似度はベクトルの長さに影響されないため順位は変わりません。効いてくるのは ip(内積)へ切り替えたときです。ノルムがそのままスコアに乗るので、たまたま長いチャンクが不当に上位へ来ます。切り詰めるなら自前で L2 正規化しておく。そう覚えてしまうほうが早いと感じております。
タスクタイプは、指定しないほうが正しい場面があります。 これがいちばん意外でした。Embedding API には task_type という引数があり、文書側は RETRIEVAL_DOCUMENT、クエリ側は RETRIEVAL_QUERY を指定するのが本来の使い方です。ならばインスタンス生成時にまとめて task_type="RETRIEVAL_DOCUMENT" を渡しておこう——そう考えたのが誤りでした。
langchain-google-genai 4.3.5 の実装を読むと、embed_documents は task_type or self.task_type or "RETRIEVAL_DOCUMENT"、embed_query は task_type or self.task_type or "RETRIEVAL_QUERY" という優先順位になっています。インスタンス側に task_type を設定してしまうと、クエリの埋め込みまで RETRIEVAL_DOCUMENT として扱われるわけです。例外は出ません。検索精度がじわりと落ちるだけなので、気づきにくい種類の劣化です。
指定のしかた 文書側に適用される task_type クエリ側に適用される task_type
インスタンスに指定しない RETRIEVAL_DOCUMENT RETRIEVAL_QUERY
インスタンスに RETRIEVAL_DOCUMENT を指定 RETRIEVAL_DOCUMENT RETRIEVAL_DOCUMENT(意図と異なる)
呼び出しごとに引数で指定 指定した値 指定した値
インジェスト側のように embed_documents を直接呼ぶ箇所では、引数で明示したほうが意図を読み取りやすくなります。一方で、Chroma のリトリーバーへ渡すインスタンスは task_type を空のままにしておく。この使い分けに落ち着きました。ライブラリの既定値に任せるのが正解、という結論は少し据わりが悪いのですが、実装がそうなっている以上は従うほかありません。
モデルの寿命を設計に織り込む
RAG パイプラインは、一度組めばそこから数年は動かし続けることになります。ところが土台になっているモデルのほうは、想像よりずっと短い周期で入れ替わります。私自身、これを軽く見ていました。
text-embedding-004 は 2026年1月14日に停止しました。生成側で使っている gemini-2.5-pro も、2026年10月16日以降の廃止が予告されています。執筆時点で一般提供されている後継は gemini-3.7-flash(2026年8月13日 GA)で、Pro 相当の gemini-3.1-pro-preview はまだプレビュー段階です。つまり「そのまま置き換えれば済む Pro モデル」がない状態が、しばらく続きます。
対策そのものは地味です。モデル名をコードのあちこちへ散らさず、EMBED_MODEL と CHAT_MODEL のような定数へ集約しておく。それだけで、停止告知が出たときの作業が「1行の書き換えと動作確認」に収まります。上のコードで定数を切っているのは、行数を減らすためではなくこのためです。
ただし Embedding モデルの入れ替えだけは、1行では終わりません。ベクトルの意味空間が変わる以上、全ドキュメントの再インジェストが必須です。数万チャンク規模なら費用も時間も無視できません。私はこの再インジェストを、差分更新とは別の「フルリビルド」スクリプトとして最初から用意しておくようにしました。告知が出てから書き始めるのでは遅い、というのがここでの学びです。
次に手を動かすなら
まずは手元の 20〜30 ドキュメントで小さくインジェストし、想定質問を 10 件ほど並べて RAGEvaluator を回してみてください。忠実性のスコアが 0.8 を下回るようなら、原因はプロンプトではなくチャンク戦略にあることがほとんどです。チャンクサイズを 400/800/1200 で振り、同じ質問セットで比べる。この順番を守るだけで、当てずっぽうに設定をいじる時間がかなり減ります。
複数の LLM を使い分けたい場合は、Antigravity マルチモデル完全攻略ガイド もあわせてご覧ください。
RAG は組み上げたところが出発点で、測り始めてからが本番だと、いまは考えております。お読みいただきありがとうございました。