RAG 进阶

基础

RAG 架构优化

Mermaid 图表

各组件职责速查:

组件职责常见问题
文档解析提取结构化文本表格/图片丢失、乱码
Chunking切割成适当大小片段语义截断、Chunk 过长/过短
Embedding语义向量化领域偏差、多语言质量差
向量检索找出最相关 Chunks召回率低、噪声多
LLM 生成基于上下文回答幻觉、忽略上下文

文档解析与预处理

主流解析库对比

文档解析是 RAG 流水线的第一道关卡,解析质量直接决定最终结果。核心挑战在于:

工具定位优势劣势适用场景
unstructured通用文档解析支持格式最全(25+),社区活跃复杂 PDF 表格效果差多格式文档批量处理
doclingIBM 出品,PDF 专精表格结构保留好,Markdown 输出质量高速度较慢,内存占用高高质量 PDF 学术/报告
marker基于 Nougat 改进公式识别优秀,速度快对非英文支持弱学术论文、含公式文档
MinerU上海AI Lab 出品中文 PDF 最强,布局分析精准依赖重,部署复杂中文文档、复杂排版

表格解析

表格是文档中信息密度最高、也最难处理的结构。RAG 场景下表格的核心挑战:

表格序列化的主流策略:

  1. Markdown 表格:保留视觉结构,适合 LLM 理解(首选)
  2. 行展开法:每行转为"列名: 值"的描述句(表格超长时用)
  3. HTML 表格:保留合并单元格信息(精确保留结构)
  4. 自然语言描述:用 LLM 将表格转为段落(最贵但最易理解)

OCR

OCR(光学字符识别)质量是扫描件 PDF 解析的瓶颈。优化方向:

提示

解析工具选型决策树:

输入文档类型?
├── 中文复杂 PDF(报告/合同/书籍)→ MinerU(精度最高,需 GPU)
├── 多格式批量处理(PDF/Word/PPT/HTML)→ unstructured(strategy="hi_res")
├── 学术论文/含数学公式 → marker 或 docling
└── 扫描件/图片 → PaddleOCR(中文)/ Tesseract(多语言)

关键生产参数:

  • unstructured strategy: 开发用 "fast",生产用 "hi_res"(慢 5-10x,但表格/图片质量差距巨大)
  • PaddleOCR det_limit_side_len: 普通文档用 960,密集小字(如报表)用 1280
  • 解析并发:建议 min(cpu_count, 8) 进程并行,单进程内避免多线程(GIL + CUDA 冲突)
  • 解析缓存:以文件 MD5 为 key 缓存解析结果,避免重复解析(解析通常是流水线最慢环节,可节省 90%+ 时间)
  • 失败降级:解析超时设为 120s,hi_res 失败时降级到 fast 策略

智能分块策略


分块策略chunk_size 建议overlap 建议适用场景
固定大小256-512 tokens10%-20%快速原型、同质文本
递归字符512-1024 chars100-200 chars通用默认方案
语义分块动态(均值约 500)无需 overlap主题多样的长文档
命题分块~100 chars/命题N/A高精度知识库问答
层级分块叶 256 + 摘要动态0多粒度检索需求
Late Chunking与语义分块配合N/A上下文依赖强的文档

经验法则:

Embedding 模型选型与微调

Embedding 模型将文本映射到高维向量空间,决定语义相似度计算质量。选型核心考量:维度、最大输入长度、多语言支持、推理速度。

模型维度最大长度中文能力MTEB 排名推荐场景
BGE-M310248192★★★★★Top 5中文 RAG 首选,多语言,三模式
BGE-large-zh1024512★★★★★Top 10纯中文,速度快,资源少时用
E5-mistral-7b409632768★★★Top 3英文长文档
GTE-Qwen2-7B358432768★★★★★Top 3中英文长文档,质量最高
text-embedding-3-large30728192★★★★Top 10无 GPU 时用 API

BGE-M3 的三种检索模式(核心差异化特性):

混合检索

BM25 与 Dense 检索的互补性:

BM25Dense
优势精确关键词匹配,专有名词/缩写效果好,可解释语义理解,同义词泛化,多语言
劣势无语义理解,词汇不匹配即 0 分对低频词/新术语泛化差
适合产品型号、人名、术语概念查询、意图理解

混合检索通过 RRF(Reciprocal Rank Fusion) 融合两路结果,公式:

RRF(d)=∑r∈R1k+rankr(d)\text{RRF}(d) = \sum_{r \in R} \frac{1}{k + \text{rank}_r(d)}

提示

符号含义
dd某个文档
RR所有检索路数的集合(如 BM25、向量检索两路)
rankr(d)\text{rank}_r(d)文档 dd 在第 rr 路检索结果中的排名(从 1 开始)
kk平滑常数,通常取 60

假设文档 dd 在两路检索中的排名:

  • BM25 排名第 3
  • 向量检索排名第 1

取 k=60k = 60:

RRF(d)=160+3+160+1=163+161≈0.01587+0.01639=0.03226\text{RRF}(d) = \frac{1}{60 + 3} + \frac{1}{60 + 1} = \frac{1}{63} + \frac{1}{61} \approx 0.01587 + 0.01639 = 0.03226

最终按 RRF 分数降序排列,取 Top-K 作为混合检索结果。

  • BM25/Dense 权重:业务初期建议 BM25:Dense = 3:7;可通过 A/B 测试在业务数据上调优
  • RRF k=60:论文推荐值,通常无需调整(调整收益极小)

Query 优化技术

Query Rewriting(查询改写)

用户原始 query 常存在问题:

改写策略:用 LLM 将原始 query 扩展/规范化,增加同义词,明确歧义。

示例
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI

REWRITE_PROMPT = """你是一个搜索查询优化专家。请将用户的原始问题改写为更适合文档检索的查询。

改写要求:
1. 保留原始问题的核心意图
2. 展开缩写和口语化表达
3. 添加可能相关的同义词/近义词
4. 如果问题有歧义,选择最可能的解释
5. 输出 1-3 个改写版本,每行一个

原始问题:{query}

改写版本:"""

def rewrite_query(query: str, llm_model: str = "gpt-4o-mini") -> list[str]:
    """LLM 查询改写,返回多个改写版本"""
    llm = ChatOpenAI(model=llm_model, temperature=0.3)
    prompt = ChatPromptTemplate.from_template(REWRITE_PROMPT)
    chain = prompt | llm
    result = chain.invoke({"query": query})
    return [q.strip() for q in result.content.strip().split("\n") if q.strip()]


def rewrite_and_retrieve(query: str, retriever, top_k: int = 5) -> list[dict]:
    """改写 + 检索 + 结果去重融合"""
    rewrites = rewrite_query(query)
    all_results = []
    seen_texts = set()

    # 对每个改写版本检索,去重融合
    for rewritten_query in [query] + rewrites:
        results = retriever.retrieve(rewritten_query, top_k=top_k)
        for r in results:
            text_key = r["text"][:100]
            if text_key not in seen_texts:
                seen_texts.add(text_key)
                all_results.append(r)

    return all_results[:top_k * 2]

HyDE(假设文档嵌入)

HyDE(Hypothetical Document Embeddings)核心思路:

  1. 用 LLM 根据 query 生成一篇假设性文档(LLM 认为能回答该 query 的文档片段)
  2. 对假设文档进行 Embedding(而非对 query 直接 Embedding)
  3. 用假设文档的 Embedding 去检索真实文档

为什么有效:query 和文档在语义空间分布不同(一问一答),假设文档与真实文档更接近(同为陈述型文本),缩小 query-doc 分布差距(asymmetric gap 问题)。

示例
HYDE_PROMPT = """请根据以下问题,生成一段可能包含该问题答案的文档片段。
注意:生成的是"可能的文档内容",而非直接回答问题。内容应具体、专业。

问题:{query}

文档片段(200字左右):"""

def hyde_search(
    query: str,
    retriever,
    embed_model,
    llm_model: str = "gpt-4o-mini",
    top_k: int = 5,
    n_hypothetical: int = 3,   # 生成多个假设文档取平均,减少 LLM 随机性影响
) -> list[dict]:
    """HyDE 检索:生成假设文档 → Embed → 检索"""
    import numpy as np

    llm = ChatOpenAI(model=llm_model, temperature=0.7)
    prompt = ChatPromptTemplate.from_template(HYDE_PROMPT)
    chain = prompt | llm

    # 生成多个假设文档
    hypothetical_docs = [chain.invoke({"query": query}).content for _ in range(n_hypothetical)]

    # 编码并取平均(平均向量比单个更稳定)
    hyp_embeddings = embed_model.encode(hypothetical_docs, normalize_embeddings=True)
    avg_embedding = hyp_embeddings.mean(axis=0)
    avg_embedding = avg_embedding / np.linalg.norm(avg_embedding)

    # 用平均 embedding 检索
    return retriever.retrieve_by_embedding(avg_embedding, top_k=top_k)

Multi-Query(多路查询)

对同一问题从不同角度生成多个 query,各自独立检索后对结果取并集(去重),提高召回率。

核心思路:用户的问题可能只命中了文档的某种表述方式,多角度查询增加命中概率。

示例
from langchain.retrievers.multi_query import MultiQueryRetriever

def multi_query_retrieval(
    query: str,
    vectorstore,
    top_k: int = 5,
    llm_model: str = "gpt-4o-mini",
) -> list[dict]:
    """
    多路查询检索(LangChain 原生支持)
    自动生成 3 个变体查询 + 原始查询,共 4 路检索
    """
    llm = ChatOpenAI(model=llm_model, temperature=0.7)

    retriever = MultiQueryRetriever.from_llm(
        retriever=vectorstore.as_retriever(search_kwargs={"k": top_k}),
        llm=llm,
        include_original=True,   # 包含原始 query 的检索结果
    )

    docs = retriever.invoke(query)

    # 去重(按文本内容)
    seen_texts = set()
    unique_docs = []
    for doc in docs:
        if doc.page_content not in seen_texts:
            seen_texts.add(doc.page_content)
            unique_docs.append({"text": doc.page_content, "metadata": doc.metadata})

    return unique_docs

Step-Back Prompting(后退提示)

Step-Back 思路:对于具体问题,先"退一步"提出更抽象的背景问题,同时检索背景知识和具体细节,融合后回答。

适用场景:需要理解原理才能回答细节的问题(如"为什么 HNSW 的 M=32 比 M=16 精度高"需要先理解 HNSW 原理)

示例
STEP_BACK_PROMPT = """你是一个专家,请将以下具体问题抽象为一个更宏观的背景问题。

具体问题:{question}

更宏观的背景问题(一句话):"""

def step_back_retrieval(
    query: str,
    retriever,
    llm_model: str = "gpt-4o-mini",
    top_k: int = 5,
) -> dict:
    """Step-Back 检索:背景知识 + 具体细节双路检索"""
    llm = ChatOpenAI(model=llm_model, temperature=0)
    prompt = ChatPromptTemplate.from_template(STEP_BACK_PROMPT)
    chain = prompt | llm

    step_back_q = chain.invoke({"question": query}).content.strip()
    print(f"原始查询: {query}")
    print(f"后退查询: {step_back_q}")

    specific_results = retriever.retrieve(query, top_k=top_k)
    background_results = retriever.retrieve(step_back_q, top_k=top_k)

    # 融合去重(具体结果优先)
    seen = set()
    unique_results = []
    for r in specific_results + background_results:
        text_key = r["text"][:100]
        if text_key not in seen:
            seen.add(text_key)
            unique_results.append(r)

    return {
        "step_back_query": step_back_q,
        "results": unique_results[:top_k],
    }

生产环境注意事项

技术延迟增加效果提升成本增加推荐场景
Query Rewriting+100-300ms★★★低(gpt-mini)默认开启
HyDE+300-500ms★★★★中专业领域知识库
Multi-Query+500-1000ms★★★★中-高高召回需求(客服、搜索)
Step-Back+300-500ms★★★中需要背景知识辅助时

Re-ranking 重排序

两阶段检索架构

检索分两阶段,分别优化速度和精度:

阶段一(召回):Bi-Encoder 快速检索 top-100 阶段二(精排):Cross-Encoder 精细评分 top-100,取 top-5

Bi-Encoder:   Enc(query) · Enc(doc)  → 内积(快,O(1),但精度有限)
Cross-Encoder: Enc([query ⊕ doc])   → 联合编码(慢,但 query-doc 交叉注意力,精度高)

为什么 Cross-Encoder 更准确:Bi-Encoder 独立编码,无法看到 query 和 doc 的交互;Cross-Encoder 联合编码,注意力机制可以直接对比两者的词汇和语义关系。

Cross-Encoder Re-ranking

示例
from sentence_transformers import CrossEncoder
import time

class CrossEncoderReranker:
    def __init__(
        self,
        model_name: str = "BAAI/bge-reranker-v2-m3",
        device: str = "cuda",
        max_length: int = 512,  # 超长文档用 1024,但延迟翻倍
    ):
        self.model = CrossEncoder(model_name, max_length=max_length, device=device)

    def rerank(
        self,
        query: str,
        documents: list[str],
        top_k: int = 5,
        batch_size: int = 32,
    ) -> list[dict]:
        """Cross-Encoder 批量重排序"""
        if not documents:
            return []

        pairs = [(query, doc) for doc in documents]

        t0 = time.time()
        scores = self.model.predict(pairs, batch_size=batch_size, show_progress_bar=False)
        print(f"Reranked {len(documents)} docs in {(time.time()-t0)*1000:.1f}ms")

        ranked_results = sorted(zip(documents, scores), key=lambda x: x[1], reverse=True)

        return [
            {"text": doc, "rerank_score": float(score)}
            for doc, score in ranked_results[:top_k]
        ]

BGE-Reranker 系列对比与使用

# BGE Reranker 模型选型(按延迟从低到高):
# ┌─────────────────────────────┬────────┬──────────┬───────────────────────┐
# │ 模型                        │ 延迟   │ 精度     │ 推荐场景              │
# ├─────────────────────────────┼────────┼──────────┼───────────────────────┤
# │ bge-reranker-base           │ 最快   │ 一般     │ 延迟 < 100ms 场景    │
# │ bge-reranker-large          │ 中等   │ 较好     │ 平衡默认选择          │
# │ bge-reranker-v2-m3          │ 中等   │ 优秀     │ ★ 中英文均优,推荐   │
# │ bge-reranker-v2-gemma       │ 最慢   │ 最高     │ 离线精排场景          │
# └─────────────────────────────┴────────┴──────────┴───────────────────────┘

from FlagEmbedding import FlagReranker

def init_bge_reranker(model_name: str = "BAAI/bge-reranker-v2-m3") -> FlagReranker:
    return FlagReranker(model_name, use_fp16=True, device="cuda")


def bge_rerank(
    reranker: FlagReranker,
    query: str,
    documents: list[str],
    top_k: int = 5,
) -> list[dict]:
    """BGE Reranker 重排序(normalize=True 输出 0-1 分数)"""
    pairs = [[query, doc] for doc in documents]

    t0 = time.time()
    scores = reranker.compute_score(pairs, normalize=True)
    print(f"Reranked {len(documents)} docs: {(time.time()-t0)*1000:.1f}ms")

    ranked = sorted(zip(documents, scores), key=lambda x: x[1], reverse=True)
    return [{"text": doc, "score": float(score)} for doc, score in ranked[:top_k]]

ColBERT 多向量检索

ColBERT(Contextualized Late Interaction over BERT)兼顾精度和效率:

Score(q,d)=∑i=1∣q∣max⁡j=1∣d∣;qi⋅dj\text{Score}(q, d) = \sum_{i=1}^{|q|} \max_{j=1}^{|d|} ; \mathbf{q}_i \cdot\mathbf{d}_j

优势:接近 Cross-Encoder 精度,但延迟接近 Bi-Encoder;对长文档效果尤佳

from FlagEmbedding import BGEM3FlagModel
import numpy as np

def colbert_score(query_vecs: np.ndarray, doc_vecs: np.ndarray) -> float:
    """ColBERT MaxSim 评分"""
    sim_matrix = query_vecs @ doc_vecs.T  # (q_len, d_len)
    max_sims = sim_matrix.max(axis=1)     # 每个 query token 的最大相似度
    return float(max_sims.sum())


def colbert_rerank(
    model: BGEM3FlagModel,
    query: str,
    documents: list[str],
    top_k: int = 5,
    batch_size: int = 8,
) -> list[dict]:
    """使用 BGE-M3 的 ColBERT 向量进行重排序"""
    query_output = model.encode(
        [query], return_dense=False, return_sparse=False, return_colbert_vecs=True
    )
    query_vec = query_output["colbert_vecs"][0]  # (q_len, 1024)

    scores = []
    for i in range(0, len(documents), batch_size):
        batch = documents[i:i + batch_size]
        doc_output = model.encode(
            batch, return_dense=False, return_sparse=False, return_colbert_vecs=True
        )
        for doc_vec in doc_output["colbert_vecs"]:
            scores.append(colbert_score(query_vec, doc_vec))

    ranked = sorted(zip(documents, scores), key=lambda x: x[1], reverse=True)
    return [{"text": doc, "colbert_score": float(score)} for doc, score in ranked[:top_k]]

完整两阶段检索流水线

import time

class TwoStageRetriever:
    """生产级两阶段检索:混合召回 → Cross-Encoder 精排"""

    def __init__(
        self,
        retriever: HybridRetriever,
        reranker: CrossEncoderReranker,
        recall_top_k: int = 50,   # 第一阶段召回数(建议 final_top_k 的 10x)
        final_top_k: int = 5,     # 最终返回数
    ):
        self.retriever = retriever
        self.reranker = reranker
        self.recall_top_k = recall_top_k
        self.final_top_k = final_top_k

    def retrieve(self, query: str) -> dict:
        """两阶段检索,附带延迟统计"""
        # 阶段一:快速召回
        t0 = time.time()
        recalled_docs = self.retriever.retrieve(query, top_k=self.recall_top_k)
        recall_latency = (time.time() - t0) * 1000

        # 阶段二:精细重排
        t1 = time.time()
        doc_texts = [d["text"] for d in recalled_docs]
        reranked = self.reranker.rerank(query, doc_texts, top_k=self.final_top_k)
        rerank_latency = (time.time() - t1) * 1000

        return {
            "results": reranked,
            "latency": {
                "recall_ms": round(recall_latency, 1),
                "rerank_ms": round(rerank_latency, 1),
                "total_ms": round(recall_latency + rerank_latency, 1),
            },
            "stats": {"recalled": len(recalled_docs), "returned": len(reranked)},
        }

延迟对比(参考值,A100 GPU,单条 query)

方法延迟(100 文档)精度 NDCG@10
Bi-Encoder only~10ms基线
+ BM25 混合检索~15ms+5-10%
+ Cross-Encoder rerank~150-300ms+15-25%
+ BGE-Reranker-v2-m3~200-400ms+15-25%
+ ColBERT (BGE-M3)~50-100ms+12-20%

生产环境注意事项


向量数据库生产优化

HNSW 参数调优

HNSW(Hierarchical Navigable Small World)是最主流的 ANN(近似最近邻)索引算法,在精度和速度上取得最优平衡。

三个关键参数:

参数作用阶段默认值调优建议
M建索引16低维(<256)用16,高维(>512)用32;越大精度越高,内存越多
ef_construction建索引100100(默认);高精度场景用200;只影响写入速度
ef_search查询10实时场景50-100,离线精排200+;直接影响检索延迟

内存估算(Qdrant HNSW):

1M 向量 × 1024维 × float16 = 2GB(向量)
+ M=16 的图连接 ≈ 0.5GB
INT8量化后向量部分 = 0.5GB
总计:约 1GB(INT8)~ 2.5GB(float16)
from qdrant_client import QdrantClient
from qdrant_client.models import (
    VectorParams, Distance, HnswConfigDiff,
    OptimizersConfigDiff, QuantizationConfig,
    ScalarQuantizationConfig, ScalarType,
)

def create_optimized_collection(
    client: QdrantClient,
    collection_name: str,
    dim: int = 1024,
    expected_count: int = 1_000_000,
) -> None:
    """创建生产级优化的向量集合(含量化、HNSW 调优)"""

    # 根据数据量动态调整参数
    m, ef_construct = (16, 100) if expected_count < 1_000_000 else (32, 200)

    client.create_collection(
        collection_name=collection_name,
        vectors_config=VectorParams(
            size=dim,
            distance=Distance.COSINE,
            hnsw_config=HnswConfigDiff(
                m=m,
                ef_construct=ef_construct,
                on_disk=False,     # 数据量 < 可用内存时 False(快)
                payload_m=16,
            ),
            quantization_config=QuantizationConfig(
                scalar=ScalarQuantizationConfig(
                    type=ScalarType.INT8,  # INT8 量化:内存减少 4x,速度提升 2-3x,精度损失 <2%
                    quantile=0.99,         # 截断 1% 异常值后量化(更精准)
                    always_ram=True,       # 量化后的向量常驻内存
                )
            ),
        ),
        optimizers_config=OptimizersConfigDiff(
            indexing_threshold=20000,    # 累积此数量后才建 HNSW(少量数据用暴力搜索更准)
            memmap_threshold=1_000_000,  # 超过此量使用 memmap(内存映射)
            flush_interval_sec=5,        # 每 5 秒刷盘(减少数据丢失风险)
            max_segment_size=200_000,    # 每个 segment 最大条数(影响合并频率)
        ),
        shard_number=2,          # 分片数(单节点也可用多分片提升并发)
        replication_factor=2,    # 副本数(生产至少 1 副本保证高可用)
    )

    # 设置实时查询的 ef_search
    client.update_collection(
        collection_name=collection_name,
        hnsw_config=HnswConfigDiff(ef=100),  # 实时 100,离线精排可调到 200
    )


def benchmark_ef_search(
    client: QdrantClient,
    collection_name: str,
    test_vectors: list[list[float]],
    ef_values: list[int] = [25, 50, 100, 200],
) -> list[dict]:
    """压测不同 ef_search 的延迟,为生产参数选择提供数据依据"""
    import numpy as np

    results = []
    for ef in ef_values:
        client.update_collection(
            collection_name=collection_name,
            hnsw_config=HnswConfigDiff(ef=ef),
        )

        latencies = []
        for vec in test_vectors[:100]:
            t0 = time.time()
            client.search(collection_name=collection_name, query_vector=vec, limit=10)
            latencies.append((time.time() - t0) * 1000)

        results.append({
            "ef": ef,
            "p50_ms": round(np.percentile(latencies, 50), 2),
            "p99_ms": round(np.percentile(latencies, 99), 2),
        })
        r = results[-1]
        print(f"ef={ef:3d}: p50={r['p50_ms']:.1f}ms, p99={r['p99_ms']:.1f}ms")

    return results

Metadata Filter(元数据过滤)

在向量检索时结合元数据 pre-filtering,实现精准召回。典型场景:

⚠️ 关键:必须先为过滤字段创建 Payload 索引,否则会触发全量扫描,延迟从毫秒级退化到秒级!

示例
from qdrant_client.models import Filter, FieldCondition, MatchValue, Range
from datetime import datetime

def search_with_filters(
    client: QdrantClient,
    collection_name: str,
    query_vector: list[float],
    department: str = None,
    doc_type: str = None,
    date_after: datetime = None,
    top_k: int = 10,
) -> list[dict]:
    """带元数据过滤的向量检索"""
    conditions = []

    if department:
        conditions.append(
            FieldCondition(key="metadata.department", match=MatchValue(value=department))
        )
    if doc_type:
        conditions.append(
            FieldCondition(key="metadata.doc_type", match=MatchValue(value=doc_type))
        )
    if date_after:
        conditions.append(
            FieldCondition(
                key="metadata.created_at",
                range=Range(gte=date_after.timestamp())
            )
        )

    search_filter = Filter(must=conditions) if conditions else None

    results = client.search(
        collection_name=collection_name,
        query_vector=query_vector,
        query_filter=search_filter,
        limit=top_k,
        with_payload=True,
        search_params={
            "hnsw_ef": 100,   # 查询时动态设置 ef(可按场景调整)
            "exact": False,   # False=HNSW近似检索(快);True=暴力搜索(超慢但精确)
        },
    )

    return [{"text": r.payload["text"], "score": r.score} for r in results]


def create_payload_indexes(client: QdrantClient, collection_name: str) -> None:
    """
    为过滤字段创建 Payload 索引(必须!)
    未建索引字段过滤 = 全量扫描 = 性能灾难
    """
    # Keyword 索引(用于精确匹配:department、doc_type、status)
    for field in ["metadata.department", "metadata.doc_type", "metadata.status"]:
        client.create_payload_index(
            collection_name=collection_name,
            field_name=field,
            field_schema="keyword",
        )

    # Float 索引(用于范围查询:时间戳、分数)
    client.create_payload_index(
        collection_name=collection_name,
        field_name="metadata.created_at",
        field_schema="float",
    )

    print("✅ Payload 索引创建完成")

增量更新策略

生产环境文档库是动态变化的,需要高效处理:

示例
from qdrant_client.models import PointStruct, FilterSelector
import hashlib
import uuid

class VectorStoreManager:
    """生产级向量存储管理器,支持完整增量更新生命周期"""

    def __init__(self, client: QdrantClient, collection_name: str, embed_model):
        self.client = client
        self.collection_name = collection_name
        self.embed_model = embed_model

    def _doc_id_to_uuid(self, doc_id: str) -> str:
        """将业务 doc_id 确定性映射为 UUID(Qdrant 要求)"""
        return str(uuid.UUID(bytes=hashlib.md5(doc_id.encode()).digest()))

    def upsert_documents(self, documents: list[dict], batch_size: int = 100) -> None:
        """
        批量 Upsert 文档(存在则更新,不存在则插入)
        documents: [{"doc_id": str, "text": str, "metadata": dict}]
        """
        for i in range(0, len(documents), batch_size):
            batch = documents[i:i + batch_size]
            texts = [d["text"] for d in batch]

            # 批量 Embedding(比逐条快 10x+)
            embeddings = self.embed_model.encode(texts, batch_size=32, normalize_embeddings=True)

            points = [
                PointStruct(
                    id=self._doc_id_to_uuid(doc["doc_id"]),
                    vector=emb.tolist(),
                    payload={
                        "text": doc["text"],
                        "doc_id": doc["doc_id"],
                        "metadata": doc.get("metadata", {}),
                    }
                )
                for doc, emb in zip(batch, embeddings)
            ]

            # wait=False 异步写入(提高吞吐量);重要数据用 wait=True
            self.client.upsert(
                collection_name=self.collection_name,
                points=points,
                wait=False,
            )

        print(f"✅ Upserted {len(documents)} documents")

    def delete_documents(self, doc_ids: list[str]) -> None:
        """按 doc_id 批量删除"""
        point_ids = [self._doc_id_to_uuid(doc_id) for doc_id in doc_ids]
        self.client.delete(
            collection_name=self.collection_name,
            points_selector=point_ids,
            wait=True,
        )

    def delete_by_metadata(self, field: str, value: str) -> None:
        """
        按元数据批量删除(最常用:删除某文件的所有 chunks)
        例:delete_by_metadata("source_file", "report_2024.pdf")
        """
        self.client.delete(
            collection_name=self.collection_name,
            points_selector=FilterSelector(
                filter=Filter(
                    must=[FieldCondition(key=f"metadata.{field}", match=MatchValue(value=value))]
                )
            ),
            wait=True,
        )

    def incremental_update(
        self,
        new_documents: list[dict],
        updated_documents: list[dict],
        deleted_doc_ids: list[str],
    ) -> dict:
        """
        增量更新:删除 → 更新 → 新增(顺序执行保证一致性)
        """
        stats = {"deleted": 0, "updated": 0, "inserted": 0}

        if deleted_doc_ids:
            self.delete_documents(deleted_doc_ids)
            stats["deleted"] = len(deleted_doc_ids)

        if updated_documents:
            # 先删旧版,再插新版(避免脏读)
            old_ids = [d["doc_id"] for d in updated_documents]
            self.delete_documents(old_ids)
            self.upsert_documents(updated_documents)
            stats["updated"] = len(updated_documents)

        if new_documents:
            self.upsert_documents(new_documents)
            stats["inserted"] = len(new_documents)

        return stats

分片与集群策略

分片策略决策:
- 数据量 < 5M 向量(~5GB @ 1024dim float16): 单节点,shard_number=1-2
- 5M - 50M 向量:单/多节点,shard_number=3-5
- > 50M 向量:多节点集群,每节点 2-3 分片,节点数 = ceil(总分片数/3)
def create_sharded_collection(
    client: QdrantClient,
    collection_name: str,
    dim: int,
    n_shards: int = 3,
    n_replicas: int = 2,
) -> None:
    """创建分片集合(适用于多节点集群部署)"""
    client.create_collection(
        collection_name=collection_name,
        vectors_config=VectorParams(size=dim, distance=Distance.COSINE),
        shard_number=n_shards,             # 分片均匀分布到集群所有节点
        replication_factor=n_replicas,     # 副本数(高可用保障)
        write_consistency_factor=1,        # 写一致性=1(最终一致性,写入更快)
    )


def get_collection_health(client: QdrantClient, collection_name: str) -> dict:
    """获取集合健康状态(用于监控告警)"""
    info = client.get_collection(collection_name)
    total = info.vectors_count or 0
    indexed = info.indexed_vectors_count or 0

    return {
        "vectors_total": total,
        "vectors_indexed": indexed,
        "index_ratio": indexed / total if total > 0 else 0,  # <0.9 时告警
        "segments_count": info.segments_count,
        "status": info.status,
        "optimizer_status": str(info.optimizer_status),
    }

生产环境注意事项汇总

参数推荐值说明
HNSW M16(<1M条),32(>1M条)M 越大:精度↑,内存↑,建索引速度↓
ef_construction100(默认),200(高精度)只影响写入质量,不影响查询延迟
ef_search50-100(实时),200+(离线)直接影响查询延迟,调优必测
INT8 量化强烈推荐开启内存节省 4x,速度提升 2-3x,精度损失 <2%
indexing_threshold20000小于此数用暴力搜索(小数据集反而更精确)
write waitFalse(批量),True(重要)批量导入用 False,单条重要写入用 True
Payload 索引必须创建未索引字段过滤 = 全量扫描 = 延迟从 ms → s
分片数ceil(data_size_GB / 5)每分片建议 ~5GB 数据

监控告警建议(Qdrant):

评估指标

RAG 系统的评估需要覆盖 检索 和 生成 两个核心环节,形成完整的评估体系。

评估体系总览

Mermaid 图表
评估维度核心问题关键指标
检索质量检索到的内容是否正确、是否全面?Precision、Recall、Relevance
生成质量生成的答案是否忠于上下文、是否回答了问题?Faithfulness、Answer Relevance
端到端质量最终答案是否正确?Correctness、Similarity

检索质量评估

Context Precision(上下文精确率)

衡量检索到的上下文中,与问题相关的比例。即"检索到的内容里有多少是真正有用的"。

Context Precision=∣检索到的相关文档∣∣检索到的全部文档∣\text{Context Precision} = \frac{|\text{检索到的相关文档}|}{|\text{检索到的全部文档}|}

实际计算中,考虑排名位置的加权版本:

Context Precision@K=1K∑k=1KPrecision@k×rel(k)1\text{Context Precision@K} = \frac{1}{K} \sum_{k=1}^{K} \frac{\text{Precision@k} \times \text{rel}(k)}{1}

其中 rel(k)\text{rel}(k) 表示第 kk 个文档是否相关(0 或 1),Precision@k\text{Precision@k} 表示前 kk 个文档中的精确率。

提示

指标低的原因:Embedding 模型语义理解不足、检索策略不当(如纯向量检索缺少关键词匹配)、Chunk 切分导致语义断裂。 优化方向:使用混合检索(向量 + BM25)、优化 Embedding 模型、调整 Top-K 参数、改进 Chunk 策略。

Context Recall(上下文召回率)

衡量所有相关文档中,被检索到的比例。即"该找到的内容是否都找到了"。

Context Recall=∣检索到的相关文档∣∣全部相关文档∣\text{Context Recall} = \frac{|\text{检索到的相关文档}|}{|\text{全部相关文档}|}

提示

指标低的原因:知识库覆盖不全、查询改写(Query Rewriting)缺失、Embedding 模型对某些领域语义不敏感、Top-K 设置过小。 优化方向:增加知识库覆盖、使用查询扩展(HyDE、Multi-Query)、增大 Top-K、使用多路召回。

Context Relevance(上下文相关性)

衡量检索到的上下文与用户问题的 整体相关程度,通常由 LLM 进行打分评判。

Context Relevance=1N∑i=1NLLM_Score(chunki,query)\text{Context Relevance} = \frac{1}{N} \sum_{i=1}^{N} \text{LLM\_Score}(\text{chunk}_i, \text{query})

提示

指标低的原因:用户查询意图模糊、Chunk 切分过大(包含大量无关信息)、缺乏查询意图理解。 优化方向:加入查询意图分类、优化 Chunk 大小与重叠、使用 Reranker 对结果重排序。

生成质量评估

Faithfulness(忠实度)

衡量生成的答案是否 忠于检索到的上下文,不包含上下文之外的"幻觉"信息。这是 RAG 最核心的指标。

Faithfulness=∣答案中可被上下文支撑的声明数∣∣答案中的全部声明数∣\text{Faithfulness} = \frac{|\text{答案中可被上下文支撑的声明数}|}{|\text{答案中的全部声明数}|}

评估流程:

  1. 将答案拆分为若干独立声明(Claims)
  2. 逐一检查每个声明是否能从上下文中找到支撑
  3. 计算有支撑的声明占比

示例:

  • 上下文:「公司成立于 2020 年,注册资本 500 万」
  • 答案:「公司成立于 2020 年,注册资本 500 万,位于北京」
  • 声明 1:公司成立于 2020 年 → ✅ 有支撑
  • 声明 2:注册资本 500 万 → ✅ 有支撑
  • 声明 3:位于北京 → ❌ 无支撑(幻觉)
  • Faithfulness = 2/3 ≈ 0.67

指标低的原因:LLM 幻觉严重、Prompt 未约束"仅基于上下文回答"、上下文信息不足导致模型自行补充。

优化方向:在 Prompt 中明确要求"仅基于提供的上下文回答,若信息不足则回答不知道"、使用更强的模型、增加上下文信息量。

Answer Relevance(答案相关性)

衡量生成的答案是否 真正回答了用户的问题,而非答非所问。

Answer Relevance=1N∑i=1Ncos⁡(LLM_gen_qi,original_q)\text{Answer Relevance} = \frac{1}{N} \sum_{i=1}^{N} \cos(\text{LLM\_gen\_q}_i, \text{original\_q})

评估方法:让 LLM 根据答案反向生成若干可能的问题,计算这些问题与原始问题的语义相似度。相似度越高,说明答案越切题。

提示

指标低的原因:查询理解不足、答案过于笼统或偏离主题、Prompt 模板设计不当。 优化方向:优化 Prompt 模板、加入查询意图识别、对答案进行相关性后处理。

端到端评估

Answer Correctness(答案正确性)

衡量最终答案与标准答案(Ground Truth)的 正确程度,需要标注数据支撑。

Answer Correctness=∣正确且相关的事实∣∣正确事实∣+∣错误事实∣+∣遗漏事实∣\text{Answer Correctness} = \frac{|\text{正确且相关的事实}|}{|\text{正确事实}| + |\text{错误事实}| + |\text{遗漏事实}|}

通常结合 LLM 评判和 F1 分数计算。

Answer Similarity(答案相似度)

衡量生成答案与标准答案的 语义相似度。

Answer Similarity=cos⁡(Embedding(generated),Embedding(ground_truth))\text{Answer Similarity} = \cos(\text{Embedding}(\text{generated}), \text{Embedding}(\text{ground\_truth}))

指标速查表

指标评估对象计算方式取值范围越高越好
Context Precision检索结果相关文档 / 检索文档[0, 1]✅
Context Recall检索结果检索到的相关 / 全部相关[0, 1]✅
Context Relevance检索结果LLM 打分[0, 1]✅
Faithfulness生成答案有支撑的声明 / 全部声明[0, 1]✅
Answer Relevance生成答案反向问题相似度[0, 1]✅
Answer Correctness端到端F1 / LLM 评判[0, 1]✅
Answer Similarity端到端语义余弦相似度[0, 1]✅

综合优化策略

当各项指标偏低时,可按以下方向逐步优化:

Mermaid 图表

优化优先级建议:

  1. 先调检索,再调生成 — 检索是基础,检索不到正确信息,生成再好也无用
  2. 先调 Prompt,再换模型 — Prompt 优化成本最低,效果立竿见影
  3. 先加 Reranker,再改检索策略 — Reranker 是性价比最高的检索优化手段
  4. 建立评估-优化闭环 — 定期评估 → 发现瓶颈 → 针对优化 → 再次评估

评估框架

框架语言核心特点适用场景
RAGASPython业界标准,指标全面,支持 LLM-as-Judge通用 RAG 评估
TruLensPython可观测性强,支持实时监控和反馈生产环境监控
DeepEvalPython类 Pytest 风格,易于集成 CI/CD自动化测试
LangSmithPython/TSLangChain 官方平台,可视化强LangChain 生态
Phoenix (Arize)Python可观测性 + 追踪,支持 OpenTelemetry调试与分析
rag-evalPythonNVIDIA 出品,支持多种检索器大规模评估

RAGAS 快速上手

pip install ragas
from ragas import evaluate
from ragas.metrics import (
    context_precision,
    context_recall,
    faithfulness,
    answer_relevancy,
)
from datasets import Dataset

# 准备评估数据
eval_data = Dataset.from_dict({
    "question": ["公司的退货政策是什么?"],
    "answer": ["7天无理由退货,需保持商品完好。"],
    "contexts": [["本公司支持7天无理由退货政策..."]],
    "ground_truth": ["7天无理由退货,商品需保持完好。"],
})

# 运行评估
result = evaluate(
    eval_data,
    metrics=[context_precision, context_recall, faithfulness, answer_relevancy],
)
print(result)
# {'context_precision': 0.95, 'context_recall': 0.90, 'faithfulness': 1.0, 'answer_relevancy': 0.92}

DeepEval 快速上手

pip install deepeval
from deepeval import assert_test
from deepeval.metrics import FaithfulnessMetric, AnswerRelevancyMetric
from deepeval.test_case import LLMTestCase

test_case = LLMTestCase(
    input="公司的退货政策是什么?",
    actual_output="7天无理由退货,需保持商品完好。",
    retrieval_context=["本公司支持7天无理由退货政策..."],
)

# 类 Pytest 风格断言
assert_test(test_case, [FaithfulnessMetric(threshold=0.8)])

自动化评估流程

将评估集成到 RAG 系统的开发和运维流程中:

Mermaid 图表

评估数据集构建要点: