iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
AI Engineering

地端 AI 建築學系列 第 13

13 LlamaIndex (2) 檢索後處理

  • 分享至 

  • xImage
  •  

本篇聚焦在查詢端的兩個進階後處理技巧:Small-to-BigHybrid Query。這兩者都不改變你怎麼問問題,而是優化:「檢索到的內容要怎麼被組織」、「怎麼被過濾」,因此都歸類在 LlamaIndex 官方文件的 Node Postprocessor / Query Mode


為什麼需要「檢索後處理」

在一般 RAG 實務中,我們把文件切成 chunk、做 embedding、建立 index,但實務上會遇到兩個典型痛點:

  1. 切太小,語意準;切太大,語意糊。 小 chunk 的 embedding 比較精準,但送給 LLM 的內容缺乏上下文;大 chunk 上下文夠,但 embedding 容易被稀釋、檢索不準。

  2. 純向量檢索(Dense)在某些查詢上表現不佳。 例如查詢包含專有名詞、縮寫、型號、程式碼片段時,語意向量常常抓不到關鍵字的精確匹配,這時反而需要傳統的關鍵字檢索(Sparse / BM25)來補強。

LlamaIndex 針對這兩個問題,分別提供了對應方案:

問題 對應技巧 核心元件
chunk 大小的兩難 Small-to-Big(先找小後找大) SentenceWindowNodeParser + MetadataReplacementPostProcessor
Dense 檢索抓不到關鍵字 Hybrid Query(Dense + Sparse) QdrantVectorStore(enable_hybrid=True) + VectorStoreQueryMode.HYBRID

兩者都屬於「不動 LLM、不動 Prompt,只調整檢索/後處理管線」的優化手段,CP 值很高,也是地端 RAG 系統上線前必調的兩個旋鈕。


Small-to-Big:用小句子找、用大窗口答

核心概念

Small-to-Big 的想法很直覺:

  • 檢索(Retrieval)階段用「小」單位:以「句子」為粒度做 embedding,因為單一句子語意集中,向量檢索的準確度最高。

  • 合成(Synthesis)階段用「大」單位:命中某個句子後,不要只把那一句丟給 LLM,而是把它前後幾句組成的「窗口(window)」一起送進去,讓 LLM 有足夠上下文可以回答。

在 LlamaIndex 中整個流程由兩個物件負責:

  1. SentenceWindowNodeParser:切文件時,把每個句子存成一個 node,但同時在該 node 的 metadata 裡,額外保存「以此句為中心、前後各 N 句」的完整窗口文字。

  2. MetadataReplacementPostProcessor:這是一個 node postprocessor,會在檢索完成、送進 LLM 之前執行— 它把每個被檢索到的 node,原本的「單句內容」替換成 metadata 裡存的「窗口內容」。

索引階段:把每句話包成「帶窗口的 node」

from llama_index.core.node_parser import SentenceWindowNodeParser

node_parser = SentenceWindowNodeParser.from_defaults(
    window_size=3,                      # 前後各取 3 句
    window_metadata_key="window",       # 窗口文字存在這個 metadata key
    original_text_metadata_key="original_text",  # 原始單句存在這裡
)

nodes = node_parser.get_nodes_from_documents(documents)
sentence_index = VectorStoreIndex(nodes)

這裡的關鍵是:embedding 只針對單一句子計算,但每個 node 的 metadata 都額外帶著「原句 + 前後 window_size 句」的完整段落。索引階段不使用一般的 chunk_size 設定,改由 window 設定主導。

查詢階段:檢索到句子後,換成窗口再回答

from llama_index.core.postprocessor import MetadataReplacementPostProcessor

query_engine = sentence_index.as_query_engine(
    similarity_top_k=2,
    node_postprocessors=[
        MetadataReplacementPostProcessor(target_metadata_key="window")
    ],
)

response = query_engine.query("What are the concerns surrounding the AMOC?")

執行順序是:

Query → Embedding 檢索(單句) → 命中 Top-K 句子節點
       → MetadataReplacementPostProcessor 把節點內容換成 window
       → 換好的「大段落」送進 LLM 合成答案

值得注意的是,node_postprocessors 是一個 list,代表可以串接多個後處理器(例如再接一個 rerank,或相似度門檻過濾),MetadataReplacementPostProcessor 只是其中一個環節,執行順序依串列先後決定。

為什麼有效:對照「純 chunk」的失敗案例

官方文件用一份 IPCC 氣候報告做對照實驗,問一個很依賴專有名詞的問題(AMOC,大西洋經向翻轉環流):

  • 一般 VectorStoreIndex(chunk 較大,similarity_top_k=2:完全答不出來,因為含 AMOC 關鍵段落被稀釋在大 chunk 中,向量相似度排不進前兩名。

  • 拉高到 similarity_top_k=5 才勉強答對,但代價是檢索變慢、Token 用量變高。

  • Sentence Window + MetadataReplacementPostProcessorsimilarity_top_k=2:兩個候選就準確命中,因為單句 embedding 精準抓到含 AMOC 的句子,再用窗口補回上下文。

深入追查一般索引為什麼失敗,會發現即使某個 chunk 內確實包含 AMOC,但它出現在該 chunk「中段」— 這正好對應到 Lost in the Middle 這篇論文提到的現象:LLM 對放在上下文中段的資訊,注意力明顯較差。Small-to-Big 因為檢索粒度細,關鍵資訊更容易被排到窗口的邊緣或整段都很短,間接緩解了這個問題。

實作參數建議

  • window_size:一般抓 3~5 句,太小起不了補上下文的作用,太大又會拉回「大 chunk 稀釋」的老問題。

  • similarity_top_k:因為單句檢索精準度高,通常可以設得比一般 chunk 檢索更小(如 2),同時降低成本與延遲。


Hybrid Query:Dense 語意 + Sparse 關鍵字,雙引擎後處理

核心概念

Hybrid Query 解決的是另一個維度的問題:單靠 Dense 向量檢索,對精確關鍵字(型號、代號、專有名詞)不夠敏感。做法是同時跑兩種檢索器,再把結果融合:

  • Dense:一般的語意向量檢索,抓「意思相近」的內容。
  • Sparse(BM25):傳統的詞頻檢索,抓「字面相符」的內容。

兩者的分數會被 Qdrant 融合排序後,回傳一份綜合的 Top-K 結果,檢索本身分成兩條路徑執行,最終的排序/篩選才決定哪些節點會進到 LLM。

建立支援 Dense + Sparse 的 Collection

from qdrant_client import models
from llama_index.vector_stores.qdrant import QdrantVectorStore
from llama_index.embeddings.fastembed import FastEmbedEmbedding
from llama_index.embeddings.ollama import OllamaEmbedding

Settings.embed_model = OllamaEmbedding(model_name="embeddinggemma")

# dense 向量設定:維度由 embedding 模型動態決定,避免寫死
dense_config = models.VectorParams(
    size=len(Settings.embed_model.get_text_embedding("probe")),
    distance=models.Distance.COSINE,
)

# sparse 向量設定:啟用記憶體內索引以支援混合評分
sparse_config = models.SparseVectorParams(
    index=models.SparseIndexParams(on_disk=False)
)

這裡的重點是 Qdrant collection 同時開了兩組向量欄位:一組給 dense embedding,一組給 sparse(BM25)表示法,兩者互不干擾,但可以在查詢時一起計分。

多用戶隔離:Shard Key 與 Payload Index

在多用戶場景,「隔離」有兩層意義:

  1. 邏輯隔離:用 tenant_id 這個 metadata 欄位加 payload index,讓每次查詢都能用 filter 精準篩掉其他用戶的資料。

  2. 物理隔離/效能優化:用 Qdrant 的 Custom Sharding,讓同一用戶的資料實際落在固定的分片群組上,查詢時可以只打自己的分片,不必掃過整個叢集。

shard_keys = ["tenant_a", "tenant_b"]

payload_indexes = [
    {"field_name": "tenant_id", "field_schema": models.PayloadSchemaType.KEYWORD}
]

def shard_key_selector_fn(tenant_id: str) -> models.ShardKeySelector:
    return tenant_id  # 寫入與查詢都用同一個函式決定分片

接著把 dense 設定、sparse 設定、分片設定一起交給 QdrantVectorStore

vector_store = QdrantVectorStore(
    collection_name=COLLECTION,
    client=client,
    aclient=aclient,
    dense_vector_name="dense",
    sparse_vector_name="sparse",
    enable_hybrid=True,
    dense_config=dense_config,
    sparse_config=sparse_config,
    fastembed_sparse_model="Qdrant/bm25",
    shard_number=6,
    sharding_method=models.ShardingMethod.CUSTOM,
    shard_key_selector_fn=shard_key_selector_fn,
    shard_keys=shard_keys,
    replication_factor=2,          # 高可用:每個分片複製 2 份
    payload_indexes=payload_indexes,
)

shard_number=6replication_factor=2 是分散式部署的典型配置:分片數決定平行度與用戶分散的粒度,複製因子決定容錯與讀取吞吐量。地端多節點 Qdrant 叢集就是靠這組參數把資料鋪開。

寫入時帶上 Shard Identifier

for tenant_id, docs in TENANT_DOCS.items():
    for doc in docs:
        doc.embedding = Settings.embed_model.get_text_embedding(doc.text)
    await vector_store.async_add(docs, shard_identifier=tenant_id)

寫入時明確指定 shard_identifier=tenant_id,讓每筆資料落在對應的分片群組,這是「用戶資料落地時就已經隔離」的關鍵一步,而不是等查詢時才靠 filter 補救。

查詢階段:啟用 Hybrid 模式

from llama_index.core.retrievers import VectorIndexRetriever
from llama_index.core.vector_stores.types import VectorStoreQueryMode

def create_retriever_for_tenant(tenant_id: str) -> VectorIndexRetriever:
    return VectorIndexRetriever(
        index=index,
        vector_store_query_mode=VectorStoreQueryMode.HYBRID,
        similarity_top_k=5,   # dense 候選數
        sparse_top_k=5,       # sparse 候選數
        hybrid_top_k=5,       # 融合後最終保留數
        vector_store_kwargs={"shard_identifier": tenant_id},
    )

retriever = create_retriever_for_tenant("tenant_b")
results = retriever.retrieve("manage microservices traffic and observability")

這裡有三個 top_k 各司其職:similarity_top_k 是 dense 那條路徑先撈出來的候選量,sparse_top_k 是 sparse 那條路徑的候選量,兩批候選融合排序後,最終只留下 hybrid_top_k 筆送到後續流程。

這一步在「後處理」裡的角色

把 Hybrid Query 放進「查詢後處理」脈絡來看,它做的事情是:

Query → 同時觸發 Dense 檢索 與 Sparse 檢索
       → Qdrant 端把兩組分數融合、排序
       → 依 hybrid_top_k 截斷,只保留最終候選
       → (可選)疊加 tenant_id / tags 等 metadata filter 再過濾一次
       → 送進 LLM 合成答案

換句話說,Hybrid 本身就是一種「多路檢索 + 融合排序」的後處理策略;如果還想更嚴謹,可以在 vector_store_query_mode=HYBRID 之外,額外疊加 metadata filter(例如只找 tags 包含特定標籤的節點),把後處理的顆粒度做得更細。


兩個技巧可以一起用嗎?

可以,而且是很自然的組合:

  1. 索引階段先用 SentenceWindowNodeParser 產生「小句 + 大窗口」的 node。

  2. 檢索階段用支援 Hybrid 的向量庫(如本篇提到的 Qdrant)同時做 Dense + Sparse 檢索,取得語意與關鍵字都準的候選節點。

  3. 後處理階段再串上 MetadataReplacementPostProcessor,把命中的單句節點換成完整窗口。

  4. 最後才把換好的大段落丟給 LLM 合成答案。

一句話總結兩者的分工:Hybrid Query 負責「找得準」,Small-to-Big 負責「答得全」。前者優化的是候選節點的相關性,後者優化的是送進 LLM 的上下文完整度,兩者互不衝突、可以疊加使用。

面向 Small-to-Big Hybrid Query
解決的問題 chunk 大小兩難:準確度 vs. 上下文完整度 Dense 檢索抓不到精確關鍵字
作用時機 節點內容替換(合成前) 檢索路徑本身(多路檢索 + 融合)
關鍵元件 SentenceWindowNodeParserMetadataReplacementPostProcessor enable_hybridVectorStoreQueryMode.HYBRID
額外成本 索引時多存一份 window metadata 需要向量庫支援 sparse 索引與融合排序
適合情境 知識密度高、句子獨立性強的文件 查詢包含專有名詞、型號、程式碼等精確詞彙

上一篇
12 LlamaIndex (1) 跟著官方文件快速總覽
系列文
地端 AI 建築學13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言