一個具體失敗案例

我這個專案早期有一個 query 一直答不對:

「這份 Q3 合約的 early termination 條款,違約金怎麼算?」

Vector search 找到了對的文件——mou_2025_003.pdf 在 top 3。但 LLM 答出來的是:early termination 走 30 天書面通知、違約金由雙方協商。

事實是合約寫得很清楚:early termination 要 60 天通知、違約金是 6 個月訂閱費。文件找到了,答案還是錯的

為什麼?回去 trace 一輪發現:vector search 撈到的是這份 MOU 的「合作範圍」跟「付款條款」兩個 section——它們都在 top 5,但 LLM 沒撈到第三段那個明確寫 early termination 跟違約金的條款。為什麼沒撈到?因為「early termination 違約金」這幾個字的 dense embedding 跟「合作範圍」這段文字的 embedding 距離比較近,跟第三段的 dense embedding 反而遠一點。

換個角度講:dense vector 抓的是「語意相似」,不是「真的能回答問題」。向量距離近 ≠ 段落內容能答這個 query。Vector search 本身沒有「對錯」概念,它只算「像不像」——這個 gap 是 naive RAG 系統性失敗的根因。

這就是純 vector search 的極限:找到「相關」的文件,但不一定找到「對」的段落。


找得到跟答得好是兩件不同的事

Vector search 找的是「語意相近」,不是「真的能回答這個問題」。

naive RAG 把這兩件事當同一件事:撈到 top 5 → 餵給 LLM。但 production 場景下,這個假設幾乎一定會破——你要嘛找到「語意近但答不了」、要嘛「答案在但語意遠所以沒撈到」。

Retrieval layer 決定答案品質的上限,不是 LLM。 LLM 再強,如果檢索給的是錯的段落,它只能基於錯的段落生成答案。LLM 換成更好的 model 也沒救——檢索漏了,模型再強也補不回來

這個專案把 70% 精力花在 retrieval layer(hybrid search、reranking、parent expansion、context compression、citation assembly),就是為了把「找得到」這件事做對。LLM 部分只佔 30%。


這個專案加的 4 個 retrieval 強化

純 vector search 不夠。4 個強化加上去後,剛才那個 early termination query 答對了。

強化 1:Hybrid retrieval(dense + BM25 + metadata)

Dense vector 抓語意、BM25 抓精確字串、metadata filter 限制範圍。三個一起跑、再用 RRF(Reciprocal Rank Fusion)合併。

建 hybrid collection(Qdrant named vectors):

from qdrant_client import QdrantClient
from qdrant_client.http import models

def create_hybrid_collection(client: QdrantClient, name: str) -> None:
    client.create_collection(
        collection_name=name,
        vectors_config={
            "dense": models.VectorParams(size=384, distance=models.Distance.COSINE),
        },
        sparse_vectors_config={
            "sparse": models.SparseVectorParams(index=models.SparseIndexParams()),
        },
    )

查詢端用 LlamaIndex Qdrant integration:

from llama_index.vector_stores.qdrant import QdrantVectorStore

def get_hybrid_store(client: QdrantClient, name: str) -> QdrantVectorStore:
    return QdrantVectorStore(
        client=client,
        collection_name=name,
        enable_hybrid=True,                # dense + sparse 同時建
        fastembed_sparse_model="Qdrant/bm25",
        hybrid_fusion_fn="rrf",            # Reciprocal Rank Fusion 合併
        # 加上 metadata filter:document_type、language、tenant_id
    )

為什麼 BM25 不能省? 同一個 query 換幾種問法:

問法Dense vectorBM25
「這份 Q3 合約的 early termination 怎麼算」中(語意近)強(命中 early termination
GET /users/me/profile弱(純字串)強(完全命中)
INV-2025-003
「終止合約要走什麼流程」弱(沒命中「終止」)

Dense 強的地方 BM25 弱,BM25 強的地方 Dense 弱。production 不是二選一。早期 RAG 系統只跑 dense search,後來出事,9 成都是 dense search 沒撈到該撈的精確字串條款

中文 + BM25 的獨特坑:很多 BM25 預設是英文斷詞,中文要另外處理。LlamaIndex 的 Qdrant hybrid integration 用 Qdrant/bm25 model 透過 FastEmbed 跑,但中文斷詞品質不穩。實務上要驗證 sparse vector 真的有非零值:

import jieba
from fastembed import SparseTextEmbedding

# 中文斷詞 + 驗證 BM25 sparse vector
sparse_model = SparseTextEmbedding(model_name="Qdrant/bm25")

def chinese_tokenize(text: str) -> list[str]:
    return [t for t in jieba.cut(text) if len(t.strip()) > 1]

# 驗證 sparse vector 真的命中
query = "early termination 違約金"
sparse_vec = list(sparse_model.embed([query]))[0]
non_zero = sum(1 for v in sparse_vec.values if v > 0)
assert non_zero >= 3, f"BM25 中文斷詞失敗: {non_zero} 個非零值(期望 ≥3)"

別看到「hybrid」就以為中文也吃——沒跑過這個驗證就上 production,會出現「hybrid 也搜不到中文精確詞」的災難。

強化 2:Reranking(粗找 → 精排)

Hybrid search 撈 50–200 個候選,reranker 用 cross-encoder 重新排序,把真正有用的 5–20 個排到前面。

完整 query engine pipeline(含 hybrid + rerank + LongContextReorder + 壓縮):

from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.postprocessor import LongContextReorder
from llama_index.postprocessor.cohere_rerank import CohereRerank
from llama_index.core.response_synthesizers import CompactAndRefine

postprocessors = [
    LongContextReorder(),    # 重要 chunk 放頭尾(LLM 注意力集中在頭尾)
    CohereRerank(top_n=8, model="rerank-english-v3.0"),
]

query_engine = RetrieverQueryEngine.from_args(
    retriever=retriever,
    node_postprocessors=postprocessors,
    response_synthesizer=CompactAndRefine(),
)

為什麼 hybrid 之後還要 rerank? Hybrid 合併 RRF 是粗排——把多個排序融合成一個,但 rerank 用 cross-encoder 對(query, candidate)做語意比對,比純向量比對精準很多。Hybrid + Rerank 一起做,跟單獨做差距 20-30%。

主流選項:Cohere Rerank(managed,馬上能跑)、BGE reranker(自架,成本可控)、Jina / Voyage(多語或重檢索場景)。Qdrant 有 ColBERT / multivector 內建選項但比較進階,新手先用外部 reranker。

強化 3:Parent-doc expansion + Context compression

小 chunk 好搜尋、大 parent 好回答。先用小 chunk 找位置,再把所屬大段落拿回來

from llama_index.core.schema import TextNode

async def expand_to_parent(node: TextNode, docstore) -> TextNode:
    """把搜尋到的小 chunk 換成它的 parent section"""
    parent_id = node.metadata.get("parent_id")
    if not parent_id:
        return node
    parent = await docstore.aget(parent_id)
    return parent or node

Compression 把不相關的句子過濾掉,只留跟 query 有關的內容。好處:省 token、降低雜訊、減少 LLM 亂引用的機率——特別是 chunking 切得比較大的時候,compression 效果最明顯。

強化 4:Citation assembly(自訂 citation mapper)

把每個答案綁回 source。這不只是顯示「來源 [1]」給使用者看——是讓你能驗證 LLM 到底憑什麼答

from pydantic import BaseModel, Field
from typing import Optional

class CitationItem(BaseModel):
    marker: str = Field(..., description="Inline marker like [1]")
    file_name: str
    page_number: Optional[int] = None
    section_title: Optional[str] = None
    node_id: str
    text_preview: str
    retrieval_score: float = 0.0

def map_citations(response) -> list[CitationItem]:
    """把 response.source_nodes 轉成前端可用的引用清單"""
    return [
        CitationItem(
            marker=f"[{i+1}]",
            file_name=node.metadata.get("file_name", "unknown"),
            page_number=node.metadata.get("page_number"),
            section_title=node.metadata.get("section_title"),
            node_id=node.node_id,
            text_preview=node.get_content()[:200],
            retrieval_score=float(node.score or 0),
        )
        for i, node in enumerate(response.source_nodes)
    ]

為什麼不直接用 LlamaIndex 內建的 CitationQueryEngine 這個專案的 context 經過「Qdrant → reranker → parent expansion → compression → LongContextReorder → custom prompt」這幾層處理,真正送進 LLM 的 source 跟 response.source_nodes 對不上。內建 citation 引擎的 marker regex 在 long-context reorder 跟 marker 替換之後會壞掉——這個反例是實際踩過的坑,不是理論風險。自訂 citation mapper 才是 production 級做法


這些強化的 tradeoff(具體數字)

不是加了越多越好。每一層強化都有成本——下面數字是根據這個專案實際測量 + 常見業界基準的估算(不是官方保證值):

強化品質影響成本影響可解釋性影響
Hybrid retrievalretrieval recall +20-30%Storage 2x(dense + sparse vectors);查詢 latency +50-100ms;中文需額外斷詞驗證中(合併規則 RRF 簡單)
Rerankingtop-k 排序正確率 +25-35%Cohere Rerank $0.001-0.005/query(per 1000 tokens);自架 BGE reranker 加 GPU 成本;latency +200-500ms低(cross-encoder 黑盒)
Parent expansion答案完整度 ↑;LLM 亂引用率 ↓Token 花費 5-10x(每個 chunk 從 100 字變 1000 字);latency +100-300ms(能追到 section)
Context compression雜訊 ↓;亂引用 ↓略增 reranker 呼叫($0.0001/query);latency +50-150ms中(要看 compressor 邏輯)
Citation assembly可驗證答案來源低(純 mapping);latency +10-50ms最高(直接綁回 source)

預算有限的取捨:先做 hybrid retrieval(找得到是基本盤)→ 再做 citation assembly(成本最低、可解釋性最高)→ 然後再做 reranking(品質提升明顯但有成本)→ 最後做 parent expansion(看 token 預算)。

另一個關鍵觀察:LLM 換成更強的 model 對答案品質的邊際效益越來越低,但 retrieval 強化每加一層都還有明顯邊際效益。當 LLM 已經是 GPT-4 等級,再花錢升級 model 帶來的答案品質提升 < 加一層 reranking 帶來的提升——預算有限時,錢花在 retrieval 比花在 LLM 划算。

這也解釋了為什麼這個專案從 V0 到 V3 都用同一個 LLM 設定,換了 4 次 retrieval 強化——retrieval 才是拉開差距的主戰場,LLM 換強一點的邊際效益太低。


Part 01 的互動 demo 跑的就是這個專案的 production pipeline——你可以直接拿那個 early termination 查詢去問、看 trace 怎麼流。Part 07 拆「production 跟 demo 的差距」——faithfulness check、citation check、offline eval、Langfuse tracing、cost tracking,這 4 層 retrieval 強化加上去後,怎麼量化它們實際拉高多少答案品質,是 Part 07 的核心問題。rag/retrieval.pyrag/compression.pyrag/citations.py 這幾個模組就是 Part 06 這 4 層強化的具體落實。