社内ドキュメントや手元のメモを、外部サービスへ送信せずに検索・回答したい場面が増えてきました。今回は Ollama と埋め込みモデルを使い、ベクトルデータベースなしで動く最小構成の RAG を作ってみます。
先に結論を書くと、文書をチャンクに分割し、埋め込みベクトルを作成して、質問との類似度が高いチャンクだけを LLM に渡せば、Python の標準ライブラリだけでも RAG の基本動作を確認できます。
RAG の構成
今回の処理は次の5段階です。
- 参考文書を用意する
- 文書を小さなチャンクに分割する
- Ollama の埋め込みモデルでベクトル化する
- 質問と各チャンクの類似度を計算する
- 上位のチャンクをプロンプトに含めて LLM に回答させる
埋め込みは、文章の意味を数値ベクトルとして表現する処理です。単純なキーワード一致ではなく、「似た意味の文章」を検索できます。
今回は、Ollama が提供する /api/embed を使います。現行の API では複数テキストをまとめて埋め込むこともでき、返されるベクトルは正規化済みです。詳しくは Ollama公式のEmbeddingドキュメント に説明があります。
Ollama とモデルを準備する
まず Ollama をインストールし、サーバーが起動している状態にします。インストール後、以下のモデルを取得しました。
ollama pull embeddinggemma
ollama pull gemma3:4b
embeddinggemma は検索用の埋め込みモデル、gemma3:4b は回答生成用のモデルです。PC のメモリが少ない場合は、手元で動かしやすいサイズの生成モデルに置き換えてください。
API が応答するか、次のコマンドでも確認できます。
curl http://localhost:11434/api/version
今回のコードは、Ollama の標準的な API URL である http://localhost:11434 を利用します。
最小構成の RAG を実装する
依存パッケージを増やさないため、HTTP 通信と類似度計算は Python 標準ライブラリで書きました。
import json
import math
import urllib.request
OLLAMA_URL = "http://localhost:11434"
documents = [
"Ollamaはローカル環境で大規模言語モデルを実行するためのツールです。",
"RAGは外部の文書を検索し、その内容を言語モデルへのコンテキストとして利用する仕組みです。",
"埋め込みは文章を数値ベクトルに変換し、意味の近さを計算できるようにします。",
"ローカルRAGでは、文書や質問を外部APIへ送信せずに処理できます。",
]
def post_json(path, payload):
body = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
OLLAMA_URL + path,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request) as response:
return json.loads(response.read().decode("utf-8"))
def embed(texts):
result = post_json(
"/api/embed",
{
"model": "embeddinggemma",
"input": texts,
},
)
return result["embeddings"]
def cosine_similarity(a, b):
# /api/embed のベクトルは正規化済みなので、
# 内積だけでも同じ順位になります。
return sum(x * y for x, y in zip(a, b))
def search(query, top_k=2):
vectors = embed(documents + [query])
document_vectors = vectors[:-1]
query_vector = vectors[-1]
scored = []
for document, vector in zip(documents, document_vectors):
score = cosine_similarity(query_vector, vector)
scored.append((score, document))
scored.sort(reverse=True)
return scored[:top_k]
def answer(query, contexts):
context_text = "\n".join(
f"- {document}" for _, document in contexts
)
prompt = f"""次の参考文書だけを使って質問に回答してください。
参考文書に答えがない場合は「参考文書からは判断できません」と答えてください。
### 参考文書
{context_text}
### 質問
{query}
"""
result = post_json(
"/api/chat",
{
"model": "gemma3:4b",
"messages": [
{
"role": "user",
"content": prompt,
}
],
"stream": False,
"options": {
"temperature": 0.2,
},
},
)
return result["message"]["content"]
if __name__ == "__main__":
question = input("質問: ")
contexts = search(question, top_k=2)
print("\n検索結果:")
for score, document in contexts:
print(f"{score:.4f} {document}")
print("\n回答:")
print(answer(question, contexts))
ファイル名を mini_rag.py として保存し、実行します。
python mini_rag.py
例えば、次のように質問します。
質問: 文書を検索して回答に使う仕組みは何ですか?
検索結果には、質問と意味が近い文書ほど高いスコアで表示されます。その上位文書だけをプロンプトへ渡すことで、モデルが持っている一般知識ではなく、手元の文書を根拠に回答しやすくなります。
実装時に気を付けた点
最初に重要なのは、インデックス作成時と検索時で同じ埋め込みモデルを使うことです。モデルを変えるとベクトルの空間が変わるため、既存のベクトルと正しく比較できなくなります。
今回はコードを短くするため、質問するたびに文書の埋め込みを作り直しています。文書数が増えると遅くなるので、実運用では次のような構成にします。
文書追加時:
文書をチャンク分割
各チャンクを埋め込み
チャンクとベクトルを保存
質問時:
質問だけを埋め込み
保存済みベクトルと類似度計算
上位チャンクをLLMへ渡す
保存先は SQLite や JSON でも構いません。さらにデータ量が増えた場合は、ベクトル検索に対応したデータベースを追加すれば拡張できます。
チャンクの長さも検索品質に影響します。短すぎると文脈が切れ、長すぎると関係のない情報まで検索結果に混ざります。今回のサンプルは文書自体が短いため分割していませんが、実際には段落単位や数百文字単位で分割するのが扱いやすいです。
まとめ
Ollama と埋め込みを使うと、ローカル環境だけで RAG の基本構成を確認できます。
今回作ったものは、文書の埋め込み、類似検索、検索結果を使った回答生成という RAG の核心部分だけに絞っています。ライブラリやベクトルデータベースを使っていないため、本番向けではありませんが、処理の流れを理解するには十分でした。
まずは数個のメモや Markdown ファイルを対象に試し、次の段階でチャンク分割、ベクトルの永続化、検索結果の評価を追加していくのがよさそうです。