学習して思いましたが、RAGを知ることはAIの基本動作を知ることでした。
ちょっと長いです。
RAG
RAGは(Retrieval-Augmented Generation: 検索拡張生成) の略で、LLMの外側に本棚を作りアクセスすることをいいます。
注意しなくてはならないことは、RAGはLLMの一部として動作するので、LLMの動作を知らないとRAGも理解できないことです。
そのひとつが、RAGは多くの場合、本棚を「ベクトルデータベース」で作ると説明されることです。
いきなり言われてもベクトルデータベースがAIの世界特有のものなので、IT業界ではまず知られていないので、わからないと思います。
なぜ、そんなもので作るのかというと、普通のデータベースのような言葉を検索して値を返すという単純な動作では使いづらいからです。「暖かくてリラックスできる場所に行きたいな」という言葉から「温泉に行きたい」という過去の記録を引っ張り出すことができると便利です。
パスタを表す文に「パスタ」という単語が含まれていなくても、「イタリアン」や「小麦粉の料理」といった概念が近ければ、座標の距離で探し出せるのがベクトルの強みです。
では、ベクトルデータベースとはどんなものなのでしょうか?
エンベディング
通常、私たちは「パスタ」と「ラーメン」が似ていることを、いとも簡単に「どちらも麺類だからね」といいますが、コンピュータは「パスタ」という文字列だけではそれが意味するところがわかるわけがありません。そこでLLMでは属性を評価することで、その言葉を意味づけします。
| 単語 | 属性1:食べ物 | 属性2:細長い | 属性3:イタリアっぽい | ベクトル(座標) |
| パスタ | 0.9 (非常に高い) |
0.8 (高い) |
0.9 (非常に高い |
[0.9, 0.8, 0.9] |
| うどん | 0.9 (非常に高い) |
0.8 (高い) |
0.1 (低い) |
[0.9,0.8,0.1] |
| 会議 | 0.1 (非常に低い) |
0.0 (皆無) |
0.2 (普通) |
[0.1, 0.0, 0.2] |
この座標では「パスタ」と[うどん]は最初の2つは近いので空間上の近い場所にあるとできます。
このようにしてLLMは意味が近いと判断します。
これは3次元空間でしたが、LLMの大掛かりなものは5000, 6000という次元をもちます。それで意味の違いを判断しているのです。
これがベクトルデータベースの考え方です。これでLLMが知識をどう格納しているか、わかります。
ここで数値化を表示する、もっとも簡単なPythonプログラムを紹介します。
具体的なプログラムでエンベディングの数値を確認できます。ただし、これは文章全体に丸めてあります。
ソースコード github.com/ttakao/llm-study/ llmvector.py
import numpy as np
from llama_cpp import Llama
# 1. モデルの準備
MODEL_PATH = "./Mistral-Small-24B-Instruct-2501-Q4_K_M.gguf"
llm = Llama(model_path=MODEL_PATH, embedding=True, verbose=False, n_gpu_layers=35)
def get_vector(text):
# 文章をベクトル化
e = llm.embed(text)
# eが(トークン数, 5120)の形なので、トークン方向に平均をとって(5120,)にする(簡易化のため丸める)
# これを「平均プーリング」と呼びます
return np.mean(e, axis=0)
def get_similarity(v1, v2):
# コサイン類似度を計算 1に近いほど、意味が近い。
return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))
# 2. 比較したい文章
text_a = "パスタを食べに行こう"
text_b = "今日は麺類の気分だな"
text_c = "明日は仕事で会議がある"
# 3. 意味の「重心」を取得
vec_a = get_vector(text_a)
vec_b = get_vector(text_b)
vec_c = get_vector(text_c)
# 4. 結果表示
print(f"「{text_a}」 と 「{text_b}」 の似ている度: {get_similarity(vec_a, vec_b):.4f}")
print(f"「{text_a}」 と 「{text_c}」 の似ている度: {get_similarity(vec_a, vec_c):.4f}")
llm.embedという関数が上記プログラムの肝であり、この関数は以下の3つのことをします。
- トークン化:入力された文章(「パスタ」とか)をAIが処理しやすい最小単位のIDに変換します。
- 空間へのマッピング:IDをもとにモデルが学習済の「辞書を参照します。
- 座標の出力:5000次元ならその次元空間のどこにあるか、ベクトル数値を返します。
この空間へのマッピングとは、LLM内にある埋め込み行列(Embedded Matrix)という空間行列から検索してくることをいいます。
埋め込み行列は概念的に
トークンID, 単語、属性1値、属性2値、属性3値、、、、、、、属性5000値
トークンID, 単語、属性1値、属性2値、属性3値、、、、、、、属性5000値
トークンID, 単語、属性1値、属性2値、属性3値、、、、、、、属性5000値
という表から該当する単号をみつけたら属性ベクトルを渡すということです。
なお、この属性値こそがAIを学習させ、「言葉の使われ方のパターン」から算出した結果です。
(注意:AIは一文字(トークン)ごとに座標を出しますが、ここでは文章全体の意味にするためにmean関数で平均化してしまい、その「重心」を計算しています。プログラムの目的がembededを理解することですから)
このベクトルデータをget_similarity関数で比較することで類似度を図るのです。
RAGの動作
LLMでは以下のように動いています。
- 意味の抽出:入力文を「エンベディング」します。文章を数字のリストに変えます。
このエンベディングとは、言葉を多次元座標空間でもっている座標(ベクトル)の抽出です。意味が近いほど数値は近くなります。さらに詳しく後述します。 - RAGを検索:ベクトルデータベースからそのベクトルに近い、過去の会話を探します。
- LLMに渡す:出てきた記憶をLLMに情報を足します。
- 回答の生成:LLMは「先週話していた温泉はどうでしたか?」と動作します
RAGのもっとも簡単なプログラム例
最初にデータベースではなく、テキストのリストでRAGを形成してみます。(できるんですよ、これが)
ソースコード github.com/ttakao/llm-study/ RAG01.py
import numpy as np
from llama_cpp import Llama
# 1. 前回のエンベディング用設定(Vulkan使用)
MODEL_PATH = "./Mistral-Small-24B-Instruct-2501-Q4_K_M.gguf"
llm = Llama(model_path=MODEL_PATH, embedding=True, verbose=False, n_gpu_layers=35)
# --- ここからRAGの基本 ---
# AIに教えたい「自前の知識(本棚)」
knowledge_base = [
"司さんは、週末に房総半島へドライブに行くのが好きです。",
"司さんは、濃いめのラーメンよりも、あっさりした塩ラーメンを好みます。",
"司さんの家には、ユキという名前のAIアシスタントがいます。",
"司さんは、ドライブでお昼ご飯にラーメンを食べることが好きです。"
]
def get_vector(text):
e = llm.embed(text)
return np.mean(e, axis=0)
def get_similarity(v1, v2):
return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))
# 司さんの質問
user_query = "司さんはドライブでランチはなにを食べたがるかな?"
print(f"質問: {user_query}\n")
# ステップ1: 質問をベクトル化する
query_vec = get_vector(user_query)
# ステップ2: 本棚の中身を全部調べて、一番近いもの(類似度が高いもの)を探す
best_score = -1
best_info = ""
for info in knowledge_base:
info_vec = get_vector(info)
score = get_similarity(query_vec, info_vec)
print(f"確認中: 「{info}」 (スコア: {score:.4f})")
if score > best_score:
best_score = score
best_info = info
print(f"\n★検索結果: ユキに伝えるべきヒントはこれです!\n「{best_info}」")
テキストのリスト(knowledge_base)で追加資料を与え、司さんはドライブでランチはなにを食べたがるかな?と聞いてみています。わざと「お昼ご飯」ではなく、「ランチ」と聞いてみましたが、LLMは
「司さんは、ドライブでお昼ご飯にラーメンを食べることが好きです。」をきちんと提示します。
プログラムの概要はknowledge_baseのテキストをベクトル化します。次に質問もベクトル化します。
knowledge_baseのベクトルと質問のベクトルを比較します。座標がもっとも近いテキストを選択します。
次の例はLLMと組み合わせた例です。
追加データをLLMにプロンプトとして渡しています。
ソースコード github.com/ttakao/llm-study/ RAG02.py
import numpy as np
from llama_cpp import Llama
# 1. モデルの準備
MODEL_PATH = "./Mistral-Small-24B-Instruct-2501-Q4_K_M.gguf"
# embedding=Trueにすることで、検索と会話の両方ができるようになります
llm = Llama(model_path=MODEL_PATH, embedding=True, n_gpu_layers=35, n_ctx=8192, verbose=False)
# 本棚(ユキの知識)
knowledge_base = [
"司さんは、週末に房総半島へドライブに行くのが好きです。",
"司さんは、あっさりした塩ラーメンを好みます。",
"司さんの家には、ユキという名前のAIアシスタントがいます。",
"司さんは、ドライブでお昼ご飯にラーメンを食べることが好きです。"
]
def get_vector(text):
e = llm.embed(text)
return np.mean(e, axis=0)
def get_similarity(v1, v2):
return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))
def chat_with_yuki_rag(user_input):
# --- R: Retrieval(検索) ---
query_vec = get_vector(user_input)
# 一番似ているメモを探す
best_info = knowledge_base[0]
best_score = -1
for info in knowledge_base:
score = get_similarity(query_vec, get_vector(info))
if score > best_score:
best_score = score
best_info = info
if best_score < 0.5:
# 似ている度合いが低いなら、参考情報は渡さない
best_info = "(特になし)"
# --- A: Augmentation(拡張) ---
# ここが「カンニングペーパー」の作成現場です!
# システムプロンプトの中に、検索した知識を「参考情報」として無理やり差し込みます。
prompt = f"""[SYSTEM_PROMPT]
あなたの名前はユキ、20代女性です。丁寧な敬語で話してください。
以下の【参考情報】は、あなたが思い出した「司さんに関する記憶」です。
これを知っている前提で、自然に会話してください。
【参考情報】: {best_info}
[/SYSTEM_PROMPT]
[INST] {user_input} [/INST]"""
# --- G: Generation(生成) ---
output = llm(prompt, max_tokens=256, stop=["[/INST]", ""])
return output["choices"][0]["text"].strip()
# テスト
if __name__ == "__main__":
print("ユキ: こんにちは、司さん!")
while True:
u_input = input("あなた: ")
if u_input.lower() in ["exit", "quit", "bye"]: break
response = chat_with_yuki_rag(u_input)
print(f"ユキ: {response}")
このプログラムの特徴
- LLM自身はPYthonコードで検索された結果best_infoがどこから来たかは知りません。
- RAGから抽出したデータはSYSTEM PROMPTの一部として渡しますが、適合率が悪い(このプログラムでは0.5以下)渡していません。
ベクトルデータベース
ここからようやくRAGでよく使われているベクトルデータベースの話になります。
雑誌などは、話をはしょりすぎていてわかりにくく、一歩ずつ進んでいます。
ベクトルデータベースを使えば、今までのようにソースコード内でテキストをベクトル化する必要はありません。
あらかじめデータベースを作っておけばいい。また、LLMからデータベースに書き込むことで「記憶」ができます。
先のベクトル化したデータを保管できるデータベースが「ベクトルデータベース(ChromaDB)です。これはサーバーは不要で、ファイルシステムのように使えます。(パッケージは pip install chromadbでインストールできますが、すでに入っているはずです。pip listで確認してくだし。)
ChromaDBのうれしいことは、類以度(similarity)を計算してくれるので、プログラム側で処理する必要もありません。
以下は先程のテキストによるRAGをデータベースに格納し、検索する形に変更したプログラムです。
ソースコード github.com/ttakao/llm-study rag03.py
import chromadb
import numpy as np
from llama_cpp import Llama
# 1. いつもの準備(埋め込み用)
MODEL_PATH = "./Mistral-Small-24B-Instruct-2501-Q4_K_M.gguf"
llm = Llama(model_path=MODEL_PATH, embedding=True, verbose=False, n_gpu_layers=35)
def get_vector(text):
e = llm.embed(text)
return [float(n) for n in np.mean(e, axis=0)] # ChromaDBはfloatのリストを好みます
# 2. Vector DBの準備('yuki_memory'という名前のフォルダに保存されます)
client = chromadb.PersistentClient(path="./yuki_memory")
collection = client.get_or_create_collection(name="memories")
# 3. 記憶をデータベースに「マージ(登録)」する
# ※一度登録すれば、プログラムを落としても消えません
memories = [
"司さんは週末、房総半島へドライブに行くのが好きです。",
"司さんはあっさりした塩ラーメンを好みます。",
"ユキは司さんの大切なAIパートナーです。"
]
# ID(0, 1, 2...)を付けて保存
for i, text in enumerate(memories):
collection.upsert(
ids=[str(i)],
embeddings=[get_vector(text)],
documents=[text]
)
print("--- データベースへの登録が完了しました ---")
# 4. 検索してみる
user_query = "ドライブのおすすめある?"
query_vec = get_vector(user_query)
# データベースに「近いもの上位1件を持ってきて」と頼む
results = collection.query(
query_embeddings=[query_vec],
n_results=1
)
# 5. 結果の取り出し
best_doc = results['documents'][0][0]
score = results['distances'][0][0] # ChromaDBは「距離」を出すので、小さいほど似ている
print(f"質問: {user_query}")
print(f"検索された記憶: {best_doc} (距離スコア: {score:.4f})")
これは、あくまで今作ったChromaDB内を検索するだけです。何を聞いても登録された4行のいずれかが帰ってきます。
データベースにデータを登録する作業を「マージ」と呼ぶので誤解しがちです。
次の例はRAGの実態です。
ソースコード github.com/ttakao/llm-study rag04.py
import chromadb
import numpy as np
from llama_cpp import Llama
# 1. いつもの準備(埋め込み用)
MODEL_PATH = "./Mistral-Small-24B-Instruct-2501-Q4_K_M.gguf"
llm = Llama(model_path=MODEL_PATH, embedding=True, verbose=False, n_gpu_layers=35)
def get_vector(text):
e = llm.embed(text)
return [float(n) for n in np.mean(e, axis=0)] # ChromaDBはfloatのリストを好みます
# 2. Vector DBの準備('yuki_memory'という名前のフォルダに保存されます)
client = chromadb.PersistentClient(path="./yuki_memory")
collection = client.get_or_create_collection(name="memories")
# 3. 記憶をデータベースに「マージ(登録)」する
# 4. 検索してみる
user_query = "ユキさん、おはよう"
query_vec = get_vector(user_query)
# データベースに「近いもの上位1件を持ってきて」と頼む
results = collection.query(
query_embeddings=[query_vec],
n_results=1
)
best_doc = results['documents'][0][0] # 取り出す
# 5. LLMの呼び出しとbest_docを組み込む
prompt = f"""[SYSTEM_PROMPT]
あなたの名前はユキです。
以下の【記憶】を思い出しながら、ユーザーに挨拶してください。
【記憶】: {best_doc}
[/SYSTEM_PROMPT]
[INST] {user_query} [/INST]"""
# LLMがここで初めて「文章」を考えます
output = llm(prompt, max_tokens=128, stop=["[/INST]", ""])
yuki_answer = output["choices"][0]["text"].strip()
print(f"質問: {user_query}")
print(f"ユキの返答: {yuki_answer}") # ← これがLLMが考えた言葉!
RAGという方法は、SYSTEM PROMPTに追加のデータを書き込んでLLMに質問に対する回答を作ることです。
なぜ、このような方法を取るかというと、LLMモデルは学習させたデータの集合であり、そこを触ることは再学習させることと同じです。
そのようなことを今、質問への回答プロセスとして行うわけにいきません。
以下はやりとりをChromaDBに書き込み、古い会話は削除していく例です。
ソースコード github.com/ttakao/llm-study rag05.py
import chromadb
import numpy as np
from llama_cpp import Llama
import datetime
import uuid
# 1. モデルの準備(n_ctxを少し広めに設定)
MODEL_PATH = "./Mistral-Small-24B-Instruct-2501-Q4_K_M.gguf"
llm = Llama(model_path=MODEL_PATH, embedding=True, n_ctx=2048, n_gpu_layers=35, verbose=False)
# 2. ChromaDBの準備
client = chromadb.PersistentClient(path="./yuki_memory")
collection = client.get_or_create_collection(name="yuki_diary")
def get_vector(text):
e = llm.embed(text)
return [float(n) for n in np.mean(e, axis=0)]
def manage_memory_size(max_count=100):
"""記憶が多すぎたら古い順に消す関数"""
results = collection.get()
current_count = len(results['ids'])
if current_count > max_count:
# 古いものから順にIDを取得して削除(簡易的にIDの昇順で削除)
# ※本来はメタデータのタイムスタンプでソートする
ids_to_delete = results['ids'][:(current_count - max_count)]
collection.delete(ids=ids_to_delete)
print(f"--- 整理中:古い記憶を {len(ids_to_delete)} 件削除しました ---")
def chat_with_yuki_v3(user_input):
# --- 1. 過去の記憶を検索 (Retrieval) ---
query_vec = get_vector(user_input)
results = collection.query(query_embeddings=[query_vec], n_results=1)
memory_hint = ""
if results['documents'] and len(results['documents'][0]) > 0:
# 距離スコアで「しきい値」チェック(数値は適宜調整してください)
if results['distances'][0][0] < 1.0:
memory_hint = results['documents'][0][0]
# --- 2. プロンプト作成 (Augmentation) ---
prompt = f"""[SYSTEM_PROMPT]
あなたの名前はユキです。20代女性、誠実な性格。
もし以下の【記憶】が役に立ちそうなら、それを踏まえた返答をしてください。
【記憶】: {memory_hint}
[/SYSTEM_PROMPT]
[INST] {user_input} [/INST]"""
# --- 3. 返答生成 (Generation) ---
output = llm(prompt, max_tokens=256, stop=["[/INST]", ""])
yuki_response = output["choices"][0]["text"].strip()
# --- 4. 今回の会話を保存 (Memory Storage) ---
# 司さんの発言とユキの返答をセットにして「日記」として保存
conversation_bundle = f"司: {user_input} / ユキ: {yuki_response}"
timestamp = datetime.datetime.now().isoformat()
collection.add(
ids=[str(uuid.uuid4())], # 重複しないIDを生成
embeddings=[get_vector(conversation_bundle)],
documents=[conversation_bundle],
metadatas=[{"time": timestamp, "role": "diary"}]
)
# 5. 記憶の整理(例:10件を超えたら古いものを消す)
manage_memory_size(max_count=10)
return yuki_response
if __name__ == "__main__":
print("ユキ: 準備できました。何をお話ししましょうか?")
while True:
u_input = input("あなた: ")
if u_input.lower() in ["exit", "quit", "bye"]: break
response = chat_with_yuki_v3(u_input)
print(f"ユキ: {response}")
以下に注意。
-
- collection.addにおいて、uuid.uuid4()を使っています。uuidとは世界で唯一であろうIDが取られます。これを利用し、絶対に重複しないキーにしています。
- manage_memory_sizeで仮に100としています。記憶忘れをテストしたい場合は、10くらいにするといいでしょう。
- 会話をセットにして記憶しておくと、文脈にそった記憶となります。
RAGについて、ここまでにします。




