はじめに
こんばんは。今回は4日目です。今日は前回のRAG構築に続き、「ベクトル検索(Embedding)を使ったより実践的なRAG構築」を行っていきます。外部のベクトルデータベースを使うと重いので、行列・ベクトル計算ができるライブラリnumpyを使って簡易的なベクトル検索エンジンを組みます。
RAGの基礎知識は前回の記事にあります。
新しい基礎知識
チャンク分割 :RAGで使う参考資料を意味のある塊(文ごと)に分ける。
埋め込み(Embedding) :各文を多次元ベクトル(数字の配列)に変換する。(今回はOllamaにもともとある機能を使います)
ベクトル検索(類似度計算) :ユーザーの質問もベクトル化し、最もベクトルの「向き(意味)」が近い文を行列計算で割り出す。
勉強開始
まず、今回使用するライブラリnumpyをインストールします。
pip install numpy
次にプログラム main.pyと参照する資料(「講義ノート」と呼ぶ) lecture_note.txtを同じフォルダ内に作成します。今回の参照資料はあえて「全く関係のない雑多な情報」を混ぜた長めの構成にします。
人工知能(AI)の歴史は1950年代から始まりました。
アルカディア・プロトコルとは、2026年に開発された量子コンピュータ耐性を持つ新しい暗号化通信プロトコルである。
今日の昼食はトマトパスタと鶏肉とサラダを食べました。
このプロトコルでは、通信のたびに「動的カオス鍵生成(DCKG)」と呼ばれるアルゴリズムを使用する。
大学の期末試験のスケジュールは来週発表されます。
DCKGは、端末の温度変化と大気ノイズをブレンドして使い捨ての暗号鍵を生成する技術である。
今はまっているゲームはストリートファイター6とクラロワです。
main.pyには前回のプログラムに追加・変更を加えて、主に
- ①テキストをベクトルに変換する関数
- ②2つのベクトルの「コサイン類似度」を計算する関数
- ③参考資料(講義ノート)を1行ずつの「チャンク」に分割して読み込む部分
を作ります。
def get_embedding(text):
response = ollama.embeddings(
model='qwen2.5:3b', # 埋め込みモデルとしてもQwenを使用
prompt=text
)
return np.array(response['embedding'])
def cosine_similarity(v1, v2):
# コサイン類似度 = (A・B) / (||A|| * ||B||)
dot_product = np.dot(v1, v2)
norm_v1 = np.linalg.norm(v1)
norm_v2 = np.linalg.norm(v2)
return dot_product / (norm_v1 * norm_v2)
with open(filepath, "r", encoding="utf-8") as f:
chunks = [line.strip() for line in f.readlines() if line.strip()]
最終的なプログラム全体は以下の通りです。
import ollama
import numpy as np
# 1. Ollamaを使ってテキストをベクトル(Embedding)に変換する関数
def get_embedding(text):
response = ollama.embeddings(
model='qwen2.5:3b', # 埋め込みモデルとしてもQwenを使用
prompt=text
)
return np.array(response['embedding'])
# 2. 2つのベクトルの「コサイン類似度」を計算する関数(情報工学の基本!)
def cosine_similarity(v1, v2):
# コサイン類似度 = (A・B) / (||A|| * ||B||)
dot_product = np.dot(v1, v2)
norm_v1 = np.linalg.norm(v1)
norm_v2 = np.linalg.norm(v2)
return dot_product / (norm_v1 * norm_v2)
# 3. 本物のRAGを実行するメイン関数
def vector_rag_system(user_question, filepath):
# 講義ノートを1行ずつの「チャンク」に分割して読み込む
with open(filepath, "r", encoding="utf-8") as f:
chunks = [line.strip() for line in f.readlines() if line.strip()]
print(f"📦 講義ノートを {len(chunks)} 個のチャンクに分割しました。")
# ユーザーの質問をベクトル化
print("🔍 ユーザーの質問をベクトル化しています...")
question_vector = get_embedding(user_question)
# 各チャンクと質問の類似度を計算
print("🧮 数学空間で意味の近さを計算中(コサイン類似度)...")
best_match_chunk = None
highest_similarity = -1.0
for chunk in chunks:
chunk_vector = get_embedding(chunk)
similarity = cosine_similarity(question_vector, chunk_vector)
# ログとして各行の類似度を画面に出してみる(デバッグ用)
print(f" - 類似度: {similarity:.4f} -> 「{chunk[:15]}...」")
if similarity > highest_similarity:
highest_similarity = similarity
best_match_chunk = chunk
print(f"\n🎯 最も意味が近いと判定されたチャンク(スコア: {highest_similarity:.4f}):")
print(f"👉 「{best_match_chunk}」")
# 最適なチャンクだけをコンテキストとしてLLMに渡す
prompt = f"""以下の【参考資料】だけをベースにして【質問】に自然な日本語で答えてください。
資料にない場合は「わかりません」と答えてください。
【参考資料】
{best_match_chunk}
【質問】
{user_question}
"""
print("\n🤖 LLMが思考中...")
response = ollama.chat(
model='qwen2.5:3b',
messages=[{'role': 'user', 'content': prompt}]
)
print("\n📝 【RAGシステムからの最終回答】")
print(response['message']['content'])
if __name__ == "__main__":
# テストする質問
query = "DCKGってどうやって暗号鍵を作っているの?"
vector_rag_system(query, "lecture_note.txt")
このプログラムを実行してみると以下のような出力が得られました。
📦 講義ノートを 7 個のチャンクに分割しました。
🔍 ユーザーの質問をベクトル化しています...
🧮 数学空間で意味の近さを計算中(コサイン類似度)...
- 類似度: 0.7978 -> 「人工知能(AI)の歴史は195...」
- 類似度: 0.8574 -> 「アルカディア・プロトコルとは、...」
- 類似度: 0.7774 -> 「今日の昼食はトマトパスタとサラ...」
- 類似度: 0.8643 -> 「このプロトコルでは、通信のたび...」
- 類似度: 0.8399 -> 「大学の期末試験のスケジュールは...」
- 類似度: 0.8502 -> 「DCKGは、端末の温度変化と大...」
- 類似度: 0.8285 -> 「今はまっているゲームはストリー...」
🎯 最も意味が近いと判定されたチャンク(スコア: 0.8643):
👉 「このプロトコルでは、通信のたびに「動的カオス鍵生成(DCKG)」と呼ばれるアルゴリズムを使用する。」
🤖 LLMが思考中...
📝 【RAGシステムからの最終回答】
このプロトコルでは、通信のたびに「動的カオス鍵生成(DCKG)」というアルゴリズムを使って暗号鍵を生成しているそうです。具体的な方法については詳細が記載されていないので、何らかのカオスモデルを使用して動的に暗号鍵を作成するためのアルゴリズムになっている可能性があります。
本当は、講義ノートの6行目(架空技術であるDCKGの理論説明文)を参照してほしかったのですが、おもったよりコサイン類似度に差が出ず、違う行が参照されてしまいました。
先生(Gemini)曰く、原因として、
- 使用したモデルQwen2.5のような高機能モデルが抽出するベクトルは「単語の完全一致」よりも「文章としての構造や文体」に強く反応しやすい可能性
- チャンクが短すぎて文脈が欠落した可能性
内容というよりも技術説明っぽい「文章の構造」や「~について教えて」という丁寧な質問の雰囲気に引っ張られ、ベースの類似度スコアが全体的に引き上げられてしまったんですね。また、1行ずつにチャンク分けをしたことで、「~という技術である。」という解説の行よりも、「~というアルゴリズムを使用する。」という行の方が「質問内容(DCKG技術の「仕組み」)」という概念に近いと判断されたようです。
問題改善
そこで次は、チャンク分けを1行ずつではなく2行ずつにしてみましょう。今回の講義ノートは、関係ある文章と関係のない文章が交互に入っている「最悪なノイズ環境」ですが一度やってみます。プログラムは以下の通りです。
import ollama
import numpy as np
def get_embedding(text):
response = ollama.embeddings(
model='qwen2.5:3b',
prompt=text
)
return np.array(response['embedding'])
def cosine_similarity(v1, v2):
return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))
def vector_rag_system(user_question, filepath):
# --- 【改良】1行ずつではなく、2行ずつのペア(文脈保持)を作る ---
with open(filepath, "r", encoding="utf-8") as f:
lines = [line.strip() for line in f.readlines() if line.strip()]
chunks = []
for i in range(len(lines) - 1):
# 現時点の行と、次の行をセットにして1つのチャンクにする
combined_chunk = lines[i] + "\n" + lines[i+1]
chunks.append(combined_chunk)
# -------------------------------------------------------------
print(f"📦 講義ノートから、文脈を維持した {len(chunks)} 個のチャンクを作成しました。")
print("🔍 ユーザーの質問をベクトル化しています...")
question_vector = get_embedding(user_question)
print("🧮 数学空間で意味の近さを計算中(コサイン類似度)...")
best_match_chunk = None
highest_similarity = -1.0
for chunk in chunks:
chunk_vector = get_embedding(chunk)
similarity = cosine_similarity(question_vector, chunk_vector)
# ログ出力(最初の1行目だけ目印に表示)
first_line = chunk.split('\n')[0]
print(f" - 類似度: {similarity:.4f} -> 「{first_line[:15]}...」")
if similarity > highest_similarity:
highest_similarity = similarity
best_match_chunk = chunk
print(f"\n🎯 最も意味が近いと判定されたチャンク(スコア: {highest_similarity:.4f}):")
print(f"👉\n{best_match_chunk}")
prompt = f"""以下の【参考資料】だけをベースにして【質問】に自然な日本語で答えてください。
資料にない場合は「わかりません」と答えてください。
【参考資料】
{best_match_chunk}
【質問】
{user_question}
"""
print("\n🤖 LLMが思考中...")
response = ollama.chat(
model='qwen2.5:3b',
messages=[{'role': 'user', 'content': prompt}]
)
print("\n📝 【RAGシステムからの最終回答】")
print(response['message']['content'])
if __name__ == "__main__":
# 前回と同じ質問
query = "DCKGってどうやって暗号鍵を作っているの?"
vector_rag_system(query, "lecture_note.txt")
- 類似度: 0.8571 -> 「人工知能(AI)の歴史は195...」
- 類似度: 0.8330 -> 「アルカディア・プロトコルとは、...」
- 類似度: 0.8859 -> 「今日の昼食はトマトパスタとサラ...」
- 類似度: 0.8363 -> 「このプロトコルでは、通信のたび...」
- 類似度: 0.8766 -> 「大学の期末試験のスケジュールは...」
- 類似度: 0.8505 -> 「DCKGは、端末の温度変化と大...」
二行ずつ(重なりあり)で実行してみましたが、結果は大して変わらずDCKGの説明をしている部分ではなく「DCKGというアルゴリズムが使われている」という文が優先されてしまいました。なんとなくそうなるとは思っていました。せっかくならきれいにやりたいので元の文章(講義ノート)を、互いに関係ある文章を隣接させてみて実行してみました。
- 類似度: 0.8571 -> 「人工知能(AI)の歴史は195...」
- 類似度: 0.8471 -> 「アルカディア・プロトコルとは、...」
- 類似度: 0.8445 -> 「このプロトコルでは、通信のたび...」
- 類似度: 0.8342 -> 「DCKGは、端末の温度変化と大...」
- 類似度: 0.8420 -> 「今日の昼食はトマトパスタとサラ...」
- 類似度: 0.8463 -> 「大学の期末試験のスケジュールは...」
🎯 最も意味が近いと判定されたチャンク(スコア: 0.8571):
👉
人工知能(AI)の歴史は1950年代から始まりました。
アルカディア・プロトコルとは、2026年に開発された量子コンピュータ耐性を持つ新しい暗号化通信プロトコルである。
しかし、結果はこんな感じでうまくいきませんでした。こうなったらかなりお手上げですね。先生(Gemini)の出番です。
先生曰く、実務でRAGを組む時、チャット用のLLM(今回でいうQwen2.5:3b)にベクトルを作らせることは絶対しないようです。Embedding専用のモデルが不可欠のようですね。今回の場合でいえば、「生成」「仕組み」という言葉が、人工知能(AI)」という単語と強く結べつきすぎてしまい、架空のDCKGという架空単語を完全に押しつぶしたようです。
おわりに
今回はなかなかうまくいかなくておもしろかったですね。
中途半端で申し訳ないですが、続きのEmbedding専用のモデルをダウンロードして使うのは次回にまわします。ゆっくりやらなきゃ続かないですからね。
読んで頂きありがとうございました。