问模型"我们内部的上线流程是什么",它给了一套通用流程,听着合理、但和你们实际的做法完全不同。问题不在模型不够强,在于它的参数里没有你们的文档。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. 自测
- 检索返回的块看着都相关,答案却错了,第一步该看什么?
- 为什么换了 embedding 模型必须重建索引?
- 余弦相似度与内积在什么条件下给出同样的排序?
- 切分规则从 800 改成 500,为什么不能只增量更新?
- 权限过滤放在检索之后,会带来什么后果?
- 评测集为什么要固定,而不是每次换新题?