首页 / 文章

RAG在实际应用中失败的原因:分块处理、嵌入模型、检索机制以及评估问题。

为何演示结束后RAG在实际应用中会失效:分块边界问题、嵌入模型存在的盲区、Top-K限制、过时数据以及团队跳过的评估步骤。

发布者:REACTAPP.TOP阅读约 10 分钟2199 词

来源: RAG Without the Hype: What Retrieval-Augmented Generation Actually Takes to Work in Production

标准的RAG教程流程十分简单直接:加载PDF并将其分割成多个片段,将这些片段嵌入并存储在向量数据库中,再将用户问题进行嵌入处理,与最接近的片段匹配后拼接成提示语。最终得到的答案十分准确,让人觉得最艰难的部分已经完成。

但在实际应用中情况并非如此。问题往往涉及多份文档,相关上下文会在片段边界处被截断。检索结果虽然是在语义空间上与查询最接近的段落,但上下文却有误,用词正确却含义错误。模型随后会基于这些过时、矛盾或缺少关键信息的材料自信地给出答案,而这些关键信息其实就在片段边界三句话之外。最糟糕的是,输出结果听起来似乎正确,却没有任何迹象表明检索过程出现了问题。

构建一个RAG演示版本很容易,但要打造一个能在真实语料库和真实用户环境中稳定运行的版本则并非如此。这一差距源于三个方面:文档的分块方式、嵌入模型对细微差别的处理方式,以及检索系统判断相关性的标准。每个方面都存在容易被忽视的故障模式,且在系统上线后调试起来成本高昂。

分块——信息流失的源头

教程中的默认方法是采用固定大小并带有一定重叠度的递归字符分割方式。

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200
)
chunks = splitter.split_documents(documents)

这对演示来说已经足够了。但当文档结构包含字符计数所忽略的含义时,它会在三种常见情况下出错。

  • 上下文被分割。规范可能规定最大支持值为500,且该限制仅适用于启用了主节点故障转移配置的高可用模式。如果按1,000个字符进行分割,这个数值可能会落在同一块内容中,而相关条件却位于另一块中。此时有人询问高可用模式的限制,得到的却是仅标注500且没有附加说明的那块内容。虽然答案存在于整个文档中,但分割方式破坏了二者之间的关联。
  • 孤立的标题。诸如“安全配置”这样的标题及其下第一段内容“每次连接都必须使用TLS”,可能会被分割到不同的数据块中。即使找到了那段文字,也无法获取对应的标题上下文,而且句子的嵌入信息也不会表明它属于安全设置相关内容。
  • 损坏的表格与列表。固定长度的分割方式常常会在表格的行中间或列表的项中间切断内容。某个数据块中只有列标题,另一个数据块中只有对应数据,单独来看都无法理解其含义。

生产级的数据分块通常依赖三种技术。

基于结构的拆分会依据文档自身的结构边界进行分割,也就是标题、段落和章节的位置,而非字符数。Markdown文档可以在二级或三级标题处拆分,而HTML文档则可以在标题、段落或章节标签处拆分。

from langchain.text_splitter import MarkdownHeaderTextSplitter

headers_to_split_on = [
    ("#", "document_title"),
    ("##", "section"),
    ("###", "subsection"),
]

splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
chunks = splitter.split_text(markdown_document)
# Each chunk now includes header metadata: {"section": "Security Configuration"}

父上下文增强会为每个数据块存储指向它的标题路径。当检索到该数据块时,提示词会同时获得文本旁边的章节及子章节标题,从而为模型提供嵌入空间所不具备的结构信息。

父子级分块则采用较小的数据块以提高检索精度,同时保留包含这些小数据块的较大父数据块。检索时会匹配到较小的数据块,而提示词则会收到对应的父数据块。较小的数据块能更精准地嵌入特定概念,而父数据块则提供周围的背景信息。

# Small child chunk for retrieval
child_chunk = "The maximum supported value is 500."

# Parent chunk injected into prompt when child is retrieved
parent_chunk = """
## High Availability Configuration
When operating in high-availability mode with primary failover enabled,
the following limits apply:
- Maximum supported value: 500
- Minimum heartbeat interval: 100ms
- Failover timeout: 30 seconds
"""

这种方法的实施和存储成本更高,但对于那些需要上下文信息的查询——而大多数实际问题都属于此类——它能给出更好的答案。

嵌入向量与相似性的局限

嵌入向量将文本置于向量空间中,相似的含义会彼此靠近,检索系统则会返回与查询最接近的文本片段。只要语义上的接近性能够合理代表相关性,这种方法就有效;但一旦两者出现差异,该方法就会失效。

  • 否定表达。那些说明系统不支持IPv6和说明系统支持IPv6的句子会得到几乎相同的向量,因为两者都在讨论IPv6支持情况。否定词几乎不会改变向量值。关于IPv6的查询可能会同时检索到这两个片段或错误的片段,模型则不得不在未察觉的情况下调和矛盾或依据错误的段落作答。
  • 词汇不一致。用户输入“login”,而文档中使用的是“authentication”和“sign-in”。通常这些词汇相近,检索仍能生效,但当用户的行话与文档中的用词不同时就会出现问题。
  • 具体性不足。关于如何配置连接池的疑问与关于默认池超时时间的疑问位置非常接近,因此两者会返回相同的内容片段。当超时值散布在多个关于连接池的段落中时,最佳的内容片段并不总是排在最前面。
  • 有三种解决方案可以解决这些限制。

    混合搜索将能够捕捉语义的密集检索与能够捕捉精确术语的稀疏检索(如BM25或TF-IDF)相结合。对于版本号、配置键或错误代码之类的查询,关键词匹配比语义相似度更能满足需求。如今许多向量数据库都已原生支持混合搜索,下方的示例展示了在Qdrant中如何结合使用稀疏检索与密集检索。

    from qdrant_client import QdrantClient
    from qdrant_client.models import SparseVector, NamedSparseVector
    
    # Many vector databases now support hybrid search natively
    # This example uses Qdrant's sparse + dense hybrid search
    results = client.query_points(
        collection_name="docs",
        prefetch=[
            models.Prefetch(
                query=sparse_query_vector,  # BM25 sparse vector
                using="sparse",
                limit=20,
            ),
            models.Prefetch(
                query=dense_query_vector,   # Embedding dense vector
                using="dense",
                limit=20,
            ),
        ],
        query=models.FusionQuery(fusion=models.Fusion.RRF),  # Reciprocal rank fusion
        limit=5,
    )
    

    查询扩展会在将问题嵌入之前对其进行丰富。关于认证的查询可以通过登录、注册、身份验证等词汇进行扩展,从而使其处于更丰富的语义空间中。通过调用小型大语言模型即可生成这些变体,或者使用静态同义词映射来覆盖领域专用术语。

    嵌入模型选择同样值得重视。许多团队会默认使用诸如text-embedding-ada-002或text-embedding-3-small这类通用的OpenAI模型。而对于医疗、法律或技术领域的文本语料,使用领域专用或经过微调的模型往往能取得显著更好的效果。即便文本分块处理得当但检索效果仍不佳时,通过具有代表性的查询样本对比不同模型会很有帮助。

    大规模应用中出现的检索问题

    分块与嵌入决定了内容是否能够被找到,而检索则决定了针对特定查询时该内容是否真的被找到。某些问题只有在大规模语料库或多样化的查询条件下才会显现。

    K值的选择。大多数系统会根据相似度选取排名靠前的K个分块,并将它们全部放入提示词中。若K值过小,当答案需要多个分块的支持时就会遗漏关键信息;而K值过大则会让上下文窗口充斥着无关紧要的分块,从而削弱有效信号,且在较长的提示词中还可能使真正相关的分块超出模型的有效注意力范围。

    实际采用的策略是先获取较多结果,比如前20或30个候选项,然后再进行重新排序。交叉编码器会将查询与分块内容结合在一起,直接计算相关性得分,而非比较两个独立生成的嵌入向量。由于分块嵌入无法预先计算,这种方式速度较慢,但它能更有效地区分真正相关的内容与几乎相关的内容。快速近似检索用于缩小候选范围,随后由交叉编码器选出最终结果集。

    from sentence_transformers import CrossEncoder
    
    reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
    # Retrieve broad candidate set
    candidates = vector_store.similarity_search(query, k=20)
    # Re-rank with cross-encoder
    pairs = [(query, chunk.page_content) for chunk in candidates]
    scores = reranker.predict(pairs)
    # Sort by re-ranking score, take top 5
    reranked = sorted(zip(scores, candidates), reverse=True)[:5]
    final_chunks = [chunk for _, chunk in reranked]
    

    过时问题。六个月前嵌入的向量存储中仍保存着已经发生变化的文档,而嵌入模型并不知情。检索时会高置信度地返回过时的内容,模型也会基于这些内容给出答案。解决办法是进行元数据过滤:为每个文档片段记录最后修改日期,在相似度搜索前后根据日期范围进行筛选。对于需要持续维护的文档,超过设定阈值的旧片段会被丢弃或排名降低。

    from datetime import datetime, timedelta
    
    # Filter to chunks updated in the last 90 days
    cutoff = datetime.now() - timedelta(days=90)
    
    results = vector_store.similarity_search_with_filter(
        query=query,
        filter={"last_modified": {"$gte": cutoff.isoformat()}},
        k=10
    )
    

    虚妄的自信。模型会基于其接收到的任何内容给出答案,即便检索到的片段与问题毫无关联。它并未意识到检索效果不佳。这种情况最会损害用户信任,因为答案看似权威实则错误。解决办法是在生成之前设置相关性过滤机制:要么对检索到的片段进行评分,当最佳评分低于阈值时抑制答案生成;要么在系统提示中告知模型其信息不足,随后检查模型是否正确使用了这一规避措施。

    SYSTEM_PROMPT = """
    You are a documentation assistant. Answer questions using only the provided context.
    
    If the context does not contain enough information to answer the question confidently,
    respond with: "I don't have sufficient information in the available documentation to
    answer this question reliably."
    
    Do not speculate or answer from general knowledge when documentation context is insufficient.
    """
    

    评估:团队常跳过的步骤

    没有评估机制的RAG系统其质量无从知晓。它可能在某些查询上表现良好,而在其他查询上表现糟糕,而唯一能发现问题的方式就是用户的投诉。评估包含三个部分。

    检索质量。对于那些已知正确来源文档的测试查询,需衡量正确文档在检索到的前K个结果中出现的频率。通过“5次询问召回率”来检测正确内容是否出现在前五个结果中,再通过“平均互反排名”来评估其位置高低。在系统上线前应先使用100到200个具有代表性的查询进行测试。

    答案准确性。生成的答案是否真实反映了检索到的上下文,而没有添加那些上下文中并不存在的内容?可以通过“LLM作为评判者”的方式来实现评估:输入查询、检索到的内容片段以及生成的答案,进而评定上下文对答案的支持程度。

    FAITHFULNESS_PROMPT = """
    Given the following context and answer, rate whether the answer is fully
    supported by the context on a scale of 1-5.
    
    Context: {context}
    Answer: {answer}
    
    Score (1=not supported, 5=fully supported):
    """
    
    # Automate across your evaluation set
    scores = []
    for query, context, answer in eval_set:
        score = judge_model.complete(
            FAITHFULNESS_PROMPT.format(context=context, answer=answer)
        )
        scores.append(score)
    
    print(f"Average faithfulness: {sum(scores)/len(scores):.2f}")
    

    端到端正确性。对于那些已有已知答案的查询,系统能否输出正确的结果?这是最难自动化处理却又最具意义的部分。即便手动准备50道答案已验证的题目,在每次更改分块方式、嵌入模型或检索设置前后运行这些题目,也能及时发现功能退化问题。

    该评估集起到了回归测试套件的作用。新的嵌入模型、不同的分块大小或经过修改的系统提示,都应带来可测量的分数变化。当某项更改提升了平均准确性却降低了检索召回率,或出现相反情况时,这种权衡应当是经过深思熟虑的决定,而非悄无声息的部署。

    整个流程本身就是产品

    教程流程包含四个步骤,且没有错误处理机制。生产流程则有六个步骤,每一步都会进行故障检测,全程会附加元数据,并在每次发布前进行评估。

    这些故障是可以预见的:忽略文档结构的分块处理会导致边缘部分的上下文丢失;仅基于嵌入向量进行检索会遗漏完全匹配的结果,且对否定句的处理效果很差;不进行重排的检索会返回质量较低的Top K结果,从而掩盖最佳的分块内容;没有相关性过滤机制的生成模型则会在上下文薄弱的情况下给出看似有依据但实际上错误的答案。

    这些问题都不是无法解决的,每一种解决方案都有其相应的成本:需要理解文档格式才能实现结构感知的分块处理;混合搜索意味着要同时使用密集索引和稀疏索引;重排会增加检索的延迟;而评估则需要构建并维护测试集。

    那些RAG系统表现良好的团队会在发布前就有意做出这些决策,而非因错误答案而临时应对。教程会跳过复杂部分,因为演示不需要它们,但一个可靠的系统则必须包含这些内容。

    相关阅读

  • 实用笔记:什么是检索增强生成(RAG)?实用指南 — 该实用指南的详细操作步骤:适用于采用此模式的团队的代码框架、检查清单及可直接使用的代码片段。
  • 实用笔记:5款无需数据丢失即可管理多个AI智能体的CLI工具 — 该实用笔记的详细操作步骤:适用于采用此模式的团队的代码框架、检查清单及可直接使用的代码片段。
  • 超越余弦相似度:五种RAG检索技术对比 — 通过代码详解余弦相似度、BM25、混合RRF、交叉编码器重排序和MMR算法,分析各技术的优缺点,并提供一种以查询为优先的思路来决定您的RAG系统应采用哪种检索技术。
  • 确保向量存储的准确性:生命周期管理、租户隔离、元数据处理及混合搜索 — 如何在生产环境中运行用于RAG的向量存储:包括文档生命周期管理、嵌入向量更新、租户过滤、混合搜索机制、元数据架构以及检索过程追踪。