2647 字
约 8 分钟
12
S20-B05 · RAG 入门:一次问答到底经过了哪些环节

问模型"我们内部的上线流程是什么",它给了一套通用流程,听着合理、但和你们实际的做法完全不同。问题不在模型不够强,在于它的参数里没有你们的文档。RAG(Retrieval-Augmented Generation)就是把"相关原文"在提问时递到它眼前,让它照着材料说话。

一次回答要经过六个环节,每一环都会单独决定成败。搞不清链路,就只能靠"换个更大的模型"碰运气。


1. RAG、微调、长上下文的取舍

方案 知识放在哪 更新成本 擅长
RAG 外部索引,检索后拼进提示 改动文档即时生效 频繁变化的、有出处要求的问答
微调 权重里 重新训练、成本高 固定风格、固定输出格式
长上下文 一次性全塞进窗口 每次都要重发 少量文档的一次性分析

三者不是替代关系。知识会变,就用 RAG;风格要稳,就用微调或提示固化;材料少且一次性,直接塞上下文最省事。


2. 六个环节与各自的输入输出

环节 输入 输出 出错的表现
① 文档接入 PDF/网页/Markdown/表格 纯文本 + 元数据 表格结构丢失、页眉页脚混入
② 清洗切分 纯文本 块(chunk)列表 答案被切在块边界
③ 向量化 块文本 每条一个定长向量 语义被截断、超出模型上限
④ 存储索引 向量 + 元数据 可检索索引 重建后新旧混用
⑤ 检索 查询向量 + top-k 若干块 + 分数 召回了相似但无关的内容
⑥ 生成 块 + 问题 + 约束 带引用的答案 有材料却答错、或凭空补全

任何一环单独优化都难见效:切分错了,换 embedding 只是把错误表达得更精确。

文档 → [解析] → 文本 → [切分] → chunks → [embed] → vectors
       → [upsert 索引] → [query embed] → [top-k 检索] → [拼装提示] → [生成]

3. 为什么必须切分

理由 说明
窗口 整篇文档往往超过上下文预算,塞不下
精度 向量是整块的平均语义,块越大越"什么都像"
成本 每次问答都发全文,token 费用与延迟都不可接受

切分的取舍:块小则检索锋利好、但丢上下文;块大则完整、但相似度被稀释。典型做法是按标题层级切,块之间留少量重叠,让跨界的句子两边的块都有。

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=800, chunk_overlap=120,
    separators=["\n\n", "\n", "。", ";", ""])
chunks = splitter.split_text(raw_text)   # 参数按所用分词器口径核对

4. 向量与相似度:语义近则距离近

向量化把一段文字映射成一个定长数组,语义相近的文字在这个空间里方向接近。检索就是在这个空间里找最近的邻居。

度量 计算 适用 注意
余弦相似度 夹角余弦 最常用,长度无关 向量需先归一化才等价于内积
内积 点积 已归一化时与余弦等价 未归一化会被长文本偏置
欧氏距离 直线距离 图像、聚类 对向量模长敏感

关键约束:写入与查询必须用同一个 embedding 模型、同一套归一化。换了模型不重建索引,等于用两把不同刻度的尺子量长度。

import numpy as np
def cosine(a, b):
    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))

# 归一化后内积即余弦,可用向量库的原生 dot 加速

5. 检索到的块怎么塞进提示

拼装不是把块直接倒进去。三件事决定利用率:

做法 建议 原因
顺序 最相关的放最前,其次放最后 中间位置的信息最易被忽略
条数 top-k 从 3~8 起,按评测调 条数越多噪声越多
引用标注 每块带编号与来源,要求答案标注编号 让"依据"可核对
去重 相邻块重叠部分裁掉 重复内容浪费预算且强化偏置
prompt = """只依据下面资料回答,资料未提及就回答"资料未提及"。
每条结论后标注依据编号,如 [2]。

{context}

问题:{question}"""
context = "\n\n".join(f"[{i+1}] {c.text}" for i, c in enumerate(chunks[:5]))

"资料未提及就回答未提及"这一句是整段提示里最有价值的部分。


6. "检索到了但答错"的四种原因

原因 现象 定位方式
块里没有答案 召回内容相关但缺关键句 人工看 top-k 原文,找答案落在哪一块
相似但无关 词汇重合、语义不同 看分数分布是否都偏低且接近
上下文被截断 排在后面的块没进提示 打出实际拼装的 prompt 长度
模型没遵循 材料里有答案,回答却另起一套 用同一段落做单独测试,排除检索问题

定位顺序固定:先看检索到的原文,再看拼装后的 prompt,最后才怀疑模型。跳过前两步直接改提示,等于在错误的层上做优化。


7. 没有检索结果时的正确行为

检索为空、或最高分低于阈值时,正确输出是明确说不知道,而不是让模型自由发挥。

if not hits or max_score < MIN_SCORE:
    return "知识库里没有相关内容,需要我换一种问法,或补充文档?"

阈值要靠评测定,不能拍脑袋。可行的做法:构造一批"库里确实没有答案"的问题,观察分数分布,取能把它们挡住的值。


8. 评测的两个层次

层次 评什么 最小做法
检索质量 top-k 里有没有正确答案所在的块 准备 30~50 条问题,人工标注"应命中的块",算命中率
答案质量 最终回答是否正确、是否引用、是否越界 同一批问题,人工或评分模型按"正确/部分/错"三档标

最小评测集不必大,但必须固定:同一批问题、同一套标准,改动前后各跑一次,才知道这次改动是变好还是变坏。

eval_set = [
    {"q": "上线审批要谁签字?", "expected_chunk_ids": ["doc-7#3"], "answerable": True},
    {"q": "食堂几点开门?",     "expected_chunk_ids": [],        "answerable": False},
]
hit = sum(any(c in r.chunk_ids for c in e["expected_chunk_ids"]) for e, r in zip(eval_set, results))

9. 增量更新:新增、删除、变更

类型 处理 易漏点
新增文档 切分 → 向量化 → 写入 忘了写元数据,无法按来源过滤
删除文档 按 doc_id 删除全部块 只删正文块,摘要块残留
文档变更 先删旧块再写新块,或按版本号覆盖 新旧并存导致同一问题召回两个版本
切分规则变更 全量重建,不是增量 新旧块边界不一致,混在一起

删除最容易被做成"逻辑删除",然后在检索时忘了加过滤条件——这是很多"文档已下线但还能被检索到"的根因。

def reindex(doc_id, chunks):
    index.delete(filter={"doc_id": doc_id})     # 先清旧版本
    vectors = embed([c.text for c in chunks])
    index.upsert([{"id": f"{doc_id}#{i}", "vector": v,
                   "meta": {"doc_id": doc_id, "ver": chunks[0].version}}
                  for i, (c, v) in enumerate(zip(chunks, vectors))])

10. 最小可跑示例

def ask(question, k=5, min_score=0.35):
    qv = embed([question], model=EMBED_MODEL)[0]          # 同一模型
    hits = index.search(qv, top_k=k, filter={"ver": CURRENT})  # 过滤先于检索
    if not hits or hits[0].score < min_score:
        return UNKNOWN_ANSWER
    ctx = "\n\n".join(f"[{i+1}] {h.text}" for i, h in enumerate(hits))
    return generate(PROMPT.format(context=ctx, question=question))

两个参数决定效果上限:k 与 min_score。两者都要通过第 8 节的评测集调,不要沿用别人的默认值。


11. 常见坑

坑 后果 改法
按固定字符切表格 表头与数据分家,数字对不上行 按行/按结构切,或整表作为一个块
top-k 拍脑袋定 10 噪声压过信号,成本翻倍 用评测集扫 k 值
从不评测 改了什么变好变坏都不知道 固定小评测集,改动前后各跑
改了切分不重建索引 新旧块边界不一致 切分规则变更走全量重建
权限过滤放在最后 先召回再过滤,等于把无权内容送进模型 过滤条件下沉到检索查询里
多版本文档并存 同一问题召回互相矛盾的两段 元数据带版本,检索时限定当前版本

12. 边界:什么时候不需要 RAG

场景 更合适的选择
知识总量很小(几页) 直接放进上下文
要的是固定风格与格式 提示固化或微调
需要跨文档做关系推理 图谱类方案,而非纯向量检索
实时数据(价格、库存) 调接口取数,检索解决不了时效

13. 自测

  1. 检索返回的块看着都相关,答案却错了,第一步该看什么?
  2. 为什么换了 embedding 模型必须重建索引?
  3. 余弦相似度与内积在什么条件下给出同样的排序?
  4. 切分规则从 800 改成 500,为什么不能只增量更新?
  5. 权限过滤放在检索之后,会带来什么后果?
  6. 评测集为什么要固定,而不是每次换新题?
S20-B05 · RAG 入门:一次问答到底经过了哪些环节
https://www.clxhxhhr.top/posts/6066/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。