「pgvectorのベクター検索だけでは固有名詞・型番・コード番号が混在する検索が弱く、実務で使いものにならない」
そんな課題を持つLinuxエンジニアは少なくないはずです。この記事では、ElasticsearchとOllamaを組み合わせたハイブリッド検索RAGをUbuntu Serverに構築する手順を解説します。
BM25によるキーワード検索とEmbeddingによるベクター検索を並走させ、RRF(Reciprocal Rank Fusion)で統合することで、どちらか一方では取りこぼしていた文書を精度よく引き出せるようになります。pgvectorによる純粋なベクター検索と使い分けたいエンジニア向けに、コマンドと実装コードを中心に説明します。
この記事のポイント
・ElasticsearchはBM25と `dense_vector` を同一インデックスに共存させてハイブリッド検索を一本化できる
・OllamaのEmbedding API(`/api/embeddings`)で生成したベクターをそのままElasticsearchに格納する
・Elasticsearch 8系の `knn` クエリと `sub_searches` + `rrf` でスコア統合の実装コードが最小限に収まる
・ノートPCレベルのCPU専用環境でも `nomic-embed-text` + `llama3.3:8b-instruct-q4_0` の組み合わせで実用速度が出る
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
ハイブリッド検索RAGとベクター検索単体の違いを理解する
RAGの精度問題の多くは、検索アルゴリズムの選択に起因しています。まず2つのアプローチの特性を整理します。ベクター検索(セマンティック検索)は意味的な近さで文書を返します。「サーバーが落ちた」と「インスタンスが応答しない」のような言い換えに強い一方、「CVE-2024-1234」「v2.1.3」のような固有番号は別の数字と混同される典型的なつまずきがあります。
BM25(Best Match 25)はTF-IDFを改良した全文検索アルゴリズムです。型番・製品コード・コマンド名の完全一致には強いですが、言い換えや同義語には対応できません。
ハイブリッド検索はこの2つを組み合わせます。同じクエリをBM25とベクター検索で独立して実行し、それぞれの順位をRRF(Reciprocal Rank Fusion)で統合します。社内ドキュメントのQAや技術マニュアルへの問い合わせでは、ハイブリッドアプローチが単体より安定した精度を出す典型的なケースが多く見られます。
Elasticsearchは8系からこのハイブリッド検索を標準サポートしており、pgvectorと異なり専用の全文検索エンジンとしての実績があります。既にELK/EFKスタックを運用しているチームであれば、インフラの追加コストなしにLLMを連携させることができます。社内でChatGPTが使えないときの代替手段としてのローカルLLMを検討している情シス担当者には、既存の検索基盤を活用できる点が特に有効です。
ElasticsearchをUbuntu Serverにインストールする手順
ElasticsearchはJavaベースのため、OpenJDKが必要です。Ubuntu 22.04/24.04では公式APTリポジトリを使ってインストールします。なお、OllamaがUbuntu Serverにインストール済みであることが前提です。まだの場合はUbuntu ServerでのOllama構築手順を先に参照してください。
1. GPGキーと公式APTリポジトリを追加する
# GPGキーのインポート wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo gpg --dearmor -o /usr/share/keyrings/elasticsearch-keyring.gpg # リポジトリの追加 echo "deb [signed-by=/usr/share/keyrings/elasticsearch-keyring.gpg] https://artifacts.elastic.co/packages/8.x/apt stable main" \ | sudo tee /etc/apt/sources.list.d/elastic-8.x.list sudo apt update
2. Elasticsearchをインストールしてsystemdで起動する
sudo apt install -y elasticsearch sudo systemctl daemon-reload sudo systemctl enable --now elasticsearch
3. インストールの確認と初期設定
# systemdステータスの確認 systemctl status elasticsearch # APIへのアクセス確認(パスワードは記録したものに置き換える) curl -k -u elastic:YOUR_PASSWORD https://localhost:9200
ローカル専用環境でHTTPSを簡略化したい場合は、`/etc/elasticsearch/elasticsearch.yml` に以下を追加して再起動します。**注意: 開発環境限定の設定です。本番では絶対に適用しないでください。**
# /etc/elasticsearch/elasticsearch.yml に追記(開発環境のみ) xpack.security.enabled: false xpack.security.http.ssl.enabled: false
ハイブリッド検索用Elasticsearchインデックスを作成する
BM25とベクター検索を同時に使うには、テキストフィールドと `dense_vector` フィールドを同一インデックスに定義します。1. インデックスのマッピング設計を理解する
`nomic-embed-text` はEmbeddingの次元数が768です。`dense_vector` フィールドの `dims` にこの値を指定します。`mxbai-embed-large` なら1024になります。モデル選定についてはローカルLLMのモデル比較と使い分けの解説も参考にしてください。2. curlでインデックスを作成する
curl -X PUT http://localhost:9200/docs \ -H "Content-Type: application/json" \ -d '{ "mappings": { "properties": { "content": { "type": "text" }, "title": { "type": "text" }, "embedding": { "type": "dense_vector", "dims": 768, "index": true, "similarity": "cosine" } } } }'
OllamaのEmbedding APIでドキュメントをインデックスに登録する
1. ドキュメントを適切なサイズにチャンク分割する
1チャンクを300~500文字程度に分割するのが典型的です。文の途中で切れないよう、改行や句点を境界にする実装が安定します。以下はシンプルな段落区切りによる例です。def split_into_chunks(text, max_chars=400): paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()] chunks = [] buf = "" for p in paragraphs: if len(buf) + len(p) > max_chars and buf: chunks.append(buf.strip()) buf = p else: buf += "\n\n" + p if buf else p if buf: chunks.append(buf.strip()) return chunks
2. Embeddingを生成してElasticsearchに登録する
OllamaのEmbedding APIエンドポイントは `/api/embeddings` です。生成したベクターをそのままElasticsearchのbulkインデックスに渡します。import requests, json OLLAMA = "http://localhost:11434" ES = "http://localhost:9200" INDEX = "docs" def get_embedding(text): r = requests.post(f"{OLLAMA}/api/embeddings", json={"model": "nomic-embed-text", "prompt": text}) return r.json()["embedding"] def index_document(doc_id, title, content): embedding = get_embedding(content) payload = {"title": title, "content": content, "embedding": embedding} requests.put(f"{ES}/{INDEX}/_doc/{doc_id}", json=payload, headers={"Content-Type": "application/json"}) # 使用例 with open("manual.txt") as f: raw = f.read() chunks = split_into_chunks(raw) for i, chunk in enumerate(chunks): index_document(f"manual-{i}", "操作マニュアル", chunk) print(f"indexed {i+1}/{len(chunks)}")
BM25とベクター検索を組み合わせたハイブリッドクエリを実装する
1. Elasticsearch 8系のsub_searchesとRRFを理解する
Elasticsearch 8.9以降では `sub_searches` パラメータで複数の検索をひとつのリクエストにまとめ、`rank.rrf` で順位を統合できます。RRFのスコアは各検索の順位 `r` に対して `1/(k + r)` の和(デフォルト `k=60`)で計算します。「BM25で5位、ベクター検索でも5位」の文書は「BM25で1位・ベクター検索で圏外」より高スコアになりやすく、両面で評価する統合のメリットが出やすくなります。
2. Pythonでハイブリッドクエリを実装する
def hybrid_search(query_text, top_k=5): query_vec = get_embedding(query_text) payload = { "sub_searches": [ { "query": { "multi_match": { "query": query_text, "fields": ["title^2", "content"] } } }, { "knn": { "field": "embedding", "query_vector": query_vec, "k": top_k, "num_candidates": 50 } } ], "rank": {"rrf": {"window_size": 50, "rank_constant": 60}}, "size": top_k, "_source": ["title", "content"] } r = requests.post(f"{ES}/{INDEX}/_search", json=payload, headers={"Content-Type": "application/json"}) hits = r.json()["hits"]["hits"] return [h["_source"] for h in hits]
OllamaのLLMに検索結果を渡して質問応答システムを完成させる
1. 検索結果をコンテキストとしてプロンプトに組み込む
RAGの最終ステップは、検索で取得した上位文書をLLMのプロンプトに「コンテキスト」として渡し、そのコンテキストを根拠に回答させることです。「コンテキストにない情報は作らない」という制約をプロンプトに明記することでハルシネーションを抑えられます。2. エンドツーエンドのPython実装
def ask(question): docs = hybrid_search(question, top_k=4) context = "\n\n---\n\n".join(d["content"] for d in docs) prompt = f"""以下のコンテキストだけを根拠に質問に答えてください。 コンテキストに回答がない場合は「該当する情報がコンテキスト内に見つかりません」と答えてください。 【コンテキスト】 {context} 【質問】 {question} """ r = requests.post(f"{OLLAMA}/api/generate", json={"model": "llama3.3:8b-instruct-q4_0", "prompt": prompt, "stream": False}) return r.json()["response"] # 実行例 answer = ask("バックアップの保持期間はどのくらいですか?") print(answer)
ハイブリッド検索RAGのよくあるエラーと対処法
実際の構築でつまずきやすいポイントを整理します。**Embeddingの次元数エラー(mapper_parsing_exception)が出る場合**
インデックス作成時の `dims` と登録しようとしているEmbeddingの次元数が一致していない場合に発生します。モデルを変更したときに起きやすい問題です。
# Ollamaのembeddingの実際の次元数を確認 python3 -c " import requests r = requests.post('http://localhost:11434/api/embeddings', json={'model': 'nomic-embed-text', 'prompt': 'test'}) print('dims:', len(r.json()['embedding'])) "
**sub_searchesが使えない(バージョンエラー)場合**
`sub_searches` は Elasticsearch 8.9以降の機能です。バージョンを確認します。
curl http://localhost:9200 | grep number
**検索結果が全てベクター検索の結果になる場合**
デフォルトのAnalyzerが日本語を単語分割できず、BM25がヒットしていない可能性があります。日本語ドキュメントを扱う場合は `analysis-kuromoji` プラグインの追加とAnalyzer設定が必要です。
sudo /usr/share/elasticsearch/bin/elasticsearch-plugin install analysis-kuromoji sudo systemctl restart elasticsearch
まとめ
OllamaとElasticsearchを連携させたハイブリッド検索RAGの構築手順を解説しました。| 手順 | 主なコマンド・API | 確認ポイント |
|---|---|---|
| Elasticsearchインストール | sudo apt install -y elasticsearch | curl http://localhost:9200 でJSON応答 |
| インデックス作成 | curl -X PUT http://localhost:9200/docs | acknowledged:true |
| Embedding生成と登録 | POST /api/embeddings → PUT /docs/_doc/{id} | Python printでindexed N/N |
| ハイブリッドクエリ | POST /docs/_search(sub_searches + rrf) | hits.hits にBM25・ベクター双方の結果 |
| LLM質問応答 | POST /api/generate(llama3.3:8b-instruct-q4_0) | コンテキスト外の質問で「見つかりません」応答 |
pgvectorとの使い分けの目安は「型番・コード・固有名詞の完全一致が重要かどうか」です。技術マニュアル・社内規程はハイブリッド検索が安定し、意味的な質問が多いFAQは純粋なベクター検索でも十分な精度が出るケースが多く見られます。
ローカルLLMを使ったRAGと検索基盤構築を2日間のハンズオンで体験する
ElasticsearchとOllamaを組み合わせたハイブリッド検索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をTraefikリバースプロキシで保護する方法|自動HTTPS・BasicAuth・レート制限をYAML設定で一括管理する手順
- この記事の属するカテゴリ:ローカルLLMへ戻る

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