本篇聚焦在查詢端的兩個進階後處理技巧:
Small-to-Big與Hybrid Query。這兩者都不改變你怎麼問問題,而是優化:「檢索到的內容要怎麼被組織」、「怎麼被過濾」,因此都歸類在 LlamaIndex 官方文件的Node Postprocessor / Query Mode。
在一般 RAG 實務中,我們把文件切成 chunk、做 embedding、建立 index,但實務上會遇到兩個典型痛點:
切太小,語意準;切太大,語意糊。 小 chunk 的 embedding 比較精準,但送給 LLM 的內容缺乏上下文;大 chunk 上下文夠,但 embedding 容易被稀釋、檢索不準。
純向量檢索(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 的想法很直覺:
檢索(Retrieval)階段用「小」單位:以「句子」為粒度做 embedding,因為單一句子語意集中,向量檢索的準確度最高。
合成(Synthesis)階段用「大」單位:命中某個句子後,不要只把那一句丟給 LLM,而是把它前後幾句組成的「窗口(window)」一起送進去,讓 LLM 有足夠上下文可以回答。
在 LlamaIndex 中整個流程由兩個物件負責:
SentenceWindowNodeParser:切文件時,把每個句子存成一個 node,但同時在該 node 的 metadata 裡,額外保存「以此句為中心、前後各 N 句」的完整窗口文字。
MetadataReplacementPostProcessor:這是一個 node postprocessor,會在檢索完成、送進 LLM 之前執行— 它把每個被檢索到的 node,原本的「單句內容」替換成 metadata 裡存的「窗口內容」。
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 只是其中一個環節,執行順序依串列先後決定。
官方文件用一份 IPCC 氣候報告做對照實驗,問一個很依賴專有名詞的問題(AMOC,大西洋經向翻轉環流):
一般 VectorStoreIndex(chunk 較大,similarity_top_k=2):完全答不出來,因為含 AMOC 關鍵段落被稀釋在大 chunk 中,向量相似度排不進前兩名。
拉高到 similarity_top_k=5 才勉強答對,但代價是檢索變慢、Token 用量變高。
Sentence Window + MetadataReplacementPostProcessor(similarity_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 向量檢索,對精確關鍵字(型號、代號、專有名詞)不夠敏感。做法是同時跑兩種檢索器,再把結果融合:
兩者的分數會被 Qdrant 融合排序後,回傳一份綜合的 Top-K 結果,檢索本身分成兩條路徑執行,最終的排序/篩選才決定哪些節點會進到 LLM。
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)表示法,兩者互不干擾,但可以在查詢時一起計分。
在多用戶場景,「隔離」有兩層意義:
邏輯隔離:用 tenant_id 這個 metadata 欄位加 payload index,讓每次查詢都能用 filter 精準篩掉其他用戶的資料。
物理隔離/效能優化:用 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=6、replication_factor=2 是分散式部署的典型配置:分片數決定平行度與用戶分散的粒度,複製因子決定容錯與讀取吞吐量。地端多節點 Qdrant 叢集就是靠這組參數把資料鋪開。
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 補救。
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 包含特定標籤的節點),把後處理的顆粒度做得更細。
可以,而且是很自然的組合:
索引階段先用 SentenceWindowNodeParser 產生「小句 + 大窗口」的 node。
檢索階段用支援 Hybrid 的向量庫(如本篇提到的 Qdrant)同時做 Dense + Sparse 檢索,取得語意與關鍵字都準的候選節點。
後處理階段再串上 MetadataReplacementPostProcessor,把命中的單句節點換成完整窗口。
最後才把換好的大段落丟給 LLM 合成答案。
一句話總結兩者的分工:
Hybrid Query負責「找得準」,Small-to-Big負責「答得全」。前者優化的是候選節點的相關性,後者優化的是送進 LLM 的上下文完整度,兩者互不衝突、可以疊加使用。
| 面向 | Small-to-Big | Hybrid Query |
|---|---|---|
| 解決的問題 | chunk 大小兩難:準確度 vs. 上下文完整度 | Dense 檢索抓不到精確關鍵字 |
| 作用時機 | 節點內容替換(合成前) | 檢索路徑本身(多路檢索 + 融合) |
| 關鍵元件 | SentenceWindowNodeParser、MetadataReplacementPostProcessor |
enable_hybrid、VectorStoreQueryMode.HYBRID |
| 額外成本 | 索引時多存一份 window metadata | 需要向量庫支援 sparse 索引與融合排序 |
| 適合情境 | 知識密度高、句子獨立性強的文件 | 查詢包含專有名詞、型號、程式碼等精確詞彙 |