首页归档标签关于
← 返回首页

从零搭一个 RAG:把「检索」这件事做对

#AI#RAG#工程实践

很多人第一次做 RAG 的路径是:把 PDF 丢进切分函数 → 灌进向量库 → 相似度检索 → 拼进提示词。跑通 demo 只要半小时,但上线之后会发现,回答经常「看起来很流畅,其实答非所问」。问题几乎从来不在模型,而在检索链路。这篇把我在工程上真正会调的几个环节拆开讲。

一、先承认:RAG 是个检索系统

大语言模型负责的是「给定上下文,组织和表达答案」,它不会替你找到正确的上下文。所以评价体系也应该分成两层:

  • 检索层:正确的片段有没有进到 top-k 里?
  • 生成层:有了正确片段,答案有没有忠实于它、有没有引用来源?

把这两层混在一起调提示词,是最常见的浪费时间方式。先用检索命中率把第一段打稳,再动生成。

二、切分决定上限

按固定字符数硬切最省事,也最容易把一句话、一张表、一个代码块从中间劈开。更稳的做法是沿文档结构切:先按标题分层,再在段落边界合并到目标长度,并保留一小段重叠。

def split_by_heading(text, max_chars=800, overlap=80):
    """按 Markdown 标题分块,段落内不硬切。"""
    chunks, buf, heading = [], [], ''
    for line in text.splitlines():
        if line.startswith('#') and buf:
            chunks.append((heading, '\n'.join(buf).strip()))
            buf = []
        if line.startswith('#'):
            heading = line.lstrip('#').strip()
        buf.append(line)
        if sum(len(x) for x in buf) >= max_chars and not line.startswith('#'):
            body = '\n'.join(buf).strip()
            chunks.append((heading, body))
            buf = [body[-overlap:]] if overlap else []
    if buf:
        chunks.append((heading, '\n'.join(buf).strip()))
    return [(h, b) for h, b in chunks if b]

几个经验值可以当起点,但一定要用自己的文档验证:

策略适合的文档主要风险
固定长度 + 重叠纯文本、聊天记录切断语义单元
按标题/段落技术文档、手册章节长度差异大
语义切分长篇叙述计算成本、结果不稳定
父子块问答型知识库索引结构更复杂

父子块是我最常加的一层优化:用小片段去检索(匹配精准),命中后把所属的整个小节喂给模型(上下文完整)。实现上只是给每个 chunk 存一个 parent_id,成本很低,收益通常很明显。

三、别只信向量:混合检索

纯稠密向量擅长语义近似,但对「错误码 E4021」「某个具体函数名」这类精确字符串反而不敏感。BM25 这类关键词检索正好互补。两路结果用 RRF(倒数排名融合) 合并,比手动调权重省心,也不需要分数归一化:

def rrf(rank_lists, k=60):
    """rank_lists: [[doc_id, ...], [doc_id, ...]],按相关性从高到低。"""
    scores = {}
    for lst in rank_lists:
        for rank, doc_id in enumerate(lst, 1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
    return sorted(scores, key=scores.get, reverse=True)

top = rrf([vector_hits, bm25_hits])[:20]

融合之后还有一步值得做:重排。用一个 cross-encoder 或轻量重排模型,把 query 和候选片段成对打分,把 20 条压到 5 条。这一步只处理少量候选,延迟可控,对最终答案质量的提升往往比换更大的生成模型更划算。

四、提示词里要给「不知道」留出口

拼装上下文时,把每段带上来源编号,并要求模型只用给定片段回答、引用编号、无依据时明确说不知道。模板不必花哨:

请仅依据以下资料回答问题。每个结论后标注来源编号,如 [1]。
资料中没有依据的内容,直接回答「资料未提及」,不要推测。

[1] {chunk_1}
[2] {chunk_2}
...
问题:{question}

前端把编号渲染成可点击的原文链接,用户就能自己核验——这比任何「提高准确率的话术」都更能建立信任。

五、上线前检查清单

  • 建了 30~50 条人工标注问题,能算 recall@k 和答案忠实度
  • 切分后抽样看过 20 个 chunk,确认没有把表格/代码切断
  • 关键词检索与向量检索都启用,做了融合
  • 上下文超长时有截断与优先级策略,不是简单丢弃尾部
  • 文档更新后索引能增量重建,且删除的文档不再被召回
  • 加了「未检索到相关内容」的降级回复,而不是硬答

最后

RAG 的复杂度不在模型,而在数据:脏文档、过期版本、重复条目、权限隔离,这些才是长期要还的债。把检索命中率做成一条能每天跑的指标,后面每次改动才有依据,否则就只是在反复调参碰运气。