「手作り RAG システム」で RAG の仕組みを学び直す
はじめに
Google Cloud では、RAG を手軽に実現する手段として、Vertex AI Search や Vertex AI RAG Engine などのマネージドサービスが提供されています。あるいは、ベクトル検索エンジンである Vector Search を利用して、独自の RAG システムを構築することもできます。
業務システムとして利用する際は、これらのサービスを利用することで、スケーラビリティの高いシステムが容易に構築できますが、システム設計に関わるエンジニアとしては、そもそも RAG とはどのような仕組みで、どのような処理が裏側で行われているのかを理解しておくことも大切です。そこでこの記事では、前述の便利なサービスをあえて利用せず、自前の RAG システムを手作りすることで、RAG を構成するパーツや高品質な結果を返すための工夫を学びます。
RAG の仕組み
はじめに、下記の記事を参考にして、RAG の仕組みを簡単におさらいしておきます。
まず、「グラウンディングと RAG の違いについて」では、RAG の基本的な処理の流れを次のようにまとめました。
- 利用者が質問を入力する
↓ - 質問の回答に必要な情報を検索する
↓ - 検索で得られた情報と質問を LLM に入力して回答を生成する
その上で、「RAG システム構築の難しさ」では、「入力された質問に応じて、回答に必要な情報を的確に検索・発見する仕組み」を実装する部分が、RAG システム構築の課題の1つになることを説明しました。
RAG システムの考え方が生まれた当初は、検索対象のドキュメント群に含まれるテキストを一定の文字数の「チャンク」に分割してデータベースに保存した上で、参照するべきチャンクを検索する方式が主に用いられていました。特に初期の RAG システムでは、それぞれのチャンクのサイズは、英語なら100単語、日本語なら200〜500文字程度と小さなものでした。このため、検索で得られた複数のチャンクを寄せ集めた際に、無関係なドキュメントの細かなチャンクが混在して、回答に役立つ意味のある情報になりにくいという課題がありました。それぞれのチャンクには、個別の文脈や背景情報が付随しているはずですが、それらは個々のチャンクには含まれないためです。

異なるドキュメントの小さなチャンクを集めても文脈や背景情報が得られない
このように小さなチャンクを使用するのは、次のような技術的制約によるものでした。
- LLM のコンテキストウィンドウの制限:初期の LLM では、プロンプトに入力できるテキストは、2,048〜4,096トークンが上限になっており、複数の検索結果を含めるには、一つ一つのチャンクを小さくする必要がありました。
- 埋め込みモデルの限界:チャンクが長すぎると、埋め込みベクトルとして抽出するべき「主要な情報」があいまいになり、検索精度が下がる傾向にありました。
ここで、2つ目の「埋め込みモデル」について補足しておきます。質問に基づいて回答に必要な情報を含むチャンクを検索する際は、通常、「埋め込みモデル」によって、テキストを「埋め込みベクトル」に変換した上で、埋め込みベクトルの類似度に基づいて検索します。具体的には、次のような手順になります。
- 埋め込みモデルを用いて、それぞれのチャンクを高次元空間の「埋め込みベクトル」に変換する
- 同じく、埋め込みモデルを用いて、質問文を埋め込みベクトルに変換する
- 質問文の埋め込みベクトルと類似した値の埋め込みベクトルを持つチャンクを検索する

埋め込みベクトルの類似度による検索
埋め込みモデルが生成する埋め込みベクトルは、それぞれのテキストに含まれる情報を抽象的に表現したもので、2つのテキストに含まれる情報が類似していれば、それぞれのテキストからは、類似した値の埋め込みベクトルが生成されます。
ただし、埋め込みベクトルが表現できる情報量には限界がありますので、さまざま情報を含んだ長いチャンクの場合、検索に必要な情報を適切に含んだ埋め込みベクトルの生成が難しくなります。これが、「埋め込みベクトルの限界」で指摘した内容です。
現代的な RAG の戦略
前項で「小さなチャンクが必要なる技術的制約」を説明しましたが、LLM と埋め込みモデルの進化により、これらは過去のものになりつつあります。まず、LLM のコンテキストウィンドウは大幅に広がりました。Gemini 2.5 Flash であれば、約100万トークンが入力可能で、日本語であれば、70万〜80万字に相当します。
従って、よほど大きなファイルでなければ、回答生成に必要な情報を含んだドキュメントをチャンクに分割せずに入力することも可能です。さらに、Gemini はマルチモーダルモデルですので、図表を含む PDF ファイルをそのままの形で入力することもできます。こうすれば、質問内容に直接関連しない背景情報を含めて、該当ドキュメントに含まれるすべての情報を踏まえた、より適切な回答が生成できます。
ただし、この場合、ドキュメントをチャンクに分割するのではなく、ドキュメントごとに埋め込みベクトルを生成して、回答時に参照するドキュメントを検索するために、先に触れた「埋め込みモデルの限界」への対応が必要です。さまざまな情報を含むドキュメントから、「主要な情報」をもれなく含む埋め込みベクトルは生成できるのでしょうか?
この点については、ドキュメントを直接埋め込みベクトルに変換するのではなく、事前に LLM を用いて、「文書の目的、対象となる読者、該当文書を探す際に使われそうなキーワード」などを抽出したテキストを生成して、これを埋め込みベクトルに変換します。さらに、質問文についても同様に、LLM で「回答するために探すべき情報、回答に含まれるキーワード」などを生成して、これを埋め込みベクトルに変換します。こうすれば、「質問文から想定されるキーワード」によって、質問文と対応するドキュメントがうまくマッチできます。
この後のハンズオンで用いる題材から具体例を示しておきましょう。新宿区が公開している「新宿区くらしのガイド」に含まれる「ごみ/環境/リサイクル」というタイトルの PDF ファイルからは、次のようなテキストが生成されます。このテキストを埋め込みベクトルに変換したものを使用します。
テキスト1
文書タイトル:ごみ/環境/リサイクル
文書サマリー:本文書は、新宿区の住民および事業者向けに、ごみと資源の適切な分別・排出方法、
リサイクルに関する情報を提供する総合ガイドです。
...(中略)...
ごみの適正処理に関する各種問い合わせは、各清掃事務所、清掃センター、ごみ減量リサイクル課、
環境対策課へ連絡するよう促しています。
検索キーワード:ごみ、資源、リサイクル、分別、出し方、粗大ごみ、燃やすごみ、
金属・陶器・ガラスごみ、古紙、プラスチック、びん・缶・ペットボトル、スプレー缶、
家電リサイクル、パソコンリサイクル、事業系ごみ、路上喫煙、ポイ捨て、動物被害、
防鳥ネット、集団回収、リサイクル活動センター、エコギャラリー新宿、環境相談、
公害相談、ワットモニター、清掃事務所、清掃センター、新宿区
一方、「最近引っ越してきたのですが、古い電子レンジを捨てるにはどうすればいいですか?」という質問文からは次のテキストが生成されるので、これを埋め込みベクトルに変換します。
テキスト2
電子レンジの処分方法について、自治体のガイドブックから以下の情報を探してください。
まず「電子レンジ 処分方法」「粗大ごみ 電子レンジ」「小型家電リサイクル 電子レンジ」
の項目を確認します。
粗大ごみとして出す場合、「申し込み方法(電話、インターネットなど)」「手数料」
「粗大ごみ処理券の購入場所」「収集日」「収集場所」を重点的に探します。
小型家電リサイクルの対象であれば、「回収ボックス設置場所」「回収方法」を確認します。
清掃工場や処理施設への「持ち込み」が可能か、その際の「料金」「受付時間」「持ち込み場所」
も確認します。
家電リサイクル法の対象外であることも念のため確認し、誤った情報に惑わされないようにします。
自治体が推奨する「不用品回収業者」の情報があれば、それも参考にします。「ごみ分別ガイド」
「ごみ出しルール」の電子レンジに関する記述を詳しく調べましょう。
こうすれば、「テキスト1」は、「テキスト2」に関連した情報を含むことが明確にわかります。つまり、これら2つのテキストから生成される埋め込みベクトルは類似度が高くなり、質問の回答に必要なドキュメントファイルが確実に検索できます。
手作り RAG システムを実装する
新宿区が公開している「新宿区くらしのガイド」(20個程度の PDF 文書)に基づいて、区民の質問に回答するコンシェルジュ AI を作成します。
ここでは、質問の回答に必要な PDF をファイル単位で検索・特定した後に、トップ3の PDF をそのままプロンプトに埋め込んで回答を生成するアプローチを取ります。この際、質問に関連した PDF は次の方法で検索します。
- 各 PDF のサマリーテキストを埋め込みベクトル(サマリーベクトル)に変換しておく
- 質問文から「検索用クエリー」を生成して、これを埋め込みベクトル(クエリーベクトル)に変換する
- クエリーベクトルとサマリーベクトル類似度から関連性が高い PDF を特定する
また、質問者のプロファイルや過去の質問を補助的に入力することで、パーソナライズされた回答を生成する仕組みも実装します。
環境準備
Vertex AI workbench のノートブック上で実装しながら説明するために、まずは、ノートブックの実行環境を用意します。新しいプロジェクトを作成したら、Cloud Shell のコマンド端末を開いて、必要な API を有効化します。
gcloud services enable \
aiplatform.googleapis.com \
notebooks.googleapis.com \
cloudresourcemanager.googleapis.com
また、Gemini API が GCS バケットにアクセスできるように、IAM ロールを付与しておきます。
PROJECT_ID=$(gcloud config list --format 'value(core.project)' 2>/dev/null)
PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)' 2>/dev/null)
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:service-${PROJECT_NUMBER}@gcp-sa-aiplatform.iam.gserviceaccount.com" \
--role='roles/storage.objectUser'
続いて、Workbench のインスタンスを作成します。
PROJECT_ID=$(gcloud config list --format 'value(core.project)')
gcloud workbench instances create rag-development \
--project=$PROJECT_ID \
--location=us-central1-a \
--machine-type=e2-standard-2
クラウドコンソールのナビゲーションメニューから「Vertex AI」→「Workbench」を選択すると、作成したインスタンス rag-development があります。インスタンスの起動が完了するのを待って、「JUPYTERLAB を開く」をクリックしたら、「Python 3(ipykernel)」の新規ノートブックを作成します。
この後は、ノートブックのセルでコードを実行していきます。
事前準備
必要なモジュールをインポートして、SDK クラインアントを初期化した上で、PDF ファイルを保存するストレージバケットを作成します。
import os
import pickle
import requests
import numpy as np
import vertexai
from google import genai
from google.cloud import storage
from google.genai import types
from IPython.display import Markdown, display
# プロジェクト設定
[PROJECT_ID] = !gcloud config list --format 'value(core.project)'
LOCATION = 'us-central1'
# Vertex AI と Google GenAI の SDK クライアントを初期化
vertexai.init(project=PROJECT_ID, location=LOCATION)
client = genai.Client(vertexai=True, project=PROJECT_ID, location=LOCATION)
# PDF を保存するバケットを作成
BUCKET = f'{PROJECT_ID}_ragdata'
!gsutil ls -b gs://{BUCKET} 2>/dev/null || \
gsutil mb -l {LOCATION} gs://{BUCKET}
「新宿区くらしのガイド」に含まれる PDF ファイルのタイトルとファイル名を定義します。
pdf_dict = {
'区長の挨拶': '000391171.pdf',
'引っ越しするとき': '000391172.pdf',
'もくじ': '000391174.pdf',
'いざというときに備えて': '000391175.pdf',
'エリアシティウォーク': '000391176.pdf',
'公的施設等一覧': '000391178.pdf',
'主な区立施設の設備一覧': '000391179.pdf',
'相談': '000391180.pdf',
'区役所と特別出張所': '000394761.pdf',
'緊急のとき/安心安全': '000391182.pdf',
'手続き/届出': '000391183.pdf',
'健康/衛生': '000391184.pdf',
'高齢者福祉': '000391185.pdf',
'介護保険': '000391187.pdf',
'障害者福祉': '000391189.pdf',
'その他の福祉': '000391192.pdf',
'子ども/教育': '000391194.pdf',
'文化/スポーツ': '000391197.pdf',
'ごみ/環境/リサイクル': '000391199.pdf',
'生活環境': '000391201.pdf',
'住宅/まちづくり': '000391203.pdf',
'仕事/経営': '000391205.pdf',
'協働/身近な行政': '000391206.pdf',
'わたしの便利帳2024年版 さくいん(索引)': '000391177.pdf',
'医療機関リスト': '000391207.pdf'
}
定義したファイルをダウンロードして、ストレージバケットに保存します。
# Storage クライアントとバケットの準備
storage_client = storage.Client()
bucket = storage_client.bucket(BUCKET)
print(f'Starting download and upload to gs://{BUCKET}/pdf/ ...')
for title, filename in pdf_dict.items():
url = f'https://www.city.shinjuku.lg.jp/content/{filename}'
try:
# PDFをダウンロード
response = requests.get(url)
response.raise_for_status() # エラーがあれば例外を発生させる
# 保存先のパスを指定
blob = bucket.blob(f'pdf/{filename}')
# メモリ上のコンテンツを直接アップロード
blob.upload_from_string(response.content, content_type='application/pdf')
print(f'Succeeded: {title} ({filename})')
except Exception as e:
print(f'Failed: {title} ({filename}) - Reason: {e}')
print('\nAll processes are complete.')
[出力結果]
Starting download and upload to gs://your_project_ragdata/pdf/ ...
Succeeded: 区長の挨拶 (000391171.pdf)
Succeeded: 引っ越しするとき (000391172.pdf)
Succeeded: もくじ (000391174.pdf)
...(中略)...
Succeeded: 医療機関リスト (000391207.pdf)
All processes are complete.
ドキュメントの埋め込みベクトルを生成
Gemini 2.5 Flash を用いて、それぞれの PDF ファイルのサマリーテキストを生成します。
# 保存用ファイル名
PICKLE_FILE = 'summary_results.pkl'
if os.path.exists(PICKLE_FILE):
# --- ファイルが存在する場合は復元 ---
print(f'Loading existing summary results from {PICKLE_FILE}...')
with open(PICKLE_FILE, 'rb') as f:
summary_results = pickle.load(f)
print(f'Successfully loaded {len(summary_results)} items.')
else:
# --- ファイルが存在しない場合は生成 ---
print(f'{PICKLE_FILE} not found. Starting summarization with Gemini...')
summary_results = {}
for title, filename in pdf_dict.items():
gcs_uri = f'gs://{BUCKET}/pdf/{filename}'
system_instruction = f'''
あなたは自治体の案内係です。提供されたPDF文書の内容を、後で検索(RAG)に利用するためのサマリーとしてまとめてください。
* 以下のガイドラインに従ってください:
1. 文書の主要な目的と対象者を明確にする。
2. 手続きが必要な場合は、その名称や場所、必要なものをキーワードとして含める。
3. 住民がこの文書を探す際に使いそうな検索クエリ(キーワード)を抽出して出力する。
4. 以下の形式で500文字程度で出力する。(「:」の直後は改行しない。3つの項目の間は空行を1行入れる。)
文書タイトル:<文書タイトル>
文書サマリー:<文書のサマリー>
検索キーワード:<キーワードを「、」で区切って並べる>
'''
try:
response = client.models.generate_content(
model='gemini-2.5-flash',
contents=[
f'文書タイトル: {title}',
types.Part.from_uri(file_uri=gcs_uri, mime_type='application/pdf'),
],
config=types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=0.2
)
)
summary_results[title] = {
'filename': filename,
'summary': response.text
}
print(f'Succeeded: {title}')
except Exception as e:
print(f'Failed: {title} - Reason: {e}')
# 生成した結果を保存
with open(PICKLE_FILE, 'wb') as f:
pickle.dump(summary_results, f)
print(f'\nAll summarization processes are complete and saved to {PICKLE_FILE}.')
[出力結果]
summary_results.pkl not found. Starting summarization with Gemini...
Succeeded: 区長の挨拶
Succeeded: 引っ越しするとき
Succeeded: もくじ
...(中略)...
Succeeded: 医療機関リスト
All summarization processes are complete and saved to summary_results.pkl.
ここでは、生成した結果をファイル summary_results.pkl に保存しておき、すでにこのファイルがある場合は、再度生成するのではなく、ファイルから内容を復元するようにしてあります。あえて再生成する場合は、次のコマンドでファイル summary_results.pkl を削除した上で上記のコードを実行してください。
os.remove('summary_results.pkl')
最初の3件について、生成された内容を表示して確認します。
# 内容の確認(最初の3件を表示)
for title in list(summary_results.keys())[:3]:
print('\n' + '-' * 30)
print(f'Summary text: {title}')
print('-' * 30)
print(summary_results[title]['summary'])
[出力結果]
------------------------------
Summary text: 区長の挨拶
------------------------------
文書タイトル:新宿区くらしのガイド 2024年版(区長挨拶・概要)
文書サマリー:この文書は「新宿区くらしのガイド 2024年版」の冒頭ページで、新宿区長
吉住健一氏からの挨拶と、ガイドの概要、新宿区の基本情報、緊急連絡先、平和都市宣言を
掲載しています。ガイドは、行政サービス、医療機関、防災情報、区内の散策コース
「エリアシティウォーク」など、区民の暮らしに役立つ情報を2年ごとに更新して提供。特に
防災情報では、大雨・台風や地震への備え、災害情報の入手方法、発災時の行動を解説し、
エリアシティウォークでは新宿区の魅力的な12コースを紹介しています。区の紋章、区の木
(けやき)、区の花(つつじ)といったシンボルや、警察、消防、救急相談センターなどの
緊急連絡先も掲載されており、区民が日々の生活や緊急時に活用できるよう促しています。
検索キーワード:新宿区、くらしのガイド、区長挨拶、吉住健一、防災情報、エリアシティ
ウォーク、緊急連絡先、平和都市宣言、行政サービス、医療機関、散策コース、新宿区の魅力、
紋章、区の木、区の花
...(以下省略)...
続いて、埋め込みモデル gemini-embedding-001 を用いて、生成したサマリーテキストから埋め込みベクトルを生成します。
# 保存用ファイル名
EMB_PICKLE_FILE = 'embeddings_dict.pkl'
if os.path.exists(EMB_PICKLE_FILE):
# --- ファイルが存在する場合は復元 ---
print(f'Loading existing embeddings from {EMB_PICKLE_FILE}...')
with open(EMB_PICKLE_FILE, 'rb') as f:
embeddings_dict = pickle.load(f)
print(f'Successfully loaded embeddings for {len(embeddings_dict)} documents.')
else:
# --- ファイルが存在しない場合は生成 ---
print('Generating embeddings using gemini-embedding-001...')
embeddings_dict = {}
for title, data in summary_results.items():
summary_text = data['summary']
try:
# gemini-embedding-001 を使用してベクトル化
# task_type='RETRIEVAL_DOCUMENT' を指定
res = client.models.embed_content(
model='gemini-embedding-001',
contents=summary_text,
config=types.EmbedContentConfig(task_type='RETRIEVAL_DOCUMENT')
)
# ベクトル(数値配列)を取り出す
vector = res.embeddings[0].values
embeddings_dict[title] = {
'filename': data['filename'],
'embedding': vector
}
print(f'Succeeded: {title}')
except Exception as e:
print(f'Failed: {title} - Reason: {e}')
# 生成した結果を保存
with open(EMB_PICKLE_FILE, 'wb') as f:
pickle.dump(embeddings_dict, f)
print(f'\nAll embedding processes are complete and saved to {EMB_PICKLE_FILE}.')
[出力結果]
Generating embeddings using gemini-embedding-001...
Succeeded: 区長の挨拶
Succeeded: 引っ越しするとき
Succeeded: もくじ
...(中略)...
Succeeded: 医療機関リスト
All embedding processes are complete and saved to embeddings_dict.pkl.
ここでは、生成した結果をファイル embeddings_dict.pkl に保存しておき、すでにこのファイルがある場合は、再度生成するのではなく、ファイルから内容を復元するようにしてあります。あえて再生成する場合は、次のコマンドでファイル embeddings_dict.pkl を削除した上で上記のコードを実行してください。
os.remove('embeddings_dict.pkl')
最初の3件について、生成された埋め込みベクトルを表示して確認します。
# データの詳細を確認(最初の3件を表示)
for title in list(embeddings_dict.keys())[:3]:
sample_vector = embeddings_dict[title]['embedding']
print('-' * 30)
print(f'Document Title: {title}')
print(f'Vector Dimension: {len(sample_vector)}')
print(f'Vector (first 10 elements): {sample_vector[:10]}')
[出力結果]
------------------------------
Document Title: 区長の挨拶
Vector Dimension: 3072
Vector (first 10 elements): [-0.02220349758863449, -0.01842305436730385, 0.016044966876506805, -0.03509635105729103, -0.008716683834791183, 0.007425079122185707, -0.0057008275762200356, 0.007034968584775925, 0.0024815215729177, -0.009867820888757706]
------------------------------
Document Title: 引っ越しするとき
Vector Dimension: 3072
Vector (first 10 elements): [-0.01779654063284397, -0.01505563035607338, 0.017976151779294014, -0.025311468169093132, 0.00982920452952385, 0.003563973354175687, 0.016740581020712852, 0.012617745436728, 0.01656181551516056, -0.010555865243077278]
------------------------------
Document Title: もくじ
Vector Dimension: 3072
Vector (first 10 elements): [-0.021430017426609993, -0.021420052275061607, 0.012420736253261566, -0.03364516794681549, -0.009396692737936974, 0.01008770801126957, -0.001109318109229207, 0.008338133804500103, 0.007491908501833677, -0.023767635226249695]
これで、質問の回答に必要な参考情報(PDF ファイルと検索用の埋め込みベクトル)が用意できました。ここからは、与えられた質問文から参考になる PDF ファイルを検索した上で回答を生成する、RAG の仕組みを実装していきます。
ドキュメント検索機能を実装
まずは、質問文に基づいて、参考になる PDF ファイルを検索する関数を定義します。
def search_relevant_documents(user_query, top_k=3):
# 1. Search query を生成
system_instruction = f'''
ユーザーの質問に対して、自治体のガイドブックからどのような情報を探すべきか、
回答に含まれるべき具体的なキーワードや内容を300文字程度で書き出してください。
これは検索用のクエリとして使用します。
マークダウンを使用しないプレインテキストで出力すること。
'''
user_prompt = f'ユーザーの質問: {user_query}'
response = client.models.generate_content(
model='gemini-2.5-flash',
contents=user_prompt,
config=types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=0.2
)
)
search_query = response.text
# 2. search_query をベクトル化 (RETRIEVAL_QUERY)
res = client.models.embed_content(
model='gemini-embedding-001',
contents=search_query,
config=types.EmbedContentConfig(task_type='RETRIEVAL_QUERY')
)
query_vector = np.array(res.embeddings[0].values)
# 3. コサイン類似度の計算
similarities = []
for title, data in embeddings_dict.items():
doc_vector = np.array(data['embedding'])
# コサイン類似度の数式: (A・B) / (|A|*|B|)
score = np.dot(query_vector, doc_vector) / (np.linalg.norm(query_vector) * np.linalg.norm(doc_vector))
similarities.append((title, score))
# 4. スコアの高い順にソート
sorted_similarities = sorted(similarities, key=lambda x: x[1], reverse=True)
return search_query, sorted_similarities[:top_k]
ここでは、次のステップで参照する PDF ファイルを3つ選んでいます。
- ステップ1:質問文から「検索用クエリー」を生成する
- ステップ2:検索用クエリーから埋め込みベクトルを生成する
- ステップ3:埋め込みベクトルの類似度スコア(コサイン類似度)を計算する
- ステップ4:スコアの高い順にソートして、上位3件を選択する
ステップ3では、検索用クエリーの埋め込みベクトル(クエリーベクトル)に対して、各 PDF ファイルのサマリーテキストの埋め込みベクトル(サマリーベクトル)との類似度スコアを計算しています。今回は、PDF ファイルが20個程度なので、すべてのファイルに対するスコアを計算してもそれほど時間はかかりませんが、検索対象のファイル数が増えるとこの方法では時間がかかり過ぎます。あくまで、ハンズオン用の簡易実装として理解してください。
適切なファイルが選択されるか、簡単な質問文で試してみましょう。
# --- テスト実行 ---
question = '最近引っ越してきたのですが、古い電子レンジを捨てるにはどうすればいいですか?'
print(f'User input: {question}')
search_query, results = search_relevant_documents(question)
print('\n--- Search query ---')
print(search_query)
print('\n--- Search result (top 3) ---')
for i, (title, score) in enumerate(results):
filename = embeddings_dict[title]['filename']
print(f'{i+1}. {title} (Score: {score:.4f}) - ファイル: {filename}')
[出力結果]
User input: 最近引っ越してきたのですが、古い電子レンジを捨てるにはどうすればいいですか?
--- Search query ---
電子レンジの処分方法、ごみ分別区分、粗大ごみとしての出し方、小型家電リサイクル対象か、
清掃センターへの持ち込み可否、手数料、申し込み方法、収集日、収集場所、粗大ごみ処理券の
購入場所、不用品回収業者の利用案内。家電リサイクル法の対象外であることの確認。
電子レンジのサイズや重さによる分類基準、排出場所の指定、事前連絡の要否。
--- Search result (top 3) ---
1. ごみ/環境/リサイクル (Score: 0.7038) - ファイル: 000391199.pdf
2. 生活環境 (Score: 0.6162) - ファイル: 000391201.pdf
3. もくじ (Score: 0.6049) - ファイル: 000391174.pdf
「ごみ/環境/リサイクル」「生活環境」など、ゴミの出し方に関連したドキュメントが選択されたことがわかります。
これで、RAG システムの要となる検索機能が実装できました。次は、これを利用した「コンシェルジュ AI」を実装します。
コンシェルジュ AI を実装
質問文と(先ほど実装した検索機能で選ばれた)PDF ファイルを元にして、回答を生成する関数を実装します。
def generate_final_answer(user_query, search_results):
# 1. コンテキストとして渡す PDF の Part リストを作成
# 検索結果(上位3件)からファイル名を取り出し、GCS URI を作成
content_parts = []
found_titles = []
for title, score in search_results:
filename = embeddings_dict[title]['filename']
gcs_uri = f'gs://{BUCKET}/pdf/{filename}'
# PDF を Part として追加
content_parts.append(
types.Part.from_uri(file_uri=gcs_uri, mime_type='application/pdf')
)
found_titles.append(title)
# 2. 最終回答用のプロンプトを作成
system_instruction = f'''
あなたは自治体の親切な案内係です。提供された{len(found_titles)}つのPDF資料({", ".join(found_titles)})
の内容に基づいて、ユーザーの質問に正確に、かつ分かりやすく答えてください。
直接の回答以外にも参考になりそうな情報を補足してください。
制約事項:
- 資料に記載されている内容のみに基づいて回答してください。回答内に「提供された資料によると」などの文言は不要です。
- 資料に情報がない場合は「提供された資料の中には、その質問に関する情報が見当たりませんでした」と正直に答えてください。
- どの資料を参考にしたかも最後に付け加えてください。
'''
# 質問文を最後に追加
content_parts.append(f'\n質問:{user_query}')
# 3. Gemini 2.5 Flash による最終回答の生成
try:
response = client.models.generate_content(
model='gemini-2.5-flash',
contents=content_parts,
config=types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=0.2
)
)
return response.text
except Exception as e:
return f'回答生成中にエラーが発生しました: {e}'
ここでは、次のステップで回答を生成しています。
- ステップ1:指定された PDF ファイルを URI 指定でプロンプトに埋め込む
- ステップ2:回答生成用のプロンプトを構成する
- ステップ3:Gemini 2.5 Flash で回答を生成する
Google Cloud の環境で Gemini API を使用する際は、Cloud Storage に保存したファイルを gs://... という形式の URI でプロンプトに埋め込むことができるので、ステップ1ではこの機能を利用しています。
ここまでに用意した関数を組み合わせて、具体的な質問から回答を生成してみましょう。
# --- 全行程を統合した実行テスト ---
# 質問例
question = '最近引っ越してきたのですが、古い電子レンジを捨てるにはどうすればいいですか?'
print(f'User input: {question}')
# ステップ1: 関連ドキュメントの検索(上位3件)
search_query, search_results = search_relevant_documents(question, top_k=3)
# ステップ2: 最終回答の生成
final_answer = generate_final_answer(question, search_results)
print('\n--- Search query ---')
print(search_query)
print('\n--- Search result ---')
for i, (title, score) in enumerate(search_results):
filename = embeddings_dict[title]['filename']
print(f'{i+1}. {title} (Score: {score:.4f}) - ファイル: {filename}')
# --- 整形して表示 ---
print('\n--- Final answer ---')
display(Markdown(final_answer))
[出力結果]
User input: 最近引っ越してきたのですが、古い電子レンジを捨てるにはどうすればいいですか?
--- Search query ---
電子レンジの処分方法について、自治体のガイドブックから以下の情報を探します。まず、
電子レンジが「粗大ごみ」または「小型家電リサイクル」のどちらに分類されるかを確認
します。粗大ごみの場合、申し込み方法(電話、インターネット)、収集日、収集場所、
手数料、粗大ごみ処理券の購入場所と貼り方。小型家電リサイクル対象の場合は、回収
ボックスの設置場所と回収対象品目。また、清掃センターや処理施設への直接持ち込みの
可否、持ち込み方法、手数料。家電リサイクル法対象品目ではないことの確認。不用品回収
業者を利用する場合の注意点や推奨業者リストも確認します。
--- Search result ---
1. ごみ/環境/リサイクル (Score: 0.7349) - ファイル: 000391199.pdf
2. もくじ (Score: 0.6724) - ファイル: 000391174.pdf
3. 住宅/まちづくり (Score: 0.6690) - ファイル: 000391203.pdf
--- Final answer ---
<整形表示された最終回答>
最後の <整形表示された最終回答> には、次のようにマークダウンを整形表示した結果が示されます。

パーソナライゼーション機能の追加
先ほど作成した関数をユーザープロファイルと過去の質問履歴を入力できるように拡張します。
def generate_personalized_answer(user_query, search_results, user_profile, past_history):
# 1. コンテキストの構築(PDF、プロファイル、履歴をまとめる)
content_parts = []
# ユーザーのプロファイルと履歴をテキストパートとして追加
user_context_text = f'''
# ユーザープロファイル:
{user_profile}
# 過去の質問履歴:
{past_history}
'''
content_parts.append(user_context_text)
# 検索でヒットした PDF を追加
found_titles = []
for title, score in search_results:
filename = embeddings_dict[title]['filename']
gcs_uri = f'gs://{BUCKET}/pdf/{filename}'
content_parts.append(
types.Part.from_uri(file_uri=gcs_uri, mime_type='application/pdf')
)
found_titles.append(title)
# ユーザーの質問を追加
content_parts.append(f'\n質問:{user_query}')
# 2. システム指示の更新
system_instruction = f'''
あなたは自治体の親切な案内係です。以下の手順で回答を作成してください。
1. 導入メッセージ(挨拶):
ユーザープロファイルと過去の質問履歴を確認し、冒頭でユーザーに合わせた親しみやすい挨拶を行ってください。
(例:以前の相談内容に触れる、ユーザーの状況を気遣うなど)
ただし、不自然にならないよう、かつ個人情報に配慮した丁寧なトーンを維持してください。
2. 本題の回答:
提供された{len(found_titles)}つのPDF資料({", ".join(found_titles)})に基づき、
質問に正確に、かつ分かりやすく答えてください。直接の回答以外にも参考になりそうな情報を補足してください。
制約事項:
- 資料に記載されている内容のみに基づいて回答してください。回答内に「提供された資料によると」などの文言は不要です。
- 資料に情報がない場合は「提供された資料の中には、その質問に関する情報が見当たりませんでした」と正直に答えてください。
3. 出典の明記:
最後に参照した資料名を記載してください。
'''
# 3. Gemini による生成
response = client.models.generate_content(
model='gemini-2.5-flash',
contents=content_parts,
config=types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=0.2
)
)
return response.text
ここでは、質問の回答を生成する際に、ユーザープロファイルと過去の質問履歴に応じた挨拶を追加するように指示しています。サンプルデータを用いて回答を生成してみましょう。
# --- 実行イメージ ---
# サンプルデータ(実際は DB やファイルから読み込む想定)
user_profile = '''
東京在住30代男性
出身地:大阪
名前:具々類 蔵人
職業:IT企業に勤めるエンジニア。
家族:妻と小学生の娘が一人いる。
'''
past_history = '''
- 2024-12-10: 「新宿区内には趣味でPythonを学べるような勉強会やコミュニティはありますか?」
- 2025-01-15: 「小学生の子供について、新宿区で利用できる教育手当や助成金を教えて。」
'''
question = '夏休みに娘と出かける区内のスポットを教えて。'
print(f'User input: {question}')
# 検索の実行
search_query, search_results = search_relevant_documents(question, top_k=3)
# パーソナライズされた回答の生成
final_answer = generate_personalized_answer(question, search_results, user_profile, past_history)
# 結果を表示
print('\n--- Search query ---')
print(search_query)
print('\n--- Search result ---')
for i, (title, score) in enumerate(search_results):
filename = embeddings_dict[title]['filename']
print(f'{i+1}. {title} (Score: {score:.4f}) - ファイル: {filename}')
print('\n--- Final answer ---')
display(Markdown(final_answer))
[出力結果]
User input: 夏休みに娘と出かける区内のスポットを教えて。
--- Search query ---
自治体のガイドブックで「夏休み」「子供向け」「親子」「家族」「イベント」「スポット」
「遊び場」を中心に、目次や索引、本文を検索します。具体的には、「夏季限定イベント」
「水遊びができる公園」「プール開放情報」「涼しい屋内施設(博物館、科学館、図書館など)」
「体験学習」「ワークショップ」「夏祭り」「動物園」「水族館」といった項目を確認します。
雨の日でも楽しめる場所や、無料で利用できる施設も探すと良いでしょう。各施設の開館時間、
休館日、料金、対象年齢、事前予約の要不要も合わせて確認し、娘さんと一緒に楽しめる場所を
見つけます。
--- Search result ---
1. もくじ (Score: 0.7149) - ファイル: 000391174.pdf
2. 子ども/教育 (Score: 0.7096) - ファイル: 000391194.pdf
3. 文化/スポーツ (Score: 0.7027) - ファイル: 000391197.pdf
--- Final answer ---
<整形表示された最終回答>
<整形表示された最終回答> には、次のようにマークダウンを整形表示した結果が示されます。

質問文では「娘」としか言ってませんが、プロファイル情報から小学生の娘であることを理解した上での回答が生成されました。
本番システムに向けた考慮点
今回のハンズオンでは、RAG の仕組みを理解するために、「手作り RAG システム」を構築しました。この仕組みを実際のサービスとして公開する際は、スケーラビリティとセキュリティへの対応、そして、回答の品質を継続的に維持する仕組みが必要です。ここでは、これらの観点での考慮点を整理しておきます。
スケーラビリティとデータ管理の自動化
今回の構成では、埋め込みベクトルのデータのすべてを Python のディクショナリーに保存して利用しましたが、実運用で大量のドキュメントを扱う際は、次のような考慮が必要です。
-
ベクトル検索エンジンの利用:大規模なデータからミリ秒単位で類似度検索を行うには、Vertex AI Vector Search のような、近傍探索に特化したマネージドサービスが有効です。
-
データ更新のパイプライン化:PDF ファイルが追加・更新されるたびに手動で埋め込みベクトルを生成するのではなく、Cloud Storage へのアップロードをトリガーに埋め込みベクトルを自動生成する仕組みが必要です。
-
フルマネージドな RAG サービスへの移行:検索精度や運用の手間を考えると、手作りシステムから、Vertex AI Search へ切り替える方法もあります。ドキュメントのパースやチャンク分割、埋め込みベクトルの生成、検索といった一連の処理が自動化できます。
セキュリティとガバナンスの確保
自治体や企業のデータを扱う場合は、情報の保護にも考慮が必要です。「手作り RAG システム」には含まれない、次のような観点にも注意が必要です。
-
認証・認可の厳格化:システムへのアクセス権限はもちろん、「どのユーザーがどの文書を閲覧できるか」というファイル単位のセキュリティ制御も考慮が必要です。
-
個人情報(PII)の保護:パーソナライゼーションで利用した「ユーザープロファイル」や「質問履歴」には、機密性の高い情報が含まれる可能性があります。Cloud DLP を利用して、機密情報を自動で匿名化・マスキングする仕組みを検討するとよいでしょう。
-
ネットワーク境界の保護:データが外部へ漏洩するリスクを最小限に抑えるため、VPC Service Controls を利用して、システムが使用するリソースをプライベートなネットワーク内に閉じ込めます。
回答品質の評価とオブザーバビリティ
RAG システムでは、回答速度や回答の質を客観的に評価して、継続的にモニタリングする仕組みが必要です。
-
RAG に特化した指標による評価:「回答にハルシネーションが含まれていないか(忠実性)」「質問に対して適切な検索ができているか(関連性)」といった観点を、Gen AI Evaluation Service などを用いて定量的に測定します。
-
コストとレイテンシの最適化:Gemini 2.5 Pro(高精度)と Gemini 2.5 Flash(高速・安価)をタスクの難易度に応じて使い分けるルーティング処理を導入して、ユーザー体験とコストパフォーマンスを両立します。
-
根拠の明示(グラウンディング):ユーザーが回答の情報源を確認できるように、生成された回答が「PDF のどの箇所に基づいているか」を引用元として明示し、ワンクリックで一次ソースを確認できる UI を提供します。
まとめ
この記事では、Vertex AI Search などのマネージドサービスを使わずに、Python と Gemini API を組み合わせた「手作り RAG システム」を構築しました。このハンズオンを通して、次のポイントが確認できました。
-
RAG は「検索」と「生成」の組み合わせ
精度の高い回答を得るには、LLM の性能に頼るだけではなく、いかに適切なコンテキスト(資料)を見つけ出すかという検索プロセスの設計が重要です。 -
LLM による「前処理」の効果
今回は、ドキュメントをチャンクに分割するのではなく、ドキュメント単位で検索して、回答生成のプロンプトに入力する手法を採用しました。この際、ドキュメントをそのままベクトル化するのではなく、LLM でサマリーやキーワードを抽出することで、検索精度が向上することを説明しました。これは、ロングコンテキストを活用した、現代的な RAG の手法と言えます。 -
パーソナライズの応用
ユーザーのプロファイルや過去の文脈をプロンプトに追加することで、パーソナライズされた回答が生成できることを確認しました。
実際の業務システムでは、ここからさらに、ハイブリッド検索(ベクトル検索 + キーワード検索)の導入や検索結果のリランキングなどにより、検索精度をさらに向上する工夫をすることもあります。今回のハンズオンで RAG の裏側にあるロジックを理解しておけば、Google Cloud の各種マネージドサービスを利用する際も、それが「中で何をしているのか」を理解して、より高度なチューニングができるようになるでしょう。
Discussion