iT邦幫忙

2

【RAG 實戰】不是 LLM 的幻覺:從財報踩坑看 Vector Search 盲點與 Metadata filtering 三層架構解法

  • 分享至 

  • xImage
  •  

TL;DR

最近在開發 AI Agent 處理美股財報時,我發現了一個嚴重陷阱:過度依賴 Vector Search (向量搜尋) 常會導致跨公司的「Entity Mismatch (實體混淆)」——也就是問 Intel 卻把 NVIDIA 的資料當成正確答案。本文將拆解 Vector DB 的底層運作機制(Metadata, Index, Tenant),並透過 18 題對照實驗證明:為什麼在處理具備明確實體範圍的場景時,「先 Pre-filter (結構化過濾),再 Vector Search」才是唯一解。

本文原載於 Medium,如果喜歡我的文章,歡迎關注 我的 Medium 以獲得最新文章。


0. 前情提要

在開發 RAG (Retrieval-Augmented Generation)應用時,我們常說「檢索決定了生成的上限」。這句話是血淋淋的教訓:如果檢索回來的 Context 是錯誤或不完整的,後面的 LLM 再強,也只能基於錯誤的資訊「一本正經地胡說八道」。

最近我在開發我的 Side Project —— FinLab-X。這是一個旨在掌握美股官方財報(SEC 10-K)、新聞報導與即時數據的 AI Agent。為了確保它能針對官方財報提供高可信度的第一手分析,我決定在串接完整的 RAG 流程前,先專注於 Retrieval 端的評估與驗證。

就在這個過程中,我遇到了一個 RAG 設計中極為典型、且極具啟發性的陷阱: Entity Mismatch (實體錯誤)


1. 慘案現場:問 Intel 卻檢索回 Nvidia 的財報 chunk

當我詢問:「英特爾在 AI 晶片市場的競爭定位?」時,我透過 Tracing 發現,系統檢索回來的 Top-k 內容中,竟然高權重地出現了 NVIDIA (NVDA) 財報裡的段落。

https://ithelp.ithome.com.tw/upload/images/20260729/20179948Q7Qw4dfqry.png

在深入追查 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 (向量空間) 的盲點

想像一下 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


2. 核心觀念:Identifier 不應被視為關鍵字

要解決這個問題,我們必須在架構設計上做一個關鍵的切分:區分「結構化識別碼」與「非結構化語意詞」。

在 RAG 場景中,像 Risk、Competition、Regulation 這些詞彙,具備高度的模糊性與多義性,最適合用 Embedding 去做語意匹配。但像 GOOGLAAPL 這種 Ticker(股票代號),它不是一個需要模型去「理解語意」的概念,而是一個應該被精準匹配的範圍條件。

GOOGL 就是 GOOGL,它沒有「相似」的公司。

因此,這類場景最穩健的做法是:先進行 Pre-filter (結構化過濾),再進行 Vector Search (語意檢索)。 期待模型透過語意自己處理實體範圍,不僅低效,更是導致錯誤率飆升的元兇。


3. Metadata Filtering 的三層契約:從存儲到物理性能

要讓 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 — 帶條件的相似度搜尋)。而要讓這種「組合檢索」高效運作,它涉及一套完整的系統契約:MetadataIndexTenant

第一層:Metadata (元數據層) ——「我存了什麼?」

在 Ingest (寫入) 階段,每個 Chunk 必須帶上描述其屬性的結構化資料。這意味著一個 Chunk 不能只有 text 和 vector,還必須包含 ticker、year、item(財報章節)等欄位。

在不同 Vector DB 裡,它們有不同的名字(Qdrant 叫 Payload、Weaviate 叫 Properties、Pinecone 叫 Metadata)。這一層解決的是「讓 Filter 有資料可以過濾」。

第二層:Index (索引層) ——「我要怎麼找得快?」

有了 Metadata 後,過濾功能才能運作,但不代表它一定快。

這就像在傳統資料庫中,如果你沒有對 ticker 欄位建立 Index,搜尋 WHERE ticker = 'GOOGL' 就會變成 Full Scan。在資料量小時感覺不到,但當你存了數十家公司、數百萬個 Chunk 時,沒有 Index 的 Filter 會讓檢索延遲從毫秒級變成秒級。

Index 解決的是「Query 邏輯上快」—— 它確保我們不用掃描所有 Record,就能迅速鎖定符合條件的資料群。


補充先備知識:ANN 與 HNSW 為什麼是標配?

在進入第三層契約前,我們必須先理解現代 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 大幅降低延遲的秘密:

image

每個 Query 實際只訪問幾百個 Node,而不是幾百萬個。當 Query 同時要做 Vector 相似度 + Metadata Filter 時,Qdrant / Weaviate / Milvus 都是把 Filter 整合進 HNSW Graph Traversal —— 走 Graph 時即時跳過不符合條件的 Node,並繼續用 Graph 鄰居關係導航。

但這個機制有一個致命傷:當 Filter 太過具備 High Selectivity (高選擇性) 時,Graph 的連通性會破裂。


第三層:Tenant / Partition ——「避免 HNSW 導航斷裂退化」

這是技術上最關鍵的一層。在一般關聯式資料庫(如 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 chunk
  • GOOGL 的 risk chunk
  • MSFT 的 risk chunk

在 Vector Space 裡彼此非常接近,因為它們都在談論風險。相反地,同一家公司不同章節的內容(如 GOOGL riskGOOGL revenue),在圖上可能相隔甚遠,因為語意主題完全不同。

下面這支 GIF 直觀呈現了高/低選擇性 filter 對 HNSW Traversal 的影響。上半部是「高選擇性」(大部分 node 都符合條件,所以 walk 能順著 filter 的綠色 node 流暢走過)。特別觀察下半部「低選擇性」的動畫:當 filter 後僅存的 node(綠點)過於稀疏且分散時,搜尋路徑必須在僅存可通行的 node chain 上繞遠路,最後甚至有 3 個明明符合條件的 Match(畫面中打 ✗ 的節點),因為物理連通性孤立而完全到達不了:

image

這會導致嚴重的後果:

  1. Latency 飆高:中間隔了大量被 filter 掉的節點,演算法必須不斷嘗試繞路才能從一個 AAPL 節點走到下一個。
  2. Recall 崩潰:因為物理連通性被破壞,演算法可能在繞路過程中提早收斂,找不到明明存在於資料庫中的相關節點(動畫中 LOW panel 那 3 個打叉的 ✗ 就是這種情況 —— 它們是符合 filter 的 match,但沒有任何 green-green edge 連到它們)。

Tenant / Partition 的角色,就是告訴資料庫:「請在物理排列或索引結構上,將同一個 Ticker 的資料放在一起」。它等於是在建 Graph 時,給每個 Tenant 一個專屬的子 Graph,確保 Filtered Vector Search 始終在一個連通性良好的子圖中運行。

💡 一句話總結 HNSW 的物理連通性:

如果你只下 Filter 條件卻沒做好 Tenant/Partition 規劃,你的查詢在底層就像是在迷宮裡不斷碰壁繞遠路,這就是為什麼有時候加了 Filter 查詢反而變超慢,甚至找不到資料的根本原因。

三層契約總結:

  • Metadata:讓 Filter 有資料可以用。
  • Index:讓 Filter 找得快(邏輯上)。
  • Tenant / Partition:讓 Filtered ANN 不容易因 Graph 連通性而退化(物理上)。RDBMS 裡的 partition 是 ops 工具;Vector DB 裡的 tenant 是 query 性能剛需。

4. 實作落地:在 Qdrant 中建立三層契約

以下是我在 FinLab-X 中實作這套契約的 Code 邏輯。這也是為什麼 Qdrant 把 is_tenant=True 設計成已有 Payload Index 上的 Modifier(而非獨立 API)—— 因為它本來就是「在既有 Index 上再加一層物理 Layout Hint」,與我們的三層心智模型完全吻合。

A. 寫入時保留完整 Payload

不要期待從 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": "..."
    }
)

B. 定義 Schema 與 Tenant Index

這是提升效能與召回率的關鍵步驟。在 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,  # 關鍵:這會優化物理存儲與圖導航
    ),
)

C. 執行帶有 Filter 的檢索

檢索時,我們強制約束 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
)

5. 實驗驗證:不是直覺,是可量化的失誤

光用 Intel → NVIDIA 那個 anecdote 講「Entity Mismatch (實體錯誤)」,容易讓人覺得是極端案例。為了把這個現象量化,我做了一組對照實驗。

5.1 設定:同一份 embedding,兩種 collection

我準備了兩個 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 跨語言的語意對齊去鎖定公司。

5.2 讓我倒抽一口氣的不是平均,是分布

整體平均看起來不算驚悚:

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 拿回來的脈絡,有幾成是錯實體的雜訊。

5.3 災難案例:AMD 在自己的問題裡完全消失

表現最糟糕的一題是 「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。

5.4 但也不是每題都壞 —— Entity 信號強的 query 不需要 filter

對照組同樣值得記一筆:有些 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% 命中的生死關鍵。

5.5 受害者剖面:污染來源就是語意上最接近的競爭對手

把 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(硬體類比)。語意相似度在「同產業競爭對手」之間最強 —— 而這正是讀者最不希望被搞混的兩家公司

5.6 這個實驗沒涵蓋的事

老實說,這次 experiment 也有它的限制,我覺得有必要先講清楚:

  • 語料規模太小:1,844 chunks 跨 6 家公司,HNSW graph traversal 在這個規模下幾乎不會退化 —— 所以三層契約對「latency」跟「within-ticker recall」的好處,本實驗無法被測量到。Section 3 講的 Tenant Index 對 graph 連通性的影響,本實驗只能在概念上論證,沒法在這個規模下實證
  • Oracle ticker 假設:三層 scenario 假設 LLM router 完美地從 query 抽出 target ticker(因為 dataset 預先標好 target_ticker)。真實 production 中,router 抽錯 ticker 的成本是另一條獨立議題
  • 每個 ticker 只有 3 題:per-ticker 平均的統計力有限,呈現的是方向性而非精確 effect size

但即使在這些限制下,無優化平均 0.62、最差 0、AMD 整體 0.13 這幾個數字已經足以說明問題的嚴重性 —— 它不是邊角案例,而是 vector search 在多實體場景下的結構性缺陷。


6. 這不只是股票財報的問題

雖然我的案例是在處理 10-K 財報,但這種設計思維適用於幾乎所有嚴謹的 RAG 應用場景。只要你的語料庫中存在明確的分類、實體或層級結構,你就需要思考「哪些條件應該先 Filter」。

  • 法律應用場景:搜尋時應根據科別(刑法、民法、行政法)進行 Pre-filter。你不會希望在問「過失致死」時,系統去翻閱「民法債編」的補償條款。
  • 醫療資料庫:如果已經知道疾病類型或科別,先行過濾可以大幅提升準確度。語意搜尋能幫你在幾萬種症狀描述中找到最接近的,但 Pre-filter 能保證這些描述都在正確的醫學脈絡下。
  • 企業內部文件:大型企業有部門(Department)、專案(Project)之分。檢索「技術決策紀錄」時,若能先限定在特定的 Project Scope 內,就能精準消除不同團隊間相似語意但無關內容的干擾。

7. 核心挑戰:Pre-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 就會崩潰。

📌 我的建議策略是:

  • 當 Entity / Scope 明確時: 優先使用 Pre-filter。這能帶來最高的精準度。
  • 當 Query 模糊或跨領域時: 保持較大的搜尋範圍,並將過濾的動作交給後段的 Reranker(重排序模型)。Reranker 能在高層級理解哪些 Context 對於這個跨領域問題是有貢獻的。

結語:RAG 檢索即「Query Planning (查詢規劃)」,而看見才能改善

這次在 FinLab-X 處理 10-K 檢索的踩坑紀錄讓我深刻體悟到:RAG 檢索不應該只是一次單純的 Vector Search,而是一個「Query Planning (查詢規劃)」的問題。

透過 Metadata (賦予身份)Index (提供速度)Tenant (保證連通) 這三層契約的配合,我們才能讓系統從「看似對答流利」,進化到「真正看對資料、說對話」。

回過頭來看,我之所以能在開發早期就抓出這個 Retrieval 端的缺陷,很大一部分要歸功於 Observability (可觀測性)Evaluation (評估) 的導入。

  • Tracing (追蹤): 讓我在發現回答有點怪怪的時候,可以直接拆解 Log,看到底層到底檢索出了哪些內容(原來混進了一堆 NVIDIA!)。
  • Evaluation (評估): 上述 18 題的系統性實驗,就是透過 Braintrust 這樣的評估框架算出精準的 ticker-precision@10 分數,讓我能用量化的數據,而不是單純憑感覺來驗證三層契約的成效。

在 AI Agent 時代,把資料找對永遠比把話說得好聽重要,而要確保系統真的找對資料,你就必須要有能「看透黑箱」的工具。


📝 下一篇預告
針對「為什麼開發 AI Agent 時,你極度需要 Observability 與 Evaluation?」這個主題,我之後會再寫一篇文章,深入示範我是如何透過 Tracing 抓蟲,並利用評估框架建立有信心的系統。


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言