Milvus
Zilliz
  • Home
  • Blog
  • RAGが失敗する理由がわかったら?Project_GolemとmilvusでRAGを3Dデバッグする

RAGが失敗する理由がわかったら?Project_GolemとmilvusでRAGを3Dデバッグする

  • Engineering
February 18, 2026
Min Yin

RAG検索がうまくいかないとき、通常はそれが壊れていることがわかります。関連文書が表示されなかったり、無関係な文書が表示されたりするのです。しかし、その原因を突き止めるのは別の話だ。あなたが扱わなければならないのは、類似度スコアと結果のフラットなリストだけだ。ドキュメントがベクトル空間で実際にどのように配置されているのか、チャンク同士がどのように関連しているのか、クエリがマッチするはずのコンテンツと相対的にどの位置にあるのかを確認する方法はない。実際には、RAGのデバッグはほとんど試行錯誤であることを意味する:チャンキング戦略を微調整し、埋め込みモデルを入れ替え、top-kを調整し、結果が改善することを願う。

Project_Golemは、ベクトル空間を可視化するオープンソースのツールである。UMAPを使って高次元の埋め込みを3Dに投影し、Three.jsを使ってブラウザ上でインタラクティブにレンダリングする。検索に失敗した理由を推測する代わりに、チャンクが意味的にどのようにクラスタリングされているのか、クエリがどこに到達したのか、どのドキュメントが検索されたのか、すべてをひとつの視覚的なインターフェイスで見ることができる。

これは驚きだ。しかし、オリジナルのProject_Golemは小規模なデモ用に設計されたもので、実世界のシステムではない。フラットファイル、ブルートフォース検索、全データセットの再構築に依存しているため、データが数千文書以上になると、すぐに破綻してしまいます。

このギャップを埋めるために、Project_GolemはMilvus(具体的にはバージョン2.6.8)をベクターバックボーンとして統合した。Milvusは、リアルタイムの取り込み、スケーラブルなインデックス作成、ミリ秒レベルの検索を処理するオープンソースの高性能ベクトルデータベースであり、一方、Project_Golemは、ベクトル検索動作の可視化という最も得意とすることに集中します。Project_Golemは、ベクトル検索動作の可視化という最も得意とすることに専念している。両者が一緒になることで、3D可視化は、おもちゃのデモから、プロダクションRAGシステムのための実用的なデバッグ・ツールへと変わる。

この投稿では、Project_Golemについて説明し、どのようにMilvusと統合し、ベクター検索の動作を観察可能にし、スケーラブルにし、プロダクションに対応できるようにしたかを紹介する。

Project_Golemとは?

ベクトル空間は高次元であり、人間はそれを見ることができないからです。

Project_Golemはブラウザベースのツールで、RAGシステムが動作するベクトル空間を見ることができます。通常768次元または1536次元の高次元埋め込みデータを、直接探索できるインタラクティブな3Dシーンに投影します。

その仕組みは以下の通りです:

  • UMAPによる次元削減Project_Golemは、UMAPを使用して、高次元のベクトルを3次元に圧縮し、相対的な距離を保持します。元の空間で意味的に類似しているチャンクは、3D投影でも近くに留まり、無関係なチャンクは離れてしまいます。
  • Three.jsによる3Dレンダリング。各ドキュメントチャンクは、ブラウザでレンダリングされる3Dシーンのノードとして表示されます。回転、ズーム、空間の探索が可能で、ドキュメントがどのように集まっているか、どのトピックが密に集まっているか、どのトピックが重なっているか、境界線はどこにあるかを確認できます。
  • クエリ時のハイライト。クエリを実行すると、コサイン類似度を使用して元の高次元空間で検索が行われます。しかし、結果が戻ってくると、検索された塊が3Dビューに表示されます。クエリが結果に対してどの位置にあるのか、そして同様に重要なこととして、検索されなかった文書に対してどの位置にあるのかを即座に確認することができる。

これがProject_Golemをデバッグに役立たせている。ランク付けされた検索結果のリストを見つめて、なぜ関連文書が検索されなかったのかを推測する代わりに、その文書が遠くのクラスターにあるのか(埋め込みの問題)、無関係なコンテンツと重なっているのか(チャンキングの問題)、検索しきい値のぎりぎり外側にあるのか(設定の問題)を見ることができる。3Dビューは、抽象的な類似性スコアを推論可能な空間的関係に変える。

Project_Golemが本番に対応できない理由

Project_Golemは視覚化のプロトタイプとして設計された。しかし、そのアーキテクチャーは、実際のRAGデバッグに使いたい場合に問題となるような、規模が大きくなるとすぐに破綻するような前提を置いている。

アップデートのたびに完全な再構築が必要

これは最も基本的な制限だ。エンベッディングは再生成され、.npyファイルに書き込まれ、UMAPはデータセット全体で再実行され、3D座標はJSONとして再エクスポートされます。

10万ドキュメントでも、シングルコアのUMAP実行には5-10分かかる。100万ドキュメント規模になると、まったく実用的でなくなる。ニュースフィード、ドキュメント、ユーザーの会話など、継続的に変化するデータセットには使えない。更新のたびに、完全な再処理サイクルを待つことになるからだ。

強引な検索はスケールしない

検索側にも上限がある。オリジナルの実装では、NumPyを使ってブルートフォース・コサイン類似度検索を行っている。100万ドキュメントのデータセットでは、一つのクエリに1秒以上かかることもある。これでは、インタラクティブなシステムやオンラインシステムには使えない。

メモリの圧迫が問題をさらに深刻にしている。768次元のfloat32ベクトルはそれぞれおよそ3KBなので、100万個のベクトルデータセットは3GB以上のメモリを必要とします。

メタデータのフィルタリングなし、マルチテナンシーなし

実際のRAGシステムでは、ベクトルの類似性だけが検索基準になることはほとんどありません。ほとんどの場合、ドキュメントの種類、タイムスタンプ、ユーザー権限、アプリケーションレベルの境界などのメタデータによるフィルタリングが必要です。例えば、カスタマーサポートのRAGシステムでは、検索対象を特定のテナントの文書に限定する必要があります。

Project_Golemは、このどれもサポートしません。HNSWやIVFのような)ANNインデックスも、スカラーフィルタリングも、テナントの分離も、ハイブリッド検索もない。Project_Golemは、検索エンジンのない可視化レイヤなのだ。

MilvusはどのようにProject_Golemの検索レイヤーを強化しているか?

前節では、更新のたびに完全な再構築を行うこと、総当り検索を行うこと、メタデータを意識した検索を行わないことの3つのギャップを指摘しました。Project_Golemにはデータベースレイヤーがない。Project_Golemにはデータベースレイヤーがないため、検索、保存、視覚化がひとつのパイプラインにまとめられており、どの部分を変更しても、すべてをリビルドしなければならない。

解決策はパイプラインを最適化することではない。分割することだ。

Milvus2.6.8をベクターのバックボーンとして統合することで、検索は可視化とは独立したプロダクショングレードの専用レイヤーとなる。Milvusは、ベクターのストレージ、インデックス、検索を処理する。Project_Golemは純粋にレンダリングに焦点を当て、MilvusからドキュメントIDを消費し、3Dビューでハイライトします。

この分離により、2つのクリーンで独立したフローが生まれる:

検索フロー(オンライン、ミリ秒レベル)

  • クエリはOpenAIのエンベッディングを使ってベクトルに変換されます。
  • クエリーベクターはMilvusコレクションに送られます。
  • Milvus AUTOINDEXが適切なインデックスを選択し、最適化します。
  • リアルタイムのコサイン類似度検索により、関連するドキュメントIDが返されます。

可視化フロー(オフライン、デモスケール)

  • UMAPはデータ取り込み時に3D座標を生成する(n_neighbors=30, min_dist=0.1)。
  • 座標はgolem_cortex.jsonに格納される。
  • フロントエンドはMilvusが返すドキュメントIDを使って対応する3Dノードをハイライトする。

重要な点は、検索が可視化されるのを待たなくなったことです。新しいドキュメントをインジェストし、すぐに検索することができます。

ストリーミング・ノードが変えるもの

このリアルタイムインジェストは、Milvus 2.6.8の新機能であるストリーミングノードによって実現されています。以前のバージョンでは、リアルタイム・インジェストにはKafkaやPulsarのような外部メッセージ・キューが必要でした。Streaming Nodesはその調整をMilvus自体に移し、新しいベクターは継続的にインジェストされ、インデックスはインクリメンタルに更新され、新しく追加されたドキュメントは完全な再構築や外部依存なしに即座に検索可能になります。

Project_Golemにとって、これがこのアーキテクチャを実用的なものにしている。新しい記事、更新されたドキュメント、ユーザーが作成したコンテンツなど、RAGシステムにドキュメントを追加し続けることができ、高価なUMAP→JSON→リロードのサイクルを引き起こすことなく、検索を最新の状態に保つことができる。

100万スケールへの視覚化の拡張(将来の道)

このMilvusに支えられたセットアップにより、Project_Golemは現在、約10,000ドキュメントのインタラクティブなデモをサポートしている。Milvusは数百万を処理するが、可視化パイプラインは依然としてバッチUMAP実行に依存している。このギャップを埋めるために、インクリメンタルな可視化パイプラインでアーキテクチャを拡張することができる:

  • 更新トリガー:システムは、Milvusコレクションの挿入イベントをリッスンする。新しく追加されたドキュメントが定義されたしきい値(例えば1,000アイテム)に達すると、インクリメンタルアップデートがトリガーされる。

  • インクリメンタルプロジェクション:データセット全体にわたってUMAPを再実行する代わりに、UMAPのtransform()メソッドを使用して新しいベクトルを既存の3D空間に投影する。これにより、大域的な構造を維持しながら計算コストを劇的に削減することができる。

  • フロントエンドの同期:更新された座標フラグメントは、WebSocketを介してフロントエンドにストリーミングされ、シーン全体をリロードすることなく、新しいノードを動的に出現させることができます。

Milvus 2.6.8では、スケーラビリティだけでなく、ベクトル類似度と全文検索およびスカラーフィルタリングを組み合わせたハイブリッド検索が可能になりました。これにより、キーワードのハイライト、カテゴリフィルタリング、時間ベースのスライスなど、よりリッチな3Dインタラクションへの扉が開かれ、開発者はRAGの動作について探求、デバッグ、推論するためのより強力な方法を得ることができます。

Milvusを使ったProject_Golemのデプロイと探索方法

アップグレードされたProject_Golemは、現在GitHubでオープンソースとして公開されています。Milvusの公式ドキュメントをデータセットとして使用し、RAG検索を3Dで可視化する完全なプロセスを説明します。セットアップにはDockerとPythonを使用し、ゼロから始めても簡単に実行できます。

前提条件

  • Docker ≥ 20.10
  • Docker Compose ≥ 2.0
  • Python ≥ 3.11
  • OpenAI APIキー
  • データセット (MilvusドキュメントのMarkdown形式)

1.Milvusをデプロイする。

Download docker-compose.yml
wget https://github.com/milvus-io/milvus/releases/download/v2.6.8/milvus-standalone-docker-compose.yml -O docker-compose.yml
Start Milvus(verify port mapping:19530:19530)
docker-compose up -d
Verify that the services are running
docker ps | grep milvus
You should see three containers:milvus-standalone, milvus-etcd, milvus-minio

2.コア実装

Milvusインテグレーション (ingest.py)

注意: この実装は最大8つのドキュメントカテゴリをサポートします。カテゴリ数がこの制限を超えた場合、色はラウンドロビン方式で再利用されます。

from pymilvus import MilvusClient
from pymilvus.milvus_client.index import IndexParams
from openai import OpenAI
from langchain_text_splitters import RecursiveCharacterTextSplitter
import umap
from sklearn.neighbors import NearestNeighbors
import json
import numpy as np
import os
import glob
--- CONFIG ---
MILVUS_URI = "http://localhost:19530"
COLLECTION_NAME = "golem_memories"
JSON_OUTPUT_PATH = "./golem_cortex.json"
Data directory (users place .md files in this folder)
DATA_DIR = "./data"
OpenAI Embedding Config
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
OPENAI_BASE_URL = "https://api.openai.com/v1"  #
OPENAI_EMBEDDING_MODEL = "text-embedding-3-small"
1536 dimensions
EMBEDDING_DIM = 1536
Color mapping: colors are assigned automatically and reused in a round-robin manner
COLORS = [
[0.29, 0.87, 0.50],
Green
[0.22, 0.74, 0.97],
Blue
[0.60, 0.20, 0.80],
Purple
[0.94, 0.94, 0.20],
Gold
[0.98, 0.55, 0.00],
Orange
[0.90, 0.30, 0.40],
Red
[0.40, 0.90, 0.90],
Cyan
[0.95, 0.50, 0.90],
Magenta
]
def get_embeddings(texts):
"""Batch embedding using OpenAI API"""
client = OpenAI(api_key=OPENAI_API_KEY, base_url=OPENAI_BASE_URL)
embeddings = []
batch_size = 100
OpenAI allows multiple texts per request
for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]
response = client.embeddings.create(
model=OPENAI_EMBEDDING_MODEL,
input=batch
)
embeddings.extend([item.embedding for item in response.data])
print(f"   ↳ Embedded {min(i + batch_size, len(texts))}/{len(texts)}...")
return np.array(embeddings)
def load_markdown_files(data_dir):
"""Load all markdown files from the data directory"""
md_files = glob.glob(os.path.join(data_dir, "**/*.md"), recursive=True)
if not md_files:
print(f"   ❌ ERROR: No .md files found in '{data_dir}'")
print(f"   👉 Create a '{data_dir}' folder and put your markdown files there.")
print(f"   👉 Example: {data_dir}/doc1.md, {data_dir}/docs/doc2.md")
return None
docs = []
print(f"\n📚 FOUND {len(md_files)} MARKDOWN FILES:")
for i, file_path in enumerate(md_files):
filename = os.path.basename(file_path)
Categories are derived from the file’s path relative to data_dir
rel_path = os.path.relpath(file_path, data_dir)
category = os.path.dirname(rel_path) if os.path.dirname(rel_path) else "default"
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
docs.append({
"title": filename,
"text": content,
"cat": category,
"path": file_path
})
print(f"   {i+1}. [{category}] {filename}")
return docs
def ingest_dense():
print(f"🧠 PROJECT GOLEM - NEURAL MEMORY BUILDER")
print(f"=" * 50)
if not OPENAI_API_KEY:
print("   ❌ ERROR: OPENAI_API_KEY environment variable not set!")
print("   👉 Run: export OPENAI_API_KEY='your-key-here'")
return
print(f"   ↳ Using OpenAI Embedding: {OPENAI_EMBEDDING_MODEL}")
print(f"   ↳ Embedding Dimension: {EMBEDDING_DIM}")
print(f"   ↳ Data Directory: {DATA_DIR}")
1. Load local markdown files
docs = load_markdown_files(DATA_DIR)
if docs is None:
return
2. Split documents into chunks
print(f"\n📦 PROCESSING DOCUMENTS...")
splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=50)
chunks = []
raw_texts = []
colors = []
chunk_titles = []
categories = []
for doc in docs:
doc_chunks = splitter.create_documents([doc['text']])
cat_index = hash(doc['cat']) % len(COLORS)
for i, chunk in enumerate(doc_chunks):
chunks.append({
"text": chunk.page_content,
"title": doc['title'],
"cat": doc['cat']
})
raw_texts.append(chunk.page_content)
colors.append(COLORS[cat_index])
chunk_titles.append(f"{doc['title']} (chunk {i+1})")
categories.append(doc['cat'])
print(f"   ↳ Created {len(chunks)} text chunks from {len(docs)} documents")
3. Generate embeddings
print(f"\n🔮 GENERATING EMBEDDINGS...")
vectors = get_embeddings(raw_texts)
4. 3D Projection (UMAP)
print("\n🎨 CALCULATING 3D MANIFOLD...")
reducer = umap.UMAP(n_components=3, n_neighbors=30, min_dist=0.1, metric='cosine')
embeddings_3d = reducer.fit_transform(vectors)
5. Wiring (KNN)
print("   ↳ Wiring Synapses (finding connections)...")
nbrs = NearestNeighbors(n_neighbors=8, metric='cosine').fit(vectors)
distances, indices = nbrs.kneighbors(vectors)
6. Prepare output data
cortex_data = []
milvus_data = []
for i in range(len(chunks)):
cortex_data.append({
"id": i,
"title": chunk_titles[i],
"cat": categories[i],
"pos": embeddings_3d[i].tolist(),
"col": colors[i],
"nbs": indices[i][1:].tolist()
})
milvus_data.append({
"id": i,
"text": chunks[i]['text'],
"title": chunk_titles[i],
"category": categories[i],
"vector": vectors[i].tolist()
})
with open(JSON_OUTPUT_PATH, 'w') as f:
json.dump(cortex_data, f)
7. Store vectors in Milvus
print("\n💾 STORING IN MILVUS...")
client = MilvusClient(uri=MILVUS_URI)
Drop existing collection if it exists
if client.has_collection(COLLECTION_NAME):
print(f"   ↳ Dropping existing collection '{COLLECTION_NAME}'...")
client.drop_collection(COLLECTION_NAME)
Create new collection
print(f"   ↳ Creating collection '{COLLECTION_NAME}' (dim={EMBEDDING_DIM})...")
client.create_collection(
collection_name=COLLECTION_NAME,
dimension=EMBEDDING_DIM
)
Insert data
print(f"   ↳ Inserting {len(milvus_data)} vectors...")
client.insert(
collection_name=COLLECTION_NAME,
data=milvus_data
)
Create index for faster search
print("   ↳ Creating index...")
index_params = IndexParams()
index_params.add_index(
field_name="vector",
index_type="AUTOINDEX",
metric_type="COSINE"
)
client.create_index(
collection_name=COLLECTION_NAME,
index_params=index_params
)
print(f"\n✅ CORTEX GENERATED SUCCESSFULLY!")
print(f"   📊 {len(chunks)} memory nodes stored in Milvus")
print(f"   📁 Cortex data saved to: {JSON_OUTPUT_PATH}")
print(f"   🚀 Run 'python GolemServer.py' to start the server")
if __name__ == "__main__":
ingest_dense()

フロントエンドの可視化 (GolemServer.py)

from flask import Flask, request, jsonify, send_from_directory
from openai import OpenAI
from pymilvus import MilvusClient
import json
import os
import sys
--- CONFIG ---
Explicitly set the folder to where this script is located
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
OpenAI Embedding Config
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
OPENAI_BASE_URL = "https://api.openai.com/v1"
OPENAI_EMBEDDING_MODEL = "text-embedding-3-small"
Milvus Config
MILVUS_URI = "http://localhost:19530"
COLLECTION_NAME = "golem_memories"
These match the files generated by ingest.py
JSON_FILE = "golem_cortex.json"
UPDATED: Matches your new repo filename
HTML_FILE = "index.html"
app = Flask(__name__, static_folder=BASE_DIR)
print(f"\n🧠 PROJECT GOLEM SERVER")
print(f"   📂 Serving from: {BASE_DIR}")
--- DIAGNOSTICS ---
Check if files exist before starting
missing_files = []
if not os.path.exists(os.path.join(BASE_DIR, JSON_FILE)):
missing_files.append(JSON_FILE)
if not os.path.exists(os.path.join(BASE_DIR, HTML_FILE)):
missing_files.append(HTML_FILE)
if missing_files:
print(f"   ❌ CRITICAL ERROR: Missing files in this folder:")
for f in missing_files:
print(f"      - {f}")
print("   👉 Did you run 'python ingest.py' successfully?")
sys.exit(1)
else:
print(f"   ✅ Files Verified: Cortex Map found.")
Check API Key
if not OPENAI_API_KEY:
print(f"   ❌ CRITICAL ERROR: OPENAI_API_KEY environment variable not set!")
print("   👉 Run: export OPENAI_API_KEY='your-key-here'")
sys.exit(1)
print(f"   ↳ Using OpenAI Embedding: {OPENAI_EMBEDDING_MODEL}")
print("   ↳ Connecting to Milvus...")
milvus_client = MilvusClient(uri=MILVUS_URI)
Verify collection exists
if not milvus_client.has_collection(COLLECTION_NAME):
print(f"   ❌ CRITICAL ERROR: Collection '{COLLECTION_NAME}' not found in Milvus.")
print("   👉 Did you run 'python ingest.py' successfully?")
sys.exit(1)
Initialize OpenAI client
openai_client = OpenAI(api_key=OPENAI_API_KEY, base_url=OPENAI_BASE_URL)
--- ROUTES ---
@app.route('/')
def root():
Force serve the specific HTML file
return send_from_directory(BASE_DIR, HTML_FILE)
@app.route('/')
def serve_static(filename):
return send_from_directory(BASE_DIR, filename)
@app.route('/query', methods=['POST'])
def query_brain():
data = request.json
text = data.get('query', '')
if not text: return jsonify({"indices": []})
print(f"🔎 Query: {text}")
Get query embedding from OpenAI
response = openai_client.embeddings.create(
model=OPENAI_EMBEDDING_MODEL,
input=text
)
query_vec = response.data[0].embedding
Search in Milvus
results = milvus_client.search(
collection_name=COLLECTION_NAME,
data=[query_vec],
limit=50,
output_fields=["id"]
)
Extract indices and scores
indices = [r['id'] for r in results[0]]
scores = [r['distance'] for r in results[0]]
return jsonify({
"indices": indices,
"scores": scores
})
if __name__ == '__main__':
print("   ✅ SYSTEM ONLINE: http://localhost:8000")
app.run(port=8000)

データセットをダウンロードし、指定されたディレクトリに置く。

https://github.com/milvus-io/milvus-docs/tree/v2.6.x/site/en

3.プロジェクトの開始

テキスト埋め込みを3D空間に変換する

python ingest.py

[イメージ]

フロントエンドサービスを開始する

python GolemServer.py

4.可視化とインタラクション

フロントエンドが検索結果を受け取った後、ノードの明るさはコサイン類似度スコアに基づいてスケーリングされるが、元のノードの色は維持され、明確なカテゴリ・クラスタが維持される。クエリーポイントからマッチした各ノードまで半透明の線が引かれ、カメラはスムーズにパンやズームしてアクティブ化されたクラスタに焦点を合わせる。

例1:ドメイン内マッチ

クエリー"Milvusはどのインデックスタイプをサポートしていますか?"

可視化の動作:

  • 3D空間では、INDEXESとラベル付けされた赤いクラスタ内の約15のノードが顕著な輝度の増加(約2-3倍)を示す。

  • マッチしたノードには、index_types.md、hnsw_index.md、ivf_index.mdなどのドキュメントからのチャンクが含まれる。

  • クエリーベクトルから各マッチしたノードまで半透明の線がレンダリングされ、カメラはスムーズに赤いクラスタに焦点を合わせる。

例2:ドメイン外クエリの拒否

クエリ"KFCのバリューミールはいくらですか?"

可視化の動作.

  • すべてのノードは元の色を保持し、わずかなサイズ変更(1.1×未満)しかない。

  • 一致したノードは、色が異なる複数のクラスターに散在し、明確な意味の集中は見られない。

  • 類似度のしきい値(0.5)を満たさないため、カメラはフォーカス・アクションをトリガしない。

結論

Project_GolemとMilvusの組み合わせは、既存のRAG評価パイプラインを置き換えるものではないが、ほとんどのパイプラインに全く欠けているものを追加する。

このセットアップを使えば、エンベッディングが悪くて検索に失敗したのか、チャンキングが悪くて失敗したのか、閾値がちょっときつかっただけなのかの違いを見分けることができる。このような診断には、以前は推測と反復が必要でした。今は、それを見ることができる。

現在の統合は、Milvusベクトル・データベースが裏で本番レベルの検索を処理することで、デモ・スケール(〜10,000文書)でのインタラクティブなデバッグをサポートしている。100万スケールの可視化への道筋は描かれているが、まだ構築されていない。

GitHubでProject_Golemをチェックし、自分のデータセットで試してみて、ベクトル空間が実際にどのように見えるか見てみよう。

質問があったり、見つけたものを共有したい場合は、Slackチャンネルに参加するか、milvusオフィスアワーに予約して、セットアップのハンズオン指導を受けてください。

    Try Managed Milvus for Free

    Zilliz Cloud is hassle-free, powered by Milvus and 10x faster.

    Get Started

    Like the article? Spread the word

    続けて読む