「複数人でローカルLLMを共有すると、繰り返し質問でレスポンスが遅くなる」
そんな悩みを抱えるインフラエンジニアやシステム管理者は多いはずです。この記事では、OllamaチームサーバーにRedisセマンティックキャッシュを組み込み、意味的に類似した質問に対してLLM推論を再実行せずキャッシュ応答を返す仕組みを構築します。RedisStackのインストールから、Pythonによるキャッシュ層の実装、ヒット率測定まで、実務で即使える手順を丸ごと解説します。
この記事のポイント
・RedisStackのHNSWベクターインデックスと nomic-embed-text でセマンティックキャッシュを実現できる
・類似度閾値0.92(コサイン距離0.08)を起点に調整すれば、意味が同じ質問を自動でキャッシュヒットに振り分けられる
・Pythonの redis-py 17行のクラスで既存Ollamaコードへの差し込みが可能
・TTL・DIM不一致・メモリ上限が本番でよく引っかかるポイントで、それぞれ具体的な対処がある
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
セマンティックキャッシュがOllamaチームサーバーで解決する問題
チームでOllamaを共有運用するようになると、ある時点で「同じような質問が繰り返し飛んでくる」という現象が起きます。たとえば「Linuxでディスク容量を確認するコマンドは?」と「ディスク使用量を調べるLinuxコマンドを教えて」は、文字列としては別ですが意味はほぼ同一です。
通常のテキストキャッシュ(Redisのキー=質問文字列)ではこれを同一視できないため、GPU推論が2回走ります。 セマンティックキャッシュは、質問をベクトルに変換して「意味の近さ」で判定します。コサイン類似度が閾値を超えれば、過去の応答をそのまま返すのでGPUは使いません。
チーム規模が5名以上になると、同カテゴリの質問が日に何十回も来るケースがあります。現場で聞いた話では、セマンティックキャッシュ導入後にOllamaのGPU使用率が30~40%台から20%台に落ちたという事例もあります。
GPU1枚しかない環境では、推論待ちキューが詰まらなくなるだけで体感速度が大幅に改善します。 なお、Ollamaサーバーの初期構築については Ubuntu ServerでローカルLLMを構築する方法 を先に参照してください。
全体構成:RedisStack + nomic-embed-text + Ollamaの連携アーキテクチャ
本記事で構築するシステムは3層構造です。 1. **クライアント** がPythonキャッシュ層にリクエストを送る 2. **Pythonキャッシュ層** が質問テキストをOllamaのnomic-embed-text でベクトル化し、RedisStackのHNSWインデックスを検索する
3. キャッシュヒットなら即応答、ミスなら **OllamaのLLM** (例: llama3.3:70b-instruct-q4_0)を呼び出してその結果をキャッシュに書き込む
RedisStackを選ぶ理由は、標準Redisに追加せず1パッケージでベクターサーチ(RediSearch)とJSON機能が使えるためです。SCANでキーを総当たりするナイーブな実装と異なり、HNSWインデックスはキャッシュエントリが数千件に増えても検索時間はほぼ一定です。
埋め込みモデルは
nomic-embed-text(768次元)を使います。軽量でOllama上で動き、英語・日本語どちらの質問にも対応できます。
RedisStackをUbuntu Serverにインストールする手順
1. リポジトリの登録
# Redisの公式GPGキーを取得してリポジトリを追加する curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor \ -o /usr/share/keyrings/redis-archive-keyring.gpg echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] \ https://packages.redis.io/deb $(lsb_release -cs) main" \ | sudo tee /etc/apt/sources.list.d/redis.list sudo apt update
lsb_release -cs でディストリビューション名が自動判定されます。
2. RedisStackのインストールと起動確認
sudo apt install -y redis-stack-server sudo systemctl enable redis-stack-server sudo systemctl start redis-stack-server # 起動確認 redis-cli ping # 期待する出力: PONG # RediSearchモジュールがロードされているか確認 redis-cli MODULE LIST # redis-search が表示されればOK
redis-server がすでに起動している場合は、ポート競合を避けるために停止してから起動します。sudo systemctl stop redis-server sudo systemctl disable redis-server sudo systemctl start redis-stack-server
3. ベクターインデックスの事前作成
Pythonコードから動的に作ることもできますが、本番では事前に作成しておくほうが安定します。# ベクターインデックスをredis-cliから直接作成する例 redis-cli FT.CREATE llm-cache ON HASH PREFIX 1 "cache:" \ SCHEMA \ prompt TEXT \ response TEXT \ embedding VECTOR HNSW 6 TYPE FLOAT32 DIM 768 DISTANCE_METRIC COSINE # 作成確認 redis-cli FT.INFO llm-cache | grep num_docs
DIM 768 は nomic-embed-text の出力次元数です。別の埋め込みモデルに変える場合はここを合わせて変更します。mxbai-embed-large なら1024になります。
Pythonでセマンティックキャッシュ層を実装する手順
1. 依存パッケージのインストール
pip install redis numpy requests
2. キャッシュクラスの実装
以下のコードを/opt/ollama-cache/semantic_cache.py として保存します。import json, struct, hashlib import numpy as np import requests from redis import Redis from redis.commands.search.query import Query OLLAMA_URL = "http://localhost:11434" EMBED_MODEL = "nomic-embed-text" LLM_MODEL = "llama3.3:70b-instruct-q4_0" CACHE_TTL = 3600 # キャッシュの有効期限(秒) DISTANCE_THR = 0.08 # コサイン距離の閾値(1-0.92=0.08 → 92%以上の類似度) INDEX_NAME = "llm-cache" class SemanticCache: def __init__(self): self.r = Redis(host="localhost", port=6379) def _embed(self, text: str) -> list: res = requests.post(f"{OLLAMA_URL}/api/embeddings", json={"model": EMBED_MODEL, "prompt": text}) return res.json()["embedding"] def _vec_bytes(self, vec: list) -> bytes: return np.array(vec, dtype=np.float32).tobytes() def _search(self, query_vec: list) -> str | None: q = (Query("*=>[KNN 1 @embedding $vec AS dist]") .return_fields("response", "dist") .sort_by("dist") .dialect(2)) results = self.r.ft(INDEX_NAME).search( q, query_params={"vec": self._vec_bytes(query_vec)}) if results.docs and float(results.docs[0].dist) < DISTANCE_THR: return results.docs[0].response return None def ask(self, prompt: str) -> tuple[str, bool]: vec = self._embed(prompt) cached = self._search(vec) if cached: return cached, True # (応答テキスト, キャッシュヒット) res = requests.post(f"{OLLAMA_URL}/api/generate", json={"model": LLM_MODEL, "prompt": prompt, "stream": False}) response = res.json()["response"] key = f"cache:{hashlib.sha256(prompt.encode()).hexdigest()[:16]}" self.r.hset(key, mapping={ "prompt": prompt, "response": response, "embedding": self._vec_bytes(vec), }) self.r.expire(key, CACHE_TTL) return response, False if __name__ == "__main__": cache = SemanticCache() test_prompts = [ "Linuxでディスク容量を確認するコマンドは?", "ディスク使用量を調べるLinuxコマンドを教えて", # セマンティックに近い "Linuxのメモリ使用量を確認する方法は?", ] for p in test_prompts: ans, hit = cache.ask(p) print(f"[{'HIT ' if hit else 'MISS'}] {p[:30]}... => {ans[:60]}")
ask() は (応答テキスト, キャッシュヒット) のタプルを返します。既存コードへの差し込みは client.ask(prompt) の1行で済みます。Ollamaのモデル指定や閾値は定数として上部に集めているため、設定変更が容易です。
3. 動作確認
# 動作確認スクリプトを実行する cd /opt/ollama-cache python3 semantic_cache.py # 期待する出力例: # [MISS] Linuxでディスク容量を確認する... => dfコマンドを使います。... # [HIT ] ディスク使用量を調べるLinux... => dfコマンドを使います。... # [MISS] Linuxのメモリ使用量を確認する... => freeコマンドまたはtopコマン...
キャッシュのヒット率とパフォーマンスを測定する方法
1. Redisコマンドでキャッシュ状態を確認する
# キャッシュエントリ数を確認する redis-cli FT.INFO llm-cache | grep num_docs # 例: num_docs 47 → 47件がインデックス済み # Redisのキャッシュヒット・ミス統計 redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses" # keyspace_hits:1284 # keyspace_misses:312
keyspace_hits と keyspace_misses の比率がキャッシュ全体のヒット率です。ただしセマンティックキャッシュのHKV読み取りも計上されるため、実際のLLM呼び出し削減率はPythonコードの戻り値 hit フラグをアプリ側でカウントするほうが正確です。
2. 応答時間を比較するシェルスクリプト
#!/bin/bash # キャッシュあり・なしの応答時間を比較する PROMPT="Linuxのcronジョブ設定方法を教えてください" echo "--- キャッシュなし(直接Ollama呼び出し)---" time curl -s -X POST http://localhost:11434/api/generate \ -d "{\"model\":\"llama3.3:70b-instruct-q4_0\",\"prompt\":\"${PROMPT}\",\"stream\":false}" \ | python3 -c "import sys,json; print(json.load(sys.stdin)['response'][:80])" echo "" echo "--- キャッシュあり(2回目・HIT想定)---" time python3 -c " from semantic_cache import SemanticCache ans, hit = SemanticCache().ask('${PROMPT}') print(f'HIT={hit} {ans[:80]}') "
nomic-embed-text)とRedis検索で0.3~0.8秒程度かかるのが内訳です。
本番運用で直面しやすいトラブルと対処法
**類似度閾値が合わない**デフォルトの
DISTANCE_THR = 0.08(コサイン類似度92%相当)は英語質問中心の環境向けです。日本語の質問は表現ゆらぎが大きいため、0.12~0.15(88~85%)に緩めると自然な言い換えもヒットしやすくなります。逆に専門的なコマンド名が変わっただけで別の質問なのにヒットする場合は0.05以下に絞ります。運用開始後1週間は誤ヒット・未ヒットをログに記録して閾値を調整するのが現実的です。 **インデックスのDIM不一致エラー**
redis-py から FT.CREATE をコード内で動的に実行すると、すでに別次元で作成済みのインデックスとバッティングして Index already exists エラーが出る場合があります。インデックスは起動時に1回だけ作成するか、FT.INFO でDIMを確認してから起動するスクリプトを用意します。# 既存インデックスのDIM確認 redis-cli FT.INFO llm-cache | grep -A1 "dim" # DIM が 768 であることを確認する
1エントリのembeddingは768次元 × 4バイト = 約3KB、promptとresponseを含めると1エントリ10~30KBになります。キャッシュが10,000件溜まると200MB~300MBを占有します。
/etc/redis-stack.conf に maxmemory 1gb と maxmemory-policy allkeys-lru を設定し、古いエントリが自動削除されるようにします。加えてTTLを適切に設定することで、内容が変わりやすい質問(最新ニュースを聞くなど)の陳腐化を防げます。
Ollamaのモデル選定で迷っている場合は ローカルLLMのモデルを比較する方法 も参照してください。また、社内へのOllama導入検討段階にある場合は 社内でChatGPTが使えないときの代替手段 で導入判断の整理ができます。
まとめ
| 項目 | 設定・コマンド | 補足 |
|---|---|---|
| インストール | sudo apt install -y redis-stack-server |
RediSearchが同梱される |
| インデックス作成 | redis-cli FT.CREATE llm-cache ON HASH ... DIM 768 |
nomic-embed-text用 |
| キャッシュ検索 | FT.SEARCH llm-cache "*=>[KNN 1 @embedding $vec AS dist]" |
HNSWでO(log n)検索 |
| 類似度閾値 | DISTANCE_THR = 0.08(92%類似) |
用途に合わせて0.05~0.15で調整 |
| TTL設定 | self.r.expire(key, 3600) |
1時間。質問内容次第で変更 |
| メモリ上限 | maxmemory 1gb + allkeys-lru |
redis-stack.confに記述 |
| 状態確認 | redis-cli FT.INFO llm-cache |
num_docsでエントリ数確認 |
ローカルLLMのキャッシュ・チューニングを2日間のハンズオンで体験する
セマンティックキャッシュやRAGパイプラインの構築を、実機GPU環境で手を動かしながら習得したい方向けに、「ローカルAIマスターセミナー」を開催しています。
少人数(最大8名)ZOOMハンズオン形式で実施しています。
・Ubuntu ServerでローカルLLMを構築する方法|Ollamaで機密データを外に出さず業務AIを動かす完全ガイド
・社内でChatGPTが使えないときの代替手段|機密データを守るローカルLLMという選択肢
・ローカルLLMのモデルを比較する方法|Llama3.3・Mistral・Gemma・Phi-4をUbuntuで使い分けるポイント
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら
- 前のページへ:OllamaをCPU-onlyのLinuxサーバーで動かす方法|GPU不要でVPS・ラズパイにローカルLLMを安定稼働させる手順
- この記事の属するカテゴリ:ローカルLLMへ戻る

無料メルマガで学習を続ける
Linuxの実践スキルをメールで毎週お届け。
登録は30秒、解除もいつでも可。
登録無料・いつでも解除できます