← 返回博客
·AI技术

RAG 系统上线三个月后的 7 个教训

从实战角度总结 RAG 系统开发和优化过程中的 7 个关键教训,包括 Chunk 策略、Hybrid Search、Reranker 等

#RAG#检索增强生成#LangChain#向量检索

# RAG 系统上线三个月后的 7 个教训

我们团队的 RAG(检索增强生成)系统从去年 11 月开始上线,到现在跑了三个月。期间经历了三次大版本的迭代,从最开始的"能跑就行"到现在的"勉强能用在生产环境"。这些踩过的坑,值得记录下来,让后来人少走点弯路。

背景

我们的场景是给内部技术文档做问答系统。数据源包括:

  • Confluence 文档(约 5000 篇)
  • GitHub README 和 Wiki(约 1200 个仓库)
  • 内部知识库文章(约 800 篇)
  • 历史故障复盘文档(约 300 篇)
  • 技术方案:Python + LangChain + ChromaDB + OpenAI API(后来切换到自部署的 Mixtral 8x7B)。

    教训一:Chunk Size 不是越大越好

    最开始的版本,我们设置的 chunk_size 是 2000,chunk_overlap 是 200。理由是:"越大包含的信息越完整"。

    结果上线后用户反馈:"回答太啰嗦,而且经常偏离主题"。

    问题出在哪?我们用的 embedding 模型是 text-embedding-3-small,它的训练数据是按句子和段落组织的。当 chunk 太大时,一个 chunk 里可能包含多个主题,导致:

  • **检索时的相似度计算不准确**。比如用户问"如何配置 Redis 缓存",chunk 里虽然有答案,但也包含了"如何部署 Redis 集群",embedding 向量就会偏向"部署"而不是"配置"。
  • 2. **LLM 的上下文窗口被浪费**。如果检索出 5 个 chunk,每个 2000 tokens,光上下文就占了 10000 tokens,留给"真正相关内容"的空间反而小了。

    优化方案:

    # 最开始的版本

    text_splitter = RecursiveCharacterTextSplitter(

    chunk_size=2000,

    chunk_overlap=200

    )

    # 优化后的版本:根据文档类型动态调整

    def get_text_splitter(doc_type: str):

    if doc_type == 'api_doc':

    # API 文档按函数/类分割

    return RecursiveCharacterTextSplitter(

    chunk_size=800,

    chunk_overlap=100,

    separators=["\\n## ", "\\n### ", "\\n```", "\\n"]

    )

    elif doc_type == 'tutorial':

    # 教程类文档保持较大的 chunk

    return RecursiveCharacterTextSplitter(

    chunk_size=1200,

    chunk_overlap=150

    )

    else:

    # 默认配置

    return RecursiveCharacterTextSplitter(

    chunk_size=1000,

    chunk_overlap=100

    )

    做了这个优化后,检索准确率从 62% 提升到了 78%(人工评估 100 个 query)。

    教训二:Hybrid Search 真的有用,但要做好权重平衡

    最开始我们只用了向量检索(Dense Retrieval),后来加上了 BM25(Sparse Retrieval),做 Hybrid Search。

    实现方式:

    from langchain.retrievers import EnsembleRetriever

    from langchain_community.retrievers import BM25Retriever

    from langchain_community.vectorstores import Chroma

    # 向量检索器

    vectorstore = Chroma.from_documents(docs, embeddings)

    vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 10})

    # BM25 检索器

    bm25_retriever = BM25Retriever.from_documents(docs)

    bm25_retriever.k = 10

    # Hybrid Search

    ensemble_retriever = EnsembleRetriever(

    retrievers=[vector_retriever, bm25_retriever],

    weights=[0.5, 0.5] # 这里很重要!

    )

    **坑点:权重不是固定的**。

    我们最开始设置的是 [0.7, 0.3](向量检索权重更高),因为"语义理解更重要"。但实际测试发现:

  • 对于**技术术语查询**(比如"Redis AOF 和 RDB 的区别"),BM25 的效果更好,因为关键词匹配更准确。
  • 对于**自然语言问题**(比如"我的服务启动不了怎么办"),向量检索更好,因为能理解意图。
  • 解决方案是做**查询分类**,根据 query 的类型动态调整权重:

    def classify_query(query: str) -> str:

    # 简单规则:如果包含代码、命令、专业术语,认为是技术查询

    if re.search(r'{1,3}.*|sudo|apt|npm|git', query):

    return 'technical'

    elif len(query.split()) < 5:

    return 'keyword'

    else:

    return 'natural_language'

    def get_retriever_weights(query_type: str):

    if query_type == 'technical':

    return [0.3, 0.7] # BM25 权重更高

    elif query_type == 'keyword':

    return [0.4, 0.6]

    else:

    return [0.7, 0.3] # 向量检索权重更高

    这个优化让检索准确率又提升了 8%。

    教训三:Reranker 是性价比最高的优化

    在 Retrieval 和 Generation 之间加一个 Reranker,效果提升明显。

    我们用的中是 bge-reranker-large,部署在本地(用 Hugging Face Transformers)。

    from transformers import AutoModelForSequenceClassification, AutoTokenizer

    import torch

    class Reranker:

    def __init__(self, model_name='BAAI/bge-reranker-large'):

    self.tokenizer = AutoTokenizer.from_pretrained(model_name)

    self.model = AutoModelForSequenceClassification.from_pretrained(model_name)

    self.model.eval()

    def rerank(self, query: str, docs: List[str], top_k: int = 5) -> List[int]:

    pairs = [[query, doc] for doc in docs]

    with torch.no_grad():

    inputs = self.tokenizer(

    pairs,

    padding=True,

    truncation=True,

    max_length=512,

    return_tensors='pt'

    )

    scores = self.model(**inputs).logits.squeeze(-1)

    # 返回 top_k 的索引

    top_indices = torch.topk(scores, top_k).indices.tolist()

    return top_indices

    **效果**:检索阶段取 20 个 chunk,rerank 后取前 5 个给 LLM。这样既能保证召回率,又能提升 precision。

    **成本**:bge-reranker-large 在 CPU 上处理 20 个 chunk 大约需要 200ms,完全可以接受。

    教训四:Prompt 工程比换模型更有用

    我们最开始用的 prompt 是这样的(直接从 LangChain 的模板复制的):

    根据以下上下文回答问题:

    {context}

    问题:{question}

    这个 prompt 的问题:

  • 没有告诉 LLM "如果不知道就说不知道"(导致幻觉)
  • 2. 没有约束回答格式(用户想要列表,它给了段落)

    3. 没有利用历史对话(多轮问答体验差)

    优化后的 prompt:

    你是一个技术文档助手。根据提供的上下文回答问题。

    规则:

  • 只在上下文包含答案时回答,否则说"文档中没有相关信息"
  • 2. 回答要简洁,用列表格式(如果适用)

    3. 如果上下文包含代码示例,一定要包含在回答中

    4. 不要重复上下文,直接给答案

    上下文:

    {context}

    当前问题:{question}

    历史对话:

    {history}

    回答:

    这个优化让"拒绝回答"的准确率从 45% 提升到了 92%(之前经常瞎编)。

    教训五:文档预处理被严重低估了

    RAG 的很多问题描述是"检索不准",但根因是"文档质量差"。

    我们遇到的文档质量问题:

    1. 代码块没有做特殊处理

    最开始代码块和普通文本一样被切分,导致:

  • 代码被截断在关键位置(比如一个函数定义被切成两半)
  • 代码里的注释被当成了正文
  • 解决方案:切分前先提取代码块,单独处理:

    import re

    def extract_code_blocks(markdown_text: str) -> List[dict]:

    """提取 Markdown 中的代码块"""

    pattern = r'``(\\w+)?\\n(.*?)``'

    blocks = []

    for match in re.finditer(pattern, markdown_text, re.DOTALL):

    lang = match.group(1) or 'text'

    code = match.group(2)

    blocks.append({

    'lang': lang,

    'code': code,

    'start': match.start(),

    'end': match.end()

    })

    return blocks

    def split_with_code_awareness(text: str, splitter):

    """切分时保护代码块不被截断"""

    code_blocks = extract_code_blocks(text)

    # 如果代码块很短(< 50 行),把它和上下文放在一起

    # 如果很长,单独作为一个 chunk

    chunks = []

    for block in code_blocks:

    if block['code'].count('\\n') < 50:

    # 保留在原文位置

    pass

    else:

    # 单独处理

    chunks.append(block['code'])

    # 非代码部分正常切分

    non_code_text = remove_code_blocks(text)

    non_code_chunks = splitter.split_text(non_code_text)

    return chunks + non_code_chunks

    2. 图片和表格丢失

    Confluence 文档里有很多图片(架构图、流程图)和表格,直接用 API 拉取后会丢失。

    解决方案:

  • 图片:用 OCR 提取文字,或者用 GPT-4V 做图片描述(成本高,只对关键图片使用)
  • 表格:转成 Markdown 格式存储
  • def convert_table_to_markdown(html_table: str) -> str:

    """把 HTML 表格转成 Markdown"""

    from bs4 import BeautifulSoup

    soup = BeautifulSoup(html_table, 'html.parser')

    table = soup.find('table')

    # 提取表头

    headers = [th.get_text().strip() for th in table.find_all('th')]

    markdown = '| ' + ' | '.join(headers) + ' |\\n'

    markdown += '| ' + ' | '.join(['---'] * len(headers)) + ' |\\n'

    # 提取行

    for row in table.find_all('tr'):

    cells = [td.get_text().strip() for td in row.find_all('td')]

    if cells:

    markdown += '| ' + ' | '.join(cells) + ' |\\n'

    return markdown

    教训六:评估体系要从第一天开始建

    我们最开始评估 RAG 系统的方式是"跑几个 case 看看效果",这显然不行。

    后来搭建了一个评估 pipeline,核心指标:

  • **检索准确率**(Retrieval Accuracy):检索出的 chunk 是否包含答案
  • 2. **答案准确率**(Answer Accuracy):生成的答案是否正确

    3. **拒绝率**(Abstain Rate):对于无法回答的问题,系统是否拒绝回答

    4. **延迟**(Latency):端到端响应时间

    评估数据集:人工标注了 500 个 query,每个 query 标注:

  • 期望检索到的文档 ID
  • 期望答案(或"无法回答")
  • 问题类型(技术查询/操作指导/概念解释/无法回答)
  • def evaluate_retrieval(query: str, expected_doc_ids: List[str], retriever):

    retrieved_docs = retriever.get_relevant_documents(query)

    retrieved_ids = [doc.metadata['doc_id'] for doc in retrieved_docs]

    # Recall@5

    hits = len(set(expected_doc_ids) & set(retrieved_ids[:5]))

    recall = hits / len(expected_doc_ids)

    # MRR (Mean Reciprocal Rank)

    mrr = 0

    for i, doc_id in enumerate(retrieved_ids):

    if doc_id in expected_doc_ids:

    mrr = 1 / (i + 1)

    break

    return {'recall@5': recall, 'mrr': mrr}

    这个评估体系帮我们快速定位了问题。比如我们发现"概念解释"类问题的检索准确率只有 40%,原因是这类问题通常需要跨多个文档才能回答,而我们的 chunk 太小了。

    教训七:别忽视负向反馈数据

    用户每次点"这个回答没有帮助"时,我们都会记录下 query 和返回的 answer。这些数据是优化系统的金矿。

    我们做了一个简单的反馈 UI:

    # 在回答后面加上反馈按钮

    feedback_ui = """


    [👍 有帮助] [👎 没帮助]

    {如果用户点"没帮助",展开一个表单}

    请选择原因:

  • [ ] 检索的内容不相关
  • [ ] 答案不正确
  • [ ] 回答太啰嗦
  • [ ] 回答太简单
  • [ ] 其他:______
  • """

    收集到 200 多条负向反馈后,我们分析发现:

  • 40% 是因为"检索的内容不相关" → 优化 chunk_size 和 hybrid search
  • 25% 是因为"答案不正确" → 优化 prompt,加强拒绝回答的约束
  • 20% 是因为"回答太啰嗦" → 在 prompt 里加"回答要简洁"
  • 15% 是因为"回答太简单" → 对这类问题提高 temperature,鼓励详细回答
  • 总结

    RAG 系统不是"接个 LLM 就能用"的东西。它更像是一个需要持续优化的产品,而不是一个一次性的技术方案。

    如果让我重新开始,我会这样做:

  • **第一周**:搭建最简版本(向量检索 + GPT-4),跑通流程
  • 2. **第二周**:建评估体系,标注 200 个测试 case

    3. **第三周**:优化文档预处理和 chunk 策略

    4. **第四周**:加 hybrid search 和 reranker

    5. **后续**:根据负向反馈数据持续迭代

    别想着一步到位。RAG 系统的优化是个持续的过程,重要的是先上线,再快速迭代。