一、问题背景:为什么61%的命中率无法接受

我们团队在做一个面向企业内部文档的智能问答系统,文档类型包括产品手册、技术方案、会议纪要等,总量约2万+文本块。初版系统上线后,业务方反馈“答非所问”的情况频繁出现。我们排查后发现,问题集中在两个环节:

  1. 召回阶段:chunk切分不合理导致语义碎片化。比如一段关于“支付超时处理流程”的文本,被硬生生切成了两半,导致检索时上下文信息丢失。
  2. 排序阶段:单纯依赖向量相似度排序,噪声太大。有些语义相近但实际不相关的文本块排到了前面。

当时线上指标:Hit Rate@5(Top-5内包含正确答案的比例)仅为61%,MRR(平均倒数排名)为0.47。这个数据意味着近四成的问题,系统根本找不到相关文档块。

二、环境与版本:技术栈一览

  • Python 3.10
  • LlamaIndex 0.9.3
  • ChromaDB 0.4.15
  • Embedding模型:moka-ai/m3e-base(初版)→ BAAI/bge-large-zh-v1.5(优化后)
  • Rerank模型:BAAI/bge-reranker-base
  • LLM:Qwen-14B-Chat(部署在本地V100上,vLLM推理)

说明一下,我们最初选m3e-base是因为它体积小、推理快,但后来发现它对长文本的语义捕捉能力一般。

三、方案设计:三管齐下

3.1 Chunk策略调整:从固定长度到语义段落

初版使用的是固定256个字符的chunk,重叠50个字符。这种方式对于中文这种无空格分隔的语言来说,极易切断语义。我们改用了递归字符文本分割器,优先按段落(\n\n)切分,其次按句子(。!?)切分,最后按逗号切分。

关键参数配置:

from llama_index.node_parser import HierarchicalNodeParser

parser = HierarchicalNodeParser.from_defaults(
    chunk_sizes=[512, 256, 128],  # 三级层级:父块512,子块256,孙块128
    chunk_overlap=50,
    separators=["\n\n", "。", "!", "?", ";", ",", " ", ""]
)

这里用了层级节点解析器,父块保持完整段落,子块用于检索,孙块用于精细化定位。这样做的好处是:检索到子块后,可以回溯到父块,拿完整的上下文喂给LLM

3.2 Embedding模型切换:m3e → bge-large-zh

m3e-base的向量维度是768,bge-large-zh-v1.5是1024。后者在中文语义匹配上明显更强,特别是对于相似但不同义的句子区分度更高。

切换后的对比实验(在1000条标注测试集上):

模型 Hit Rate@5 MRR@5 隐式向量维度
m3e-base 61% 0.47 768
bge-large-zh-v1.5 78% 0.65 1024

注意,bge-large-zh-v1.5在使用时需要给query加上指令前缀"为这个句子生成表示以用于检索相关文章:",否则效果会打折扣。

加载方式:

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
query_instruction = "为这个句子生成表示以用于检索相关文章:"
query_emb = model.encode(query_instruction + query, normalize_embeddings=True)

3.3 引入Rerank:双阶段排序

单纯靠向量相似度排序,噪声太大。我们引入了bge-reranker-base做精排,将向量检索返回的Top-50结果重排为Top-5。

Rerank的核心逻辑:

from FlagEmbedding import FlagReranker

reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True)

def rerank(query, candidates, top_k=5):
    pairs = [[query, doc] for doc in candidates]
    scores = reranker.compute_score(pairs, normalize=True)
    sorted_results = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
    return [doc for doc, score in sorted_results[:top_k]]

注意,FlagRerankercompute_score支持批量计算,但需要一次性传入所有pairs。如果候选数量太大(比如超过100),建议分批处理,否则显存容易爆。

四、核心实现:完整检索流程

下面是我们最终落地的检索Pipeline代码(简化版):

import chromadb
from llama_index.embeddings import HuggingFaceEmbedding
from llama_index.vector_stores import ChromaVectorStore
from llama_index.schema import NodeWithScore

class RAGRetriever:
    def __init__(self):
        self.embed_model = HuggingFaceEmbedding(
            model_name="BAAI/bge-large-zh-v1.5",
            query_instruction="为这个句子生成表示以用于检索相关文章:"
        )
        self.chroma_client = chromadb.PersistentClient(path="./chroma_db")
        self.collection = self.chroma_client.get_collection("docs_v2")
        self.reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True)

    def retrieve(self, query, top_k=5):
        # Step 1: 向量召回 Top-50
        query_emb = self.embed_model.get_query_embedding(query)
        results = self.collection.query(
            query_embeddings=[query_emb],
            n_results=50,
            include=["documents", "metadatas"]
        )
        candidates = results["documents"][0]

        # Step 2: Rerank 精排取 Top-5
        pairs = [[query, doc] for doc in candidates]
        scores = self.reranker.compute_score(pairs, normalize=True)
        sorted_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]

        final_docs = [candidates[i] for i in sorted_idx]
        return final_docs

五、踩坑与优化:那些文档没说的事

5.1 层级chunk的“父块回溯”问题

HierarchicalNodeParser后,检索返回的是子块节点。但如果你只把子块喂给LLM,上下文信息依然不完整。我们最初忘了做父块回溯,导致Hit Rate反而下降了3个百分点。后来通过parent_id查询父块,拼接到子块内容后面:

def get_full_context(node_id):
    node = index.docstore.get_node(node_id)
    parent_node = index.docstore.get_node(node.parent_node.node_id)
    return parent_node.text + "\n---\n" + node.text

5.2 Rerank模型的显存优化

bge-reranker-base虽然只有278M参数,但批处理时显存占用不小。我们一开始一次性传入50对pairs,结果V100(16G)直接OOM。后来改成每次10对,循环处理。另外,use_fp16=True能省一半显存,但需要GPU支持半精度计算。

5.3 ChromaDB的元数据过滤

我们文档都有来源、日期等元数据。在检索时加上元数据过滤能显著提升精度。比如只检索某个部门近一年的文档:

results = self.collection.query(
    query_embeddings=[query_emb],
    n_results=50,
    where={"$and": [{"dept": "研发部"}, {"date": {"$gte": "2024-01-01"}}]},
    include=["documents", "metadatas"]
)

六、效果数据:最终指标对比

经过三轮优化后,在相同的1000条测试集上重新评测:

版本 Hit Rate@5 MRR@5 平均响应时间
初版(m3e + 固定chunk) 61% 0.47 1.2s
+ bge-large-zh 78% 0.65 1.5s
+ 语义chunk 83% 0.72 1.6s
+ rerank 89% 0.82 2.1s

响应时间从1.2s增加到2.1s,主要是因为rerank阶段需要额外推理。但用户侧感知到的“废话率”大幅下降,业务方表示可以接受。

七、总结与反思

这次优化的核心结论:

  1. Embedding模型的选择比chunk策略影响更大。从m3e到bge-large-zh,Hit Rate直接提升了17个百分点。如果预算允许,直接用bge-m3gte-large-zh效果会更好。
  2. Rerank是性价比最高的优化手段。额外的0.5s延迟换来了6%的命中率提升,值得。
  3. 层级chunk比固定长度chunk更适合中文文档。但注意父块回溯,否则子块上下文不全。
  4. 不要迷信单一指标。Hit Rate提升可能只是把简单问题答对了,要结合MRR看排序质量,最好再人工抽检几个典型case。

最后提一句,RAG优化是系统工程,数据清洗和索引质量同样重要。我们后来发现,文档里大量扫描件OCR错误导致的乱码,严重干扰了向量检索。如果你也遇到瓶颈,先检查数据质量,再调模型。


以上,纯手打,有问题评论区见。