🦙 第6章 Llama 2 + LangChain 文档问答模型构建
📄 一句话概括

文档问答系统出错,有四种可能:切块切坏了、检索没召回、提示没拼对、模型答歪了。 只测「最终答案对不对」,这四种错混在一个数里,永远修不完。 这一节把三层分开测:切块层看块长,检索层看召回与首块命中,生成层看答案完整度。 最后算一笔 token 账——准不准和贵不贵,是两个指标。

📝 本章和前面几章的关系

这是第 6 章最后一节,也是全书讲「编排」这条路的收尾。 评估的一般方法在 3.4 节讲过;这一节只讲文档问答这个结构特有的那一层:把错误分成三段来定位。

一、先建目录:一次建好,别边写边加

这类项目的目录形状几乎是固定的:源文档一个目录,索引缓存一个目录,评测集一个目录,代码一个目录。 索引缓存一定要单独放,而且一定要进 .gitignore。 它几十兆起步,还每次重建都不一样,混进版本库只会让 diff 变成噪音。 评测集单独一个目录,是因为它是唯一不会随代码变的东西。 代码改十版,评测集不动,才能比较出改好还是改坏。

配置用环境变量,不写进代码。 模型地址、密钥、切块粒度这三样一定会变,写死在代码里,改一次要翻一遍文件。

二、交互流程:七步,每步都可能错

加载:把 PDF、Word、网页统一读成纯文本。这一步的坑是格式——表格读成碎行,页眉页脚混进正文。 清洗:去页眉页脚、合软换行、去乱码。第 5 章已经算过,这一道能砍掉三成字符。 切块:按固定长度或按段落切,块间留重叠。这一步的坑是「把一句话切成两半」。

嵌入与入库:每块算一个向量存起来。这一步的坑是模型换了向量就得全重算。 检索:问题也算向量,取最相近的前 k 块。这一步的坑是 k 取小了漏、取大了稀释。 拼提示与生成:把检索到的块填进模板,连问题一起送模型。

解析:从答案里取字段,取不到就落回「没找到」,不要硬编一个。 七步里,前五步全是确定性的——同样的输入永远同样的输出,可以单独测。 只有最后两步带随机性,所以错误定位必须先排掉前五步。

三、三层分开测:切块、检索、生成

切块层测什么?测块长分布。块长差别太大,说明清洗没做到位; 出现只有几个字的碎块,说明切分规则碰上了表格或标题。 检索层测什么?测召回:答案那一块有没有进前 k。 注意是「那一块」,不是「那段话」——块切错了,检索层再准也没用。 生成层测什么?测答案完整度:给了正确的上下文,模型能不能把答案抄对、能不能不添油加醋。

三层测完,错误的归属就清楚了:召回低是切块或检索的问题,召回高但答案错是生成的锅。 混在一起测,永远不知道该改哪。 还有一个中间指标很值钱:首块命中,也就是「排第一的那块就是答案」。召回@3 满分很容易,因为 3 太宽; 首块命中才是真实体验。

四、指标与口径:三个数加一笔账

召回@k:前 k 块里有没有答案。它衡量检索的上限,不衡量体验——排第三和排第一,用户体验差很远。 首块命中:第一块就是答案的比例。它比召回更接近用户感受,因为模型对第一块的注意力最重。 答案完整度:给了对的上下文之后,答案里关键要素齐不齐。这一项要人工定判据,不能靠字面匹配。

token 账:每次问答实际送进去多少 token。 它直接换成钱和延迟,而且和上面三个数经常是反向的——块越大越准,也越贵。 四个指标里必有一个是取舍。所以评估的产出不是「分数多少」,是「在这个预算下,哪一组参数最划算」。

五、小结:这一章的四句话

第一句,框架不生产模型,它只让「换模型」这件事变得便宜。不换,就别加这层。 第二句,八件零件里只有模型是必须的。每加一件,都要说得出它解决哪个具体问题。 第三句,环境问题九成是版本问题。看报错只看两行,别硬装。

第四句,文档问答的错要分三层定位。只测最终答案,等于把四个变量揉成一个数。 这四句合起来是一条判断:能用检索和编排解决的,先别训模型。它更快、更便宜、改起来也不用重训。

六、动手 Demo:切块 200 还是 800,召回差多少

五段亮化工程的技术资料,各约两百字,合成一篇一千字的文档。 按三种粒度切块,各建一次索引,问五个问题。 量四个数:块数、召回@3、首块命中、三块合起来的 token。 检索用两字重合度,不用向量——这样变量只有一个:块大小。

def chunks(text, size):
    return [text[i:i + size] for i in range(0, len(text), size)]

def score(q, c):                 # 两字重合度,笨办法
    a = set(q[i:i + 2] for i in range(len(q) - 1))
    b = set(c[i:i + 2] for i in range(len(c) - 1))
    return len(a & b) / float(len(a) or 1)

for size in (100, 200, 400):
    cs = chunks(doc, size)
    hit = sum(1 for q, kw in QS if kw in top(cs, q, 3))
    print(size, len(cs), hit)

实跑输出:

--------------------------------------------------------------------
切块 200 还是 800,召回差多少
--------------------------------------------------------------------


  ① 五段资料,五个问题
  --------------------------------------------------------------------
| 主题 | 长度 | 关键句 |
|---|---:|---|
| 防护等级 | 212 字 | IP65 |
| 验收流程 | 205 字 | 报验单 |
| 灯位布置 | 193 字 | 每米一盏 |
| 雨天施工 | 189 字 | 当日安装 |
| 色温选择 | 204 字 | 三千 |

  五段合成一篇 1003 字的文档,然后按三种粒度切块,各建一次索引。
  ! 这里故意不加重叠。
  真实系统会加,但加了之后「重叠」和「粒度」两个变量就混在一起,测不出是哪个在起作用。

  ② 三种粒度
  --------------------------------------------------------------------
| 块大小 | 块数 | 最短 / 最长 |
|---:|---:|---:|
| 100 | 11 | 3 / 100 |
| 200 | 6 | 3 / 200 |
| 400 | 3 | 203 / 400 |

  块越小,块数越多;块内长度越均匀,说明切得越整齐。
  ! 这里按固定长度硬切,所以一段两百字的资料,
  在 100 字粒度下必定被切成两半——答案也就跟着被切成两半。

  ③ 召回、代价、答案完整度
  --------------------------------------------------------------------
| 块大小 | 召回@3 | 首块命中 | 三块 token |
|---:|---:|---:|---:|
| 100 | 5 / 5 | 4 / 5 | 430 |
| 200 | 5 / 5 | 4 / 5 | 870 |
| 400 | 5 / 5 | 5 / 5 | 1462 |

  召回 @3 是「前三块里有没有答案」,首块命中是「第一块就是答案」。
  召回那一列全是满分,因为 top-3 太宽。召回满分不代表答得对——要看首块命中。
  ! 块太小的问题不在召回,在上下文:答案被切成两半,模型看到的是半句话,
  它会用这半句话编一个完整答案。

  ④ 最优块长由「一段资料多长」决定
  --------------------------------------------------------------------
| 粒度 | 好处 | 代价 |
|---|---|---|
| 100(半段) | 省 token、检索细 | 答案被切断,首块命中掉一道 |
| 400(一段) | 一块一段,首块命中满分 | 上下文最贵,是三倍 |
| 远大于段长 | 块少、排序省事 | 一块混多个主题,排序被稀释 |

  这里 400 最好,不是因为它是个神奇数字,而是因为每段大约两百字,四百的块刚好装下完整一段。
  ! 真实系统还会加「重叠 50 字」和「按段落切」,两个都是为了「别把一句话切断」。
  切块的唯一目标是:一块之内,答案完整。

  ⑤ 真机上的切块与检索
  --------------------------------------------------------------------

  [真机代码 · 本沙箱不执行]
  | from langchain_text_splitters import RecursiveCharacterTextSplitter
  | from langchain_community.vectorstores import FAISS
  | 
  | spl = RecursiveCharacterTextSplitter(chunk_size=400, chunk_overlap=50)
  | vs = FAISS.from_texts(spl.split_text(text), embeddings)
  | docs = vs.similarity_search(q, k=3)
  要 langchain 与向量库,本沙箱都没有,不执行。

  真机上把「字面相似度」换成向量,语义相近也能召回——但粒度这个变量还是一样的影响。
  ! 评估文档问答系统,只测「最终答案对不对」是不够的:答案错了,可能是切块切坏了、检索没召回、
  模型答歪了。三层要分开测。

①五段资料合成的文档一千字出头。 故意不加重叠,因为加了之后「重叠」和「粒度」两个变量就混在一起,测不出是哪个在起作用。 ②块大小 100 时切出 11 块,最短的只有 3 个字——那就是切在了段落接缝上。 这种碎块进了索引只会占位,检索到它等于没检索到。 200 切出 6 块,400 切出 3 块。块越大越整齐,是因为硬切对长文本更友好; 真实系统按段落切,块长会更不均。 ③最该盯的是「召回@3 全满分」这件事。 100 字粒度也是 5/5,看着完美,但首块命中只有 4/5——说明有一道题答案排到了第二或第三块。 召回满分不代表答得对。模型对第一块的注意力最重,排第三的那块,经常被当成补充材料忽略掉。 要看首块命中,不看召回。 块越小的问题不在召回,在上下文:一段两百字的资料被切成两半,答案横跨两块, 模型看到的是半句话——它会用这半句话编出一个完整答案。 ④400 那一档首块命中满分,但它最贵:三块合起来 1462 token,是 100 那档的三倍多。 这里 400 最好,不是因为它是个神奇数字,而是因为每段资料大约两百字,四百的块刚好装下完整一段。 最优块长由「一段资料多长」决定,不由论文决定。 换成五百字一段的资料,最优块长就该跟着往上走。所以这个参数没有普适值,只有「你的资料」的值。 ⑤真机上把字面相似度换成向量,语义相近也能召回——但粒度这个变量还是同样的影响。 评估文档问答系统,只测最终答案是不够的:答案错了,可能是切块切坏了、检索没召回、模型答歪了。 三层要分开测,才知道该改哪一层。

30 秒实验

  1. 把重叠加回来(每块后面多带 50 字),重跑第 ③ 步。首块命中变了吗?token 账涨了多少?
  2. 把第 400 那档换成「按段落切」,也就是一段一块。块数还是 3 吗?首块命中会变吗?
  3. 只测「最终答案对不对」,不给分层指标。演示里三种粒度下,你怎么判断该改切块还是改模型?

常见误区

  1. 只测最终答案对不对。 答错了不知道是切块、检索还是生成的问题,于是每一次「优化」都是猜,改完也不知道有没有变好。
  2. 拿召回@3 当验收指标。它太宽,随便切都容易满分。用户感受到的是第一块,不是前三块。
  3. 以为块越大越准。 块大了确实更容易装下答案,但噪声也一起进去了,而且 token 账翻倍——准确率涨两个点,成本涨三倍, 这笔买卖常常不划算。
  4. 切块不分段落,纯按字数硬切。 演示里 100 字粒度切出的最短块只有 3 个字,这种碎块进了索引,检索到它比检索不到更糟。
  5. 把索引缓存提交进版本库。几十兆、每次重建都不一样,diff 全是二进制噪音,而且换台机器还得重算。
自测三问(点开对答案) 1. 为什么召回@3 全是满分,还要看首块命中? —— 因为 3 太宽,答案排第三也算召回成功。 但模型对第一块的注意力最重,排第三的那块经常被当成无关材料。首块命中更接近用户的真实体验。 2. 召回低,该改哪一层?召回高但答案错,该改哪一层? —— 召回低先看切块:答案是不是被切断了、碎块是不是太多。 再看检索:k 是不是太小、相似度算法是不是只认字面。 召回高但答案错,问题在生成——看提示里的格式约束,看模型有没有被噪声带偏。 3. 为什么说「最优块长没有普适值」? —— 因为块长的目标是「一块之内答案完整」,而答案多长取决于你的资料怎么写的。 演示里资料每段两百字,四百的块刚好;换成五百字一段,最优值就跟着变。

第 6 章到此结束。 前面六章都在英文和中文之间打转,最后一章换个角度:一本模型要同时服务几十种语言,难在哪。

相关笔记