如果您可以看到 RAG 失敗的原因?使用 Project_Golem 和 Milvus 在 3D 中偵錯 RAG
當 RAG 擷取出錯時,您通常會知道它壞了 - 相關的文件沒有顯示出來,或是不相關的文件顯示出來。但要找出原因則是另一回事。您要處理的只是相似度得分和結果的平面清單。您無法看到文件在向量空間中的實際位置、文件塊之間的關係,或是您的查詢相對於它應該匹配的內容的位置。實際上,這意味著 RAG 的除錯大多數是試驗與錯誤:調整分塊策略、更換嵌入模型、調整 top-k,希望結果有所改善。
Project_Golem是一個開放源碼工具,讓向量空間變得可見。它使用 UMAP 將高維的 embeddings 投射到 3D 中,並使用 Three.js 在瀏覽器中以互動方式呈現。您不需要猜測擷取失敗的原因,而是可以在單一視覺化介面中看到各個區塊的語意聚類方式、您的查詢落腳點,以及擷取到哪些文件。
這真是令人驚訝。然而,最初的 Project_Golem 是為小型示範而設計,而非實際世界的系統。它依賴於平面檔案、強制搜尋和全資料集重建,這表示當您的資料超過幾千個文件時,它就會迅速瓦解。
為了彌補這個缺口,我們將 Project_Golem 與Milvus(特別是 2.6.8 版) 整合,作為其向量骨幹。Milvus 是一個開放原始碼的高效能向量資料庫,可處理即時擷取、可擴充索引以及毫秒級的擷取,而 Project_Golem 則專注於其最擅長的領域:讓向量擷取行為變得可見。兩者的結合,讓 3D 可視化從玩具式的示範,變成 RAG 生產系統的實用除錯工具。
在這篇文章中,我們將介紹 Project_Golem,並顯示我們如何將它與 Milvus 整合,使向量檢索行為可觀察、可擴充,並適用於生產。
什麼是 Project_Golem?
RAG 調試很困難,原因很簡單:向量空間是高維的,人類看不到它們。
Project_Golem是一個以瀏覽器為基礎的工具,可以讓您看到 RAG 系統運作的向量空間。它將驅動檢索的高維嵌入(通常為 768 或 1536 維),投影到您可以直接探索的互動式 3D 场景中。
以下是它的工作原理:
- 使用 UMAP 減少維度。Project_Golem 使用 UMAP 將高維向量壓縮為三維,同時保留它們的相對距離。在原始空間中語意相似的區塊,在 3D 投影中會靠得很近;不相干的區塊則會相隔很遠。
- 使用 Three.js 進行 3D 渲染。在瀏覽器中渲染的 3D 场景中,每個文件片段都會顯示為一個節點。您可以旋轉、縮放和探索空間,以查看您的文件如何聚類 - 哪些主題緊密聚合、哪些主題重疊,以及邊界在哪裡。
- 查詢時高亮顯示。當您執行查詢時,仍會使用余弦相似度在原始的高維空間中進行檢索。但是一旦結果傳回,擷取的區塊就會在 3D 檢視中亮起。您可以立即看到您的查詢相對於結果的位置 - 同樣重要的是,相對於它沒有擷取到的文件。
這就是 Project_Golem 在調試時的用處。與其盯著排序的結果清單猜測為何遺漏了一個相關的文件,您可以看到它是否位於一個遙遠的叢集(嵌入問題),與不相關的內容重疊(分塊問題),或只是勉強在檢索臨界值之外(配置問題)。3D 視圖可將抽象的相似性分數轉化為您可以推理的空間關係。
為什麼 Project_Golem 還沒有準備好投入生產?
Project_Golem 被設計成一個可視化的原型,它在這方面運作良好。但是它的架構所做的假設,在大規模的情況下很快就會被打破 - 如果您想要將它用於真實世界的 RAG 除錯,這些假設是很重要的。
每次更新都需要完全重建
這是最基本的限制。在原始設計中,新增文件會觸發完整的管道重建:重新產生內嵌並寫入 .npy 檔案、在整個資料集重新執行 UMAP,以及將 3D 座標重新匯出為 JSON。
即使是 100,000 個文件,單核心 UMAP 執行也需要 5-10 分鐘。到了百萬文件的規模,就完全不可行了。您無法將其用於任何持續變更的資料集(例如新聞提報源、文件、使用者對話),因為每次更新都意味著需要等待一個完整的重新處理週期。
粗暴的搜尋方式無法擴充規模
擷取方面也有自己的上限。原始實作使用 NumPy 來進行粗暴的余弦相似性搜尋 - 線性時間複雜度,無索引。在百萬文件的資料集上,單一查詢可能需要超過一秒。這對任何互動或線上系統都是無法使用的。
記憶體壓力使問題更加複雜。每個 768 維的 float32 向量大約需要 3 KB,因此一個百萬向量的資料集需要超過 3 GB 的記憶體 - 全部載入一個平面的 NumPy 陣列,而且沒有索引結構來提高搜尋效率。
無元資料篩選,無多租戶功能
在真正的 RAG 系統中,向量相似性很少是唯一的檢索標準。您幾乎總是需要透過元資料來篩選,例如文件類型、時間戳記、使用者權限或應用程式層級邊界。舉例來說,客戶支援 RAG 系統需要將檢索範圍擴大到特定租戶的文件 - 而不是搜尋所有人的資料。
Project_Golem 不支援這些功能。沒有 ANN 索引(如 HNSW 或 IVF)、沒有標量過濾、沒有租戶隔離,也沒有混合搜尋。它只是一個可視化層,下面沒有生產檢索引擎。
Milvus 如何強化 Project_Golem 的檢索層
上一節指出了三個缺口:每次更新都要完全重建、強制搜尋,以及沒有元資料感知檢索。這三個缺口的根源都是相同的 - Project_Golem 沒有資料庫層。擷取、儲存和視覺化都纏結在單一的管道中,因此變更任何部分都會強迫重建所有東西。
要解決這個問題,並不是要優化這個管道。而是將它分開。
透過整合 Milvus 2.6.8 作為向量骨幹,擷取就成為一個專屬的、生產級的層次,獨立於可視化運作。Milvus 負責向量儲存、索引與搜尋。Project_Golem 純粹專注於渲染 - 從 Milvus 擷取文件 ID,並在 3D 視圖中將其突出顯示。
這樣的分離產生了兩個乾淨、獨立的流程:
檢索流程 (線上、毫秒級)
- 您的查詢會使用 OpenAI 嵌入式轉換成向量。
- 查詢向量會傳送至 Milvus 套件。
- Milvus AUTOINDEX 選擇並優化適當的索引。
- 即時的余弦相似性搜尋會返回相關的文件 ID。
可視化流程 (離線、示範規模)
- UMAP 會在資料輸入時產生 3D 座標 (n_neighbors=30, min_dist=0.1)。
- 座標儲存於 golem_cortex.json。
- 前端會使用 Milvus 傳回的文件 ID 高亮顯示對應的 3D 節點。
關鍵點:檢索不再等待可視化。您可以攝取新的文件並立即進行搜尋 - 3D 視圖會依據自己的排程進行搜尋。
串流節點的改變
Milvus 2.6.8 中的一項新功能:流節點,為實時擷取提供了動力。在早期版本中,即時擷取需要外部訊息佇列,例如 Kafka 或 Pulsar。Streaming Nodes 將這個協調功能移到 Milvus 本身 - 新的向量會被持續擷取,索引會以增量方式更新,而新加入的文件會立即成為可搜尋的文件,不需要完全重建,也不需要外部依賴。
對於 Project_Golem,這就是架構的實用性。您可以在 RAG 系統中不斷新增文件 - 新文章、更新的文件、使用者產生的內容 - 而檢索也能保持最新,無須觸發昂貴的 UMAP → JSON → 重新載入週期。
將視覺化擴展至百萬級 (未來路徑)
有了這個 Milvus 支援的設定,Project_Golem 目前可支援約 10,000 個文件的互動演示。擷取的規模遠遠超過這個規模 - Milvus 可以處理數百萬個文件 - 但視覺化管道仍然依賴 UMAP 的批次執行。為了縮小此差距,可透過增量式視覺化管道來擴充此架構:
更新觸發器:系統會監聽 Milvus 資料集中的插入事件。一旦新加入的文件達到定義的臨界值(例如 1,000 項),就會觸發增量更新。
增量投影:新向量會使用 UMAP 的 transform() 方法投射到現有的 3D 空間中,而不是在整個資料集中重新執行 UMAP。這可保留全局結構,同時大幅降低計算成本。
前端同步:更新的座標片段會透過 WebSocket 串流至前端,讓新節點可以動態出現,而無需重新載入整個場景。
除了可擴充性外,Milvus 2.6.8 還能結合向量相似性、全文搜尋與標量值篩選,實現混合搜尋。這為更豐富的 3D 互動打開了大門 - 例如關鍵字高亮、類別篩選和基於時間的切片 - 讓開發人員有更強大的方式來探索、除錯和推理 RAG 行為。
如何使用 Milvus 部署和探索 Project_Golem
升級後的 Project_Golem 現已在GitHub 上開放原始碼。使用 Milvus 官方文件作為我們的資料集,我們將透過 3D 來實現可視化 RAG 擷取的全過程。設定使用 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)
注意:實作最多支援八個文件類別。如果類別數量超過此限制,顏色會以輪流方式重複使用。
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:域外查詢拒絕
查詢:「肯德基超值套餐多少錢?」
可視化行為:
所有節點都保留原本的顏色,只有輕微的大小變化 (小於 1.1×)。
匹配的節點散佈在多個具有不同顏色的叢集中,並未顯示出明顯的語意集中。
由於未達到相似性臨界值 (0.5),因此相機不會觸發對焦動作。
結論
Project_Golem 搭配 Milvus 不會取代現有的 RAG 評估管道 - 但它增加了大多數管道完全缺乏的功能:檢視向量空間內部發生什麼事的能力。
有了這個設定,您就可以區分出由於糟糕的 embedding 所造成的擷取失敗、由於不良的 chunking 所造成的擷取失敗,以及由於臨界值稍微太緊所造成的擷取失敗。這種診斷以前需要猜測和反覆。現在您可以看到它。
目前的整合支援示範規模 (~10,000 個文件) 的互動式除錯,並由 Milvus 向量資料庫在幕後處理生產級的擷取。通往百萬規模可視化的路徑已經繪製完成,但尚未建置,因此現在是參與的好時機。
在 GitHub 上查看Project_Golem,用您自己的資料集試試看,看看您的向量空間實際上是什麼樣子。
如果您有任何問題或想要分享您的發現,請加入我們的Slack 頻道,或預約Milvus Office Hours 課程,以獲得實作指導。
Try Managed Milvus for Free
Zilliz Cloud is hassle-free, powered by Milvus and 10x faster.
Get StartedLike the article? Spread the word


