最近在開發 AI Agent 處理美股財報時,我發現了一個嚴重陷阱:過度依賴 Vector Search (向量搜尋) 常會導致跨公司的「Entity Mismatch (實體混淆)」——也就是問 Intel 卻把 NVIDIA 的資料當成正確答案。本文將拆解 Vector DB 的底層運作機制(Metadata, Index, Tenant),並透過 18 題對照實驗證明:為什麼在處理具備明確實體範圍的場景時,「先 Pre-filter (結構化過濾),再 Vector Search」才是唯一解。
在開發 RAG (Retrieval-Augmented Generation)應用時,我們常說「檢索決定了生成的上限」。這句話是血淋淋的教訓:如果檢索回來的 Context 是錯誤或不完整的,後面的 LLM 再強,也只能基於錯誤的資訊「一本正經地胡說八道」。
最近我在開發我的 Side Project —— FinLab-X。這是一個旨在掌握美股官方財報(SEC 10-K)、新聞報導與即時數據的 AI Agent。為了確保它能針對官方財報提供高可信度的第一手分析,我決定在串接完整的 RAG 流程前,先專注於 Retrieval 端的評估與驗證。
就在這個過程中,我遇到了一個 RAG 設計中極為典型、且極具啟發性的陷阱: Entity Mismatch (實體錯誤)。
當我詢問:「英特爾在 AI 晶片市場的競爭定位?」時,我透過 Tracing 發現,系統檢索回來的 Top-k 內容中,竟然高權重地出現了 NVIDIA (NVDA) 財報裡的段落。

在深入追查 Tracing Log 中每一個檢索回來的 Chunk 後,我才理解到這並不是 Embedding 模型不夠聰明的問題:就純語義而言,這些結果確實高度相似且合理。
如果你仔細看上面這段被檢索回來的 NVIDIA 財報內文:「Our AI technology leadership is reinforced by our large and expanding ecosystem... We are the leader in accelerating and releasing open AI models...」(我們的 AI 技術領先地位得益於龐大且不斷擴張的生態系...我們在加速和發布開放 AI 模型方面處於領導地位...)。
這段文字裡充滿了「AI technology leadership」、「ecosystem」、「accelerating」、「platform」這些強烈的語意特徵。對 Embedding 模型來說,這段描述簡直完美回答了「什麼是 AI 晶片市場的競爭定位」這個 Query。但問題在於,這段 Chunk 本身從頭到尾都沒有提到「NVIDIA」這個字眼。這暴露出 Vector Search (向量搜尋) 的一個本質局限:Similarity (語意相似度) 不等於 Entity Accuracy (實體準確度)。
想像一下 Vector Space 的運作方式:當我們把一段文字轉化為 Embedding 時,模型會捕捉其「主題特徵」。在財報語境下,「技術領先」、「龐大生態系」或「平台優勢」等詞彙,在 Vector Space 中會形成非常緊密的聚簇,並且完美對齊了「競爭定位」這個提問。
問題在於,不是每個 Chunk 都會像自我介紹一樣,明確寫出自己屬於哪一個 Ticker (股票代號)。一段文字裡可能完全不包含公司名稱,導致光用語意搜尋,極難精準對應到正確的目標公司。對 Embedding 模型來說,NVIDIA 描述自身 AI 霸主地位的段落,在語意特徵上甚至比 Intel 財報裡某些較為保守的業務描述,更接近使用者詢問「AI 晶片市場競爭定位」的 Query。
這就是問題的核心:我們把 Scope (實體範圍) 的判斷權,錯誤地交給了語意搜尋。
當下我直覺這不會是個案。後面在 Section 5,我會展示一組 18 題的系統性實驗 —— 在最單純(無特別優化)的設定下,平均 ticker-precision@10 只有 0.62。也就是說,檢索回來的 10 個 Chunk 中,平均有近 4 個是其他公司的財報!而表現最糟糕的一題,Top-10 裡甚至完全沒有目標公司的 Chunk。
要解決這個問題,我們必須在架構設計上做一個關鍵的切分:區分「結構化識別碼」與「非結構化語意詞」。
在 RAG 場景中,像 Risk、Competition、Regulation 這些詞彙,具備高度的模糊性與多義性,最適合用 Embedding 去做語意匹配。但像 GOOGL 或 AAPL 這種 Ticker(股票代號),它不是一個需要模型去「理解語意」的概念,而是一個應該被精準匹配的範圍條件。
GOOGL 就是 GOOGL,它沒有「相似」的公司。
因此,這類場景最穩健的做法是:先進行 Pre-filter (結構化過濾),再進行 Vector Search (語意檢索)。 期待模型透過語意自己處理實體範圍,不僅低效,更是導致錯誤率飆升的元兇。
要讓 Pre-filter 真正發揮生產環境等級的威力,不能只是在查詢時隨便加個參數。在深入探討之前,我們先來拆解 Vector DB 的底層資料結構。事實上,Vector DB 的每筆 Record (紀錄) 同時掛載了兩個可被檢索的路徑:
┌─────────────────────────────────────────────────────────┐
│ Vector DB Record │
│ id = "AAPL:2024:42" │
│ vector = [3072 floats] ← 相似度檢索 (ANN) 走這條 │
│ metadata = { ticker, year, item, header_path, ... } │
│ └──┬──┘ │
│ └── 條件篩選 (filter) 走這條 │
└─────────────────────────────────────────────────────────┘
這兩條路徑可以獨立使用(純向量搜尋 / 純 metadata 篩選),也可以組合使用(Filtered ANN — 帶條件的相似度搜尋)。而要讓這種「組合檢索」高效運作,它涉及一套完整的系統契約:Metadata、Index 與 Tenant。
在 Ingest (寫入) 階段,每個 Chunk 必須帶上描述其屬性的結構化資料。這意味著一個 Chunk 不能只有 text 和 vector,還必須包含 ticker、year、item(財報章節)等欄位。
在不同 Vector DB 裡,它們有不同的名字(Qdrant 叫 Payload、Weaviate 叫 Properties、Pinecone 叫 Metadata)。這一層解決的是「讓 Filter 有資料可以過濾」。
有了 Metadata 後,過濾功能才能運作,但不代表它一定快。
這就像在傳統資料庫中,如果你沒有對 ticker 欄位建立 Index,搜尋 WHERE ticker = 'GOOGL' 就會變成 Full Scan。在資料量小時感覺不到,但當你存了數十家公司、數百萬個 Chunk 時,沒有 Index 的 Filter 會讓檢索延遲從毫秒級變成秒級。
Index 解決的是「Query 邏輯上快」—— 它確保我們不用掃描所有 Record,就能迅速鎖定符合條件的資料群。
在進入第三層契約前,我們必須先理解現代 Vector DB 是如何運作的。
Vector Retrieval 的本質問題是:給定一個 Query Vector,從幾百萬個 Stored Vectors 裡找出 Cosine 距離最接近的 Top-k 個。最直白做法是 Exact k-NN —— 每個 Stored Vector 都算一次距離,複雜度為 O(N)。當語料庫一龐大,這根本跑不動。
因此,實務上 Vector DB 都採用 Approximate Nearest Neighbor (ANN):透過 Graph、Tree 或 Quantization 等資料結構,犧牲一點 Recall(不保證找到 100% 最接近的 k 個),來換取 Sub-linear 的查詢速度。對檢索場景來說,Recall 96% 與 100% 通常不影響下游的 LLM 生成品質,但延遲從 200ms 降到 5ms 卻是天壤之別。
其中,Hierarchical Navigable Small World (HNSW) 是當代最主流的 ANN 演算法(Qdrant、Weaviate、pgvector >= 0.5、Milvus 皆支援)。它是一個多層 graph 結構:上層稀疏只有少數 node、像 highway 一樣大跨步,下層密集涵蓋所有 vector、能做精細鄰居搜尋。
下面的動畫示範了 HNSW 的 Greedy Walk 過程 —— Query 從 Layer 2 的入口進入,逐層 descend,在每層走幾步 refine,最後在 Layer 0 抓 Top-k。請注意動畫中 Query 是如何從 Layer 2 的稀疏節點,快速跳躍到 Layer 0 的密集節點,這就是 Sub-linear 大幅降低延遲的秘密:
每個 Query 實際只訪問幾百個 Node,而不是幾百萬個。當 Query 同時要做 Vector 相似度 + Metadata Filter 時,Qdrant / Weaviate / Milvus 都是把 Filter 整合進 HNSW Graph Traversal —— 走 Graph 時即時跳過不符合條件的 Node,並繼續用 Graph 鄰居關係導航。
但這個機制有一個致命傷:當 Filter 太過具備 High Selectivity (高選擇性) 時,Graph 的連通性會破裂。
這是技術上最關鍵的一層。在一般關聯式資料庫(如 Postgres)裡,Partition 比較像是 Ops (維運) 工具(按 ticker 砍 partition、獨立 vacuum、scale write),查詢延遲的改善通常不大,因為 B-tree 已經是極快的 O(log N) 了。
但在 Vector DB 裡,Tenant / Partition 是 Query 性能的剛需,因為它關乎 HNSW 的物理連通性。
如前所述,HNSW graph 通常是根據 Vector Similarity 建立的,而不是 Metadata。這會造成一個看似違反直覺,但在語意空間中極為合理的現象:
AAPL 的 risk chunkGOOGL 的 risk chunkMSFT 的 risk chunk在 Vector Space 裡彼此非常接近,因為它們都在談論風險。相反地,同一家公司不同章節的內容(如 GOOGL risk 與 GOOGL revenue),在圖上可能相隔甚遠,因為語意主題完全不同。
下面這支 GIF 直觀呈現了高/低選擇性 filter 對 HNSW Traversal 的影響。上半部是「高選擇性」(大部分 node 都符合條件,所以 walk 能順著 filter 的綠色 node 流暢走過)。特別觀察下半部「低選擇性」的動畫:當 filter 後僅存的 node(綠點)過於稀疏且分散時,搜尋路徑必須在僅存可通行的 node chain 上繞遠路,最後甚至有 3 個明明符合條件的 Match(畫面中打 ✗ 的節點),因為物理連通性孤立而完全到達不了:
這會導致嚴重的後果:
Tenant / Partition 的角色,就是告訴資料庫:「請在物理排列或索引結構上,將同一個 Ticker 的資料放在一起」。它等於是在建 Graph 時,給每個 Tenant 一個專屬的子 Graph,確保 Filtered Vector Search 始終在一個連通性良好的子圖中運行。
如果你只下 Filter 條件卻沒做好 Tenant/Partition 規劃,你的查詢在底層就像是在迷宮裡不斷碰壁繞遠路,這就是為什麼有時候加了 Filter 查詢反而變超慢,甚至找不到資料的根本原因。
以下是我在 FinLab-X 中實作這套契約的 Code 邏輯。這也是為什麼 Qdrant 把 is_tenant=True 設計成已有 Payload Index 上的 Modifier(而非獨立 API)—— 因為它本來就是「在既有 Index 上再加一層物理 Layout Hint」,與我們的三層心智模型完全吻合。
不要期待從 ID 拆解資訊。ID 應該是 UUID5 產生的標識符,過濾用的資料必須明確存在於 Payload 中。
# 每個點位的結構
point = PointStruct(
id=generate_deterministic_uuid(ticker, year, chunk_id),
vector=embedding,
payload={
"ticker": "GOOGL",
"year": 2024,
"item": "Item 1A",
"text": "..."
}
)
這是提升效能與召回率的關鍵步驟。在 Qdrant 中,我們將 ticker 設為 is_tenant=True。
from qdrant_client import models
# 建立 Payload Index 並啟用 Tenant 特性
client.create_payload_index(
collection_name="finlab_rag",
field_name="ticker",
field_schema=models.KeywordIndexParams(
type="keyword",
is_tenant=True, # 關鍵:這會優化物理存儲與圖導航
),
)
檢索時,我們強制約束 Scope。
results = client.query_points(
collection_name="finlab_rag",
query=query_vector,
query_filter=models.Filter(
must=[
models.FieldCondition(
key="ticker",
match=models.MatchValue(value="GOOGL")
)
]
),
limit=10
)
光用 Intel → NVIDIA 那個 anecdote 講「Entity Mismatch (實體錯誤)」,容易讓人覺得是極端案例。為了把這個現象量化,我做了一組對照實驗。
我準備了兩個 Qdrant collection,內容完全相同的 1,844 個 chunks(涵蓋 6 家美股公司:AAPL / AMD / GOOGL / INTC / MSFT / NVDA 的 SEC 10-K),用同一個 embedding model(text-embedding-3-large)跑出來的 vectors —— 事實上無優化 (Naive) 的 collection 是用 Qdrant scroll + upsert 直接從三層契約 collection 拷貝過去的。兩者唯一差別在 build-time 與 query-time:
| Naive collection | Three-layer collection | |
|---|---|---|
| 寫入 | 只有 vectors | vectors + payload(ticker, year, item) |
| Schema | 無 Payload Index | KeywordIndex + is_tenant=True on ticker |
| 查詢 | search(q, top_k=10) |
search(q, must=[ticker=X], top_k=10) |
評估指標是 ticker-precision@10 —— Top-10 結果中,有幾個真的屬於 query 鎖定的公司。測試 query 是 18 題中文問句(6 家公司 × 3 題),例如「蘋果在中國市場面臨什麼風險?」、「AMD 的 AI 加速器產品有什麼策略?」。值得一提的是,沒有任何 query 直接出現英文 ticker code —— 全靠 embedding 跨語言的語意對齊去鎖定公司。
整體平均看起來不算驚悚:
| Naive | Three-layer | |
|---|---|---|
| Mean p@10 | 0.622 | 1.000 |
(三層契約那一欄的 1.000 並不算 finding —— must=[ticker=X] 是 filter 機制的數學保證,Top-k 必然全屬目標公司。)真正讓人警覺的是無優化模式的分布:
| Naive | |
|---|---|
| Std dev p@10 | 0.312 |
| Min p@10 | 0.00 |
| Max p@10 | 1.00 |
「平均六成命中」聽起來尚可,但加上 0 到 100% 的可怕變異,意義就完全不同 —— 系統的可靠度從根本上不可預測。我事先沒辦法知道下一個 query 拿回來的脈絡,有幾成是錯實體的雜訊。
表現最糟糕的一題是 「AMD 面臨的供應鏈與製程依賴風險?」。Naive collection 回傳的 Top-5 是這樣:
| Rank | Naive(無 filter) | Three-layer(帶 ticker filter) |
|---|---|---|
| 1 | ✗ NVDA — supplier dependency | ✓ AMD — Item 7,inventory |
| 2 | ✗ INTC — single supplier reliance | ✓ AMD — Item 7,Data Center revenue |
| 3 | ✗ NVDA — manufacturing lead times | ✓ AMD — Item 7,AI strategy overview |
| 4 | ✗ INTC — disaggregated architecture | ✓ AMD — Item 7,product launches |
| 5 | ✗ INTC — complex global supply chain | ✓ AMD — Item 7,AI teams |
如果你將這份沒有 Filter 的 Context 直接餵給下游的 LLM,系統等於是完美地誤導了模型,讓它用 Intel 和 NVIDIA 的供應鏈描述,去「幻想」出一份 AMD 的報告。表面上條理分明、引述精確,實際上整個答案的實體背景全錯。
AMD 為什麼受害最重?它在語料庫只佔 4.5%(83 / 1,844 chunks),語意上又跟 INTC、NVDA 同為晶片產業的直接競爭者 —— 在純向量搜尋下,它的 chunks 完全被產業競爭對手的語意鄰居淹沒了。這正是 Section 1 那個觀察的放大版:NVIDIA 描述自身 AI 領先地位的段落,在語意上比 Intel 財報裡保守的業務描述更接近「AI 晶片競爭」的 Query。套到語料佔比最小的 AMD 身上,結果就是 0/10。
對照組同樣值得記一筆:有些 query 即使在最單純的模式都能拿到 p@10 = 1.0。
| Query | Naive p@10 | 為什麼這題不靠 filter 也準 |
|---|---|---|
| 微軟雲端事業的營收成長動能來自哪裡? | 1.00 | 「微軟」+「雲端」+「營收」三層 disambiguator 在 MSFT 語料專屬性都高 |
| 英特爾製程技術轉型遇到哪些挑戰? | 1.00 | 「製程技術轉型」是 INTC 的 unique narrative(其他公司是 fabless,根本不會講自己的 fab 轉型) |
對 entity scope 在 embedding space 已經夠明確的 query,Pre-filter 是錦上添花;但對 AMD 這類會被語意鄰居淹沒的 query,Pre-filter 是從 0% 命中變成 100% 命中的生死關鍵。
把 18 題按 ticker 切開看,無優化模式下每家公司的命中率差異非常大:
| Ticker | n | p@10 mean | p@10 range | 主要污染源 |
|---|---|---|---|---|
| AMD | 3 | 0.13 | 0.0 – 0.2 | INTC, NVDA |
| GOOGL | 3 | 0.47 | 0.4 – 0.6 | AAPL, MSFT |
| AAPL | 3 | 0.73 | 0.4 – 0.9 | NVDA, INTC |
| NVDA | 3 | 0.73 | 0.4 – 0.9 | INTC |
| INTC | 3 | 0.80 | 0.6 – 1.0 | NVDA, AAPL |
| MSFT | 3 | 0.87 | 0.7 – 1.0 | NVDA, GOOGL |
污染來源高度符合產業競爭關係:AMD ↔ INTC ↔ NVDA(同晶片產業)、GOOGL ↔ MSFT(雲端競爭)、AAPL ↔ NVDA / INTC(硬體類比)。語意相似度在「同產業競爭對手」之間最強 —— 而這正是讀者最不希望被搞混的兩家公司。
老實說,這次 experiment 也有它的限制,我覺得有必要先講清楚:
target_ticker)。真實 production 中,router 抽錯 ticker 的成本是另一條獨立議題但即使在這些限制下,無優化平均 0.62、最差 0、AMD 整體 0.13 這幾個數字已經足以說明問題的嚴重性 —— 它不是邊角案例,而是 vector search 在多實體場景下的結構性缺陷。
雖然我的案例是在處理 10-K 財報,但這種設計思維適用於幾乎所有嚴謹的 RAG 應用場景。只要你的語料庫中存在明確的分類、實體或層級結構,你就需要思考「哪些條件應該先 Filter」。
既然 Pre-filter 這麼強,為什麼不乾脆把所有查詢都先過濾一遍?這裡存在一個關鍵的 Trade-off (權衡)。
Pre-filter 適合處理「Structured Signal (結構化訊號)」,而不適合取代「Semantic Understanding (語意理解)」。
事實上,Section 5 的實驗就已經透露這個 hint —— 對「微軟雲端事業的營收成長動能來自哪裡?」這種 entity 信號夠強的 query,無優化模式就已經 p@10 = 1.0,額外加 filter 只是錦上添花。問題出現在另一端:當 Query 本身是「跨分類」、「探索式」或是「分類邊界模糊」時,過早的 Filter 反而會成為阻礙。
舉個例子:如果使用者的問題是:「大型科技公司在最新一季共同面臨哪些 AI 監管風險?」
這是一個需要跨實體比較的問題。如果你在檢索前就硬性 Filter 到只剩單一公司,你就會錯失 Microsoft、Nvidia 或 Meta 的關鍵對比資訊,這時系統的 Recall 就會崩潰。
這次在 FinLab-X 處理 10-K 檢索的踩坑紀錄讓我深刻體悟到:RAG 檢索不應該只是一次單純的 Vector Search,而是一個「Query Planning (查詢規劃)」的問題。
透過 Metadata (賦予身份)、Index (提供速度) 與 Tenant (保證連通) 這三層契約的配合,我們才能讓系統從「看似對答流利」,進化到「真正看對資料、說對話」。
回過頭來看,我之所以能在開發早期就抓出這個 Retrieval 端的缺陷,很大一部分要歸功於 Observability (可觀測性) 與 Evaluation (評估) 的導入。
ticker-precision@10 分數,讓我能用量化的數據,而不是單純憑感覺來驗證三層契約的成效。在 AI Agent 時代,把資料找對永遠比把話說得好聽重要,而要確保系統真的找對資料,你就必須要有能「看透黑箱」的工具。
📝 下一篇預告
針對「為什麼開發 AI Agent 時,你極度需要 Observability 與 Evaluation?」這個主題,我之後會再寫一篇文章,深入示範我是如何透過 Tracing 抓蟲,並利用評估框架建立有信心的系統。