社内規程、議事録、手順書などを検索するとき、キーワードだけでは目的の情報にたどり着けないことがあります。一方で、社内文書を外部のAIサービスへ送ることには、情報管理や契約上の懸念があります。
そこで今回は、OllamaでローカルLLMを動かし、文書を外部へ送信せずに検索・回答できる仕組みを構築しました。結論から言うと、文書を小さく分割してベクトル化し、質問に近い文書片を検索してからLLMへ渡す「RAG(Retrieval-Augmented Generation)」が、最小構成でも実用的に動作します。
構成と前提
文書の登録から検索・回答までの処理全体を図にすると次の通りです。
今回の構成は次のとおりです。
- Ollama:ローカルLLMと埋め込みモデルの実行
- Python:文書の分割、検索、回答生成
- JSONファイル:ベクトル化した文書の保存
- Markdown・テキスト:検索対象の社内文書
最初にモデルをダウンロードする必要がありますが、ダウンロード後の検索・回答処理はオフラインで実行できます。検証では、生成モデルに gemma3:4b、埋め込みモデルに nomic-embed-text を使いました。
Ollamaをインストールした後、以下を実行します。
ollama pull gemma3:4b
ollama pull nomic-embed-text
ollama list
Ollamaが起動していない場合は、別のターミナルで次のコマンドを実行します。
ollama serve
WindowsやmacOSでは、Ollamaのデスクトップアプリを起動していれば、通常はAPIも利用できます。
文書をチャンクに分割する
LLMへ文書全体を渡すのではなく、検索しやすい単位に分割します。今回は1チャンクあたり約800文字、前後に100文字の重複を持たせました。
重複を入れるのは、文の途中で分割されても前後の文脈を残すためです。
まず、次のようなディレクトリを用意します。
project/
docs/
security.md
onboarding.txt
build_index.py
search.py
build_index.py を作成します。
import json
import urllib.request
from pathlib import Path
OLLAMA_URL = "http://localhost:11434/api/embeddings"
MODEL = "nomic-embed-text"
CHUNK_SIZE = 800
OVERLAP = 100
def embed(text: str) -> list[float]:
payload = json.dumps({
"model": MODEL,
"prompt": text
}).encode("utf-8")
request = urllib.request.Request(
OLLAMA_URL,
data=payload,
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(request) as response:
result = json.load(response)
return result["embedding"]
def split_text(text: str) -> list[str]:
chunks = []
start = 0
while start < len(text):
end = start + CHUNK_SIZE
chunk = text[start:end].strip()
if chunk:
chunks.append(chunk)
if end >= len(text):
break
start = end - OVERLAP
return chunks
records = []
for path in Path("docs").glob("*"):
if path.suffix.lower() not in [".txt", ".md"]:
continue
text = path.read_text(encoding="utf-8")
for index, chunk in enumerate(split_text(text)):
print(f"embedding: {path.name} / {index}")
records.append({
"source": str(path),
"chunk_id": index,
"text": chunk,
"embedding": embed(chunk)
})
Path("index.json").write_text(
json.dumps(records, ensure_ascii=False),
encoding="utf-8"
)
print(f"indexed: {len(records)} chunks")
実行します。
python build_index.py
index.json には、文書の内容とベクトルが保存されます。文書を追加・更新した場合は、同じコマンドを再実行してインデックスを作り直します。
ベクトル検索を実装する
質問を同じ埋め込みモデルでベクトル化し、文書チャンクとのコサイン類似度を計算します。値が大きいほど、質問と内容が近いと判断できます。
依存ライブラリを増やさず、Python標準ライブラリだけで実装しました。
import json
import math
import urllib.request
EMBEDDING_URL = "http://localhost:11434/api/embeddings"
GENERATE_URL = "http://localhost:11434/api/generate"
EMBEDDING_MODEL = "nomic-embed-text"
GENERATION_MODEL = "gemma3:4b"
def embed(text: str) -> list[float]:
body = json.dumps({
"model": EMBEDDING_MODEL,
"prompt": text
}).encode("utf-8")
request = urllib.request.Request(
EMBEDDING_URL,
data=body,
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(request) as response:
return json.load(response)["embedding"]
def cosine_similarity(a: list[float], b: list[float]) -> float:
numerator = sum(x * y for x, y in zip(a, b))
norm_a = math.sqrt(sum(x * x for x in a))
norm_b = math.sqrt(sum(y * y for y in b))
if norm_a == 0 or norm_b == 0:
return 0.0
return numerator / (norm_a * norm_b)
def search(query: str, top_k: int = 3) -> list[dict]:
query_vector = embed(query)
with open("index.json", encoding="utf-8") as file:
records = json.load(file)
for record in records:
record["score"] = cosine_similarity(
query_vector,
record["embedding"]
)
return sorted(
records,
key=lambda record: record["score"],
reverse=True
)[:top_k]
文書数が少ないうちは全件比較で十分です。数千〜数万チャンクに増えた場合は、SQLiteのベクトル拡張や専用のベクトルデータベースを検討します。
検索結果をLLMへ渡して回答する
検索した文書をコンテキストとしてLLMに渡します。重要なのは、LLMに「コンテキスト以外の情報で推測しない」と明示することです。
def generate_answer(question: str, contexts: list[dict]) -> str:
context_text = "\n\n".join(
f"[出典: {item['source']}]\n{item['text']}"
for item in contexts
)
prompt = f"""あなたは社内文書検索アシスタントです。
以下の参考文書だけを根拠に、日本語で回答してください。
参考文書に答えがない場合は「文書内に根拠がありません」と答えてください。
推測で補完せず、必要であれば出典ファイル名も示してください。
### 参考文書
{context_text}
### 質問
{question}
"""
body = json.dumps({
"model": GENERATION_MODEL,
"prompt": prompt,
"stream": False
}).encode("utf-8")
request = urllib.request.Request(
GENERATE_URL,
data=body,
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(request) as response:
return json.load(response)["response"]
if __name__ == "__main__":
question = input("質問> ")
results = search(question)
print("\n--- 検索結果 ---")
for result in results:
print(f"{result['score']:.3f} {result['source']}")
print("\n--- 回答 ---")
print(generate_answer(question, results))
実行例です。
python search.py
質問> 新入社員のアカウント申請はどのように行いますか?
回答だけでなく、類似度と出典ファイルも表示するようにしました。これにより、回答が不自然な場合に、どの文書が参照されたのか確認できます。
実運用で気を付けたこと
実際に文書を入れて試すと、検索結果の品質はチャンクの切り方に大きく左右されました。見出しと本文が別チャンクになると、質問に対する説明が途中で切れてしまいます。そのため、Markdownでは見出しを次のチャンクへ引き継ぐ処理を追加すると改善します。
また、機密文書を扱う場合でも、ログや一時ファイルに質問内容が残ることがあります。OllamaのAPIだけでなく、アプリケーション側のログ出力も確認が必要です。
モデルの応答をそのまま業務判断に使うのも避けます。検索結果の出典を表示し、最終的には原文を確認できる画面にするのが安全です。文書ごとにアクセス権が異なる場合は、検索前にユーザーの権限でフィルタリングし、権限外の文書をLLMへ渡さない設計にします。
まとめ
Ollamaを使うと、文書を外部サービスへ送信せず、ローカル環境だけで社内文書検索の仕組みを構築できます。
今回の最小構成では、次の流れを実装しました。
- 文書をチャンクへ分割する
- 埋め込みモデルでベクトル化する
- 質問との類似度で検索する
- 検索結果をローカルLLMへ渡す
- 出典付きで回答する
本格運用では、認証・認可、文書更新の自動化、検索ログの管理、回答の評価が必要です。それでも、まず手元の文書で検証できる点は、ローカルLLMとRAGを試す大きな利点だと感じました。