这次跑了什么
31 篇中文技术笔记当语料,30 条有唯一答案的问题当尺子,四种切分乘三种召回,一共 24 组配置。全部离线,不需要 API Key。
结果里有三件事跟我跑之前的预期不一样。
| 观察 | 数字 |
|---|---|
| 纯关键词召回比纯向量更准 | 53% 对 40% |
| 按句子切分把表格完整度打到 6%,命中率只掉 6 个点 | 67% 对 73% |
| 纯关键词在跨块题上一个都没命中 | 0% |
另外还踩了两个坑,都属于「看着在工作、其实没工作」那一种。
先看一组数字
同一批 563 个块、同一批 30 条问题,三种召回方式,上下文预算固定 1200 字。
| 召回方式 | 总体 | 字面 | 改写 | 结构 | 跨块 | 易混 |
|---|---|---|---|---|---|---|
| 纯关键词 | 53% | 75% | 38% | 83% | 0% | 50% |
| 混合(0.6 / 0.4) | 73% | 75% | 75% | 100% | 25% | 75% |
| 纯向量 | 40% | 50% | 50% | 33% | 25% | 25% |
字面题和结构题,关键词单干就有 75% 和 83%,纯向量只有 50% 和 33%。改写题反过来,关键词 38%,向量 50%。跨块题关键词一个都没中,向量和混合各 25%,谁也没解决。
所以「上 RAG 就得配向量库」这个默认动作,在这批语料上是站不住的。混合召回的分工也说不上巧妙,关键词负责专有名词和代码块,向量负责同义改写。跨块两边都弱,这个问题后面单独说。
先得让「答对」能判
上面这些百分比能算出来,前提是每道题只有一个答案,而且脚本能判对错。
判据用的是「源文件 + 唯一片段」,不是块编号。块编号会随切分方式变,拿它当判据,换一个切分器三十条题就全废了,而这篇文章要做的恰好就是换切分器。
json
{
"id": "q-07",
"type": "structure",
"question": "坑-17 那次并发探针打印出来的 auditDup 是多少?",
"expected": {
"source": ".../坑-17-幂等先查后写在并发下全漏.md",
"phrases": ["auditDup=0"]
}
}
命中的定义很朴素。取回的前 k 个块里,只要有一块来源在允许集合内、正文含了答案片段中至少一条,就算中。
三十条题分了五类,每一类都在验一件特定的事。
| 类型 | 条数 | 要证明什么 |
|---|---|---|
| 字面 | 8 | 基准线。这一档都命中不了,说明索引坏了 |
| 改写 | 8 | 语义能力。问句不照抄原文的词 |
| 结构 | 6 | 主结论。答案只在代码块或表格里 |
| 跨块 | 4 | 答案要两块以上拼起来 |
| 易混 | 4 | 问句里的词在多处出现,看能否挑对文件 |
有一条规则必须写进用例。答案片段在它所属的那个文件里只能出现一次。出现零次,判据永远判不中;出现两次,取回另一个块也算命中,命中率虚高,而程序不报错。这条我做过反向验证,把片段换成出现两次的字符串,用例立刻变红,还点出是哪一条。
还有个麻烦是重复副本。已发布的文章照抄过笔记原文,同一个结论散在两到四个文件里。只认一个来源,命中率会虚低,所以判据里加了「等价来源」,允许一组文件都算中。易混类故意不加,那一类考的就是能不能挑对文件。
24 组配置铺开看
有三样东西固定住不动。本地 embedding 用 bge-small-zh,同一个块永远得到同一个向量;上下文预算 1200 字;三十条题不变。
预算是这里最要紧的约束。块越大,同样取 k 块塞进去的字越多,命中率自然越高。不限预算的话,最优解永远是「一块装整篇」,那是算术,不是结论。把 1200 字钉死,四种切分才站到同一杆秤上。
| 切分 | 块/重叠 | 块数 | 召回 | 命中率 | literal | paraphrase | structure | crossblock | confusable | 平均上下文 | 表格完整 | 代码完整 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 按字符 | 300/30 | 431 | 向量 | 33% | 38% | 25% | 50% | 50% | 0% | 1180 字 | 55% | 55% |
| 按字符 | 300/30 | 431 | 混合 | 60% | 75% | 50% | 83% | 50% | 25% | 1181 字 | --- | --- |
| 按字符 | 300/30 | 431 | 父块 | 53% | 75% | 50% | 50% | 50% | 25% | 984 字 | --- | --- |
| 按字符 | 800/80 | 172 | 向量 | 30% | 25% | 38% | 17% | 75% | 0% | 782 字 | 75% | 80% |
| 按字符 | 800/80 | 172 | 混合 | 47% | 25% | 50% | 67% | 100% | 0% | 792 字 | --- | --- |
| 按字符 | 800/80 | 172 | 父块 | 47% | 63% | 38% | 50% | 50% | 25% | 631 字 | --- | --- |
| 按段落 | 300/30 | 556 | 向量 | 43% | 50% | 50% | 50% | 25% | 25% | 1077 字 | 68% | 82% |
| 按段落 | 300/30 | 556 | 混合 | 70% | 75% | 75% | 100% | 25% | 50% | 1057 字 | --- | --- |
| 按段落 | 300/30 | 556 | 父块 | 43% | 63% | 25% | 50% | 50% | 25% | 1899 字 | --- | --- |
| 按段落 | 800/80 | 196 | 向量 | 30% | 50% | 25% | 17% | 50% | 0% | 839 字 | 94% | 95% |
| 按段落 | 800/80 | 196 | 混合 | 67% | 63% | 63% | 83% | 100% | 25% | 752 字 | --- | --- |
| 按段落 | 800/80 | 196 | 父块 | 50% | 63% | 50% | 50% | 50% | 25% | 705 字 | --- | --- |
| 按句子 | 300/30 | 453 | 向量 | 37% | 38% | 38% | 33% | 50% | 25% | 1124 字 | 6% | 18% |
| 按句子 | 300/30 | 453 | 混合 | 67% | 75% | 75% | 83% | 50% | 25% | 1139 字 | --- | --- |
| 按句子 | 300/30 | 453 | 父块 | 50% | 75% | 38% | 50% | 50% | 25% | 1239 字 | --- | --- |
| 按句子 | 800/80 | 191 | 向量 | 33% | 25% | 38% | 17% | 75% | 25% | 810 字 | 45% | 69% |
| 按句子 | 800/80 | 191 | 混合 | 57% | 38% | 63% | 83% | 100% | 0% | 795 字 | --- | --- |
| 按句子 | 800/80 | 191 | 父块 | 50% | 63% | 50% | 50% | 50% | 25% | 792 字 | --- | --- |
| 递归 | 300/30 | 563 | 向量 | 40% | 50% | 50% | 33% | 25% | 25% | 1091 字 | 70% | 80% |
| 递归 | 300/30 | 563 | 混合 | 73% | 75% | 75% | 100% | 25% | 75% | 1068 字 | --- | --- |
| 递归 | 300/30 | 563 | 父块 | 43% | 63% | 25% | 50% | 50% | 25% | 1918 字 | --- | --- |
| 递归 | 800/80 | 196 | 向量 | 30% | 50% | 25% | 17% | 50% | 0% | 851 字 | 94% | 95% |
| 递归 | 800/80 | 196 | 混合 | 67% | 63% | 63% | 83% | 100% | 25% | 752 字 | --- | --- |
| 递归 | 800/80 | 196 | 父块 | 50% | 63% | 50% | 50% | 50% | 25% | 705 字 | --- | --- |
从这张表里能拎出四件事。
召回方式的杠杆比切分大。同一切分配置下,纯向量换成混合,涨 17 到 37 个点(递归 300/30 那组,40% 直接到 73%)。但切分也不能忽略,同一召回方式下换切分,最大差 20 个点(800/80 混合,按字符 47%,按段落和递归都是 67%)。
结构完整度和命中率是脱钩的。按句子切分把表格完整度打到 6%、代码 18%,等于把每一行都切开了。可混合召回下命中率还有 67%,跟最优只差 6 个点。原因不难猜,答案片段多半落在普通文字行里,结构被毁不等于答案找不到。但模型拿到一张残缺表格照样读不懂,只看命中率,这块损失会被漏掉。
父块不是万灵药。递归和段落 300/30 的父块,平均上下文分别是 1918 字和 1899 字,都超出预算了(第一块强制装入),命中率只有 43%。子块调大之后父块变短,705 字,命中率还是低于混合。它的价值在上下文完整,不在命中。
块大小是个取舍。300 比 800 命中率高(混合 73% 对 67%),800 的结构完整度又高得多(表格 94% 对 70%)。两头都要付代价。
关键词为什么在这批语料上更强
这个结论依赖语料性质,别直接搬到别的场景。
这批语料是中文技术笔记,幂等键、defaultToolCallbacks、on-http-codes 这类专有名词密度极高。向量模型把它们压进 512 维,相邻的概念就容易糊在一起,幂等、抢坑位、限流都算「并发与护栏」,语义上贴得非常近。二元组匹配碰到专有名词几乎不失手,代价是同义改写完全无能,那一列只有 38%。
跨块那列关键词 0%,值得单独记一笔。它只能找到词对得上的那一块,拼不出跨段的答案。混合也只有 25%,跨块是所有方案的短板。真要解决,得上父块回填或者多轮检索,可父块回填在 1200 字预算下并不划算,前面 1918 字平均上下文那组数字已经说明了。
三个估出来的参数,后来都扫了一遍
混合权重 0.6 / 0.4、预算 1200 字、相似度阈值 0,这三个数一开始全是我拍的。拍出来的值不能当结论,所以后面各扫了一遍,看它到底是平台,还是某个幸运点。
| 向量 / 关键词 | 命中率 |
|---|---|
| 0.0 / 1.0 | 53% |
| 0.2 / 0.8 | 67% |
| 0.3 / 0.7 | 67% |
| 0.4 / 0.6 | 73% |
| 0.5 / 0.5 | 73% |
| 0.6 / 0.4 | 73% |
| 0.7 / 0.3 | 63% |
| 0.8 / 0.2 | 60% |
| 1.0 / 0.0 | 40% |
0.4 到 0.6 这三档结果完全相同,取哪个都一样。往下到 0.3 掉到 67%,往上到 0.7 掉到 63%。是个很宽的平顶,不是某个幸运点。
| 预算(字) | 命中率 | 实际平均用字 |
|---|---|---|
| 300 | 30% | 242 |
| 600 | 50% | 517 |
| 900 | 57% | 770 |
| 1200 | 73% | 1067 |
| 1800 | 77% | 1708 |
| 2400 | 80% | 2281 |
| 4000 | 83% | 3892 |
900 到 1200 加了 16 个点,1200 到 1800 只加 4 个点,1800 之后每翻一倍换 3 个点。1200 就是「再加不划算」的那个位置。
| minScore | 命中率 | 平均取块数 |
|---|---|---|
| -1.0 | 73% | 4.6 |
| 0.0 | 73% | 4.6 |
| 0.2 | 73% | 4.6 |
| 0.3 | 73% | 4.6 |
| 0.4 | 73% | 4.6 |
| 0.5 | 60% | 4.4 |
| 0.6 | 30% | 3.2 |
0 到 0.4 之间结果一模一样。进预算的块,余弦本来就在 0.4 以上,阈值根本拦不到东西。拉到 0.5 开始误杀,命中率掉到 60%,再拉到 0.6 只剩 30%。所以这个语料上阈值设 0 是对的,设了也没用。
顺手看了 Python 那边的默认值
很多人是从 Python 那边过来的。我把 langchain_text_splitters 0.3.11 的源码解出来看了默认值,跟 Java 这边对不上的地方不少。
| LangChain(Python) | LangChain4j(Java) | |
|---|---|---|
| 默认块大小 | chunk_size=4000、chunk_overlap=200 |
没有默认值,必须传 |
| 切分依据 | 字符(length_function=len) |
字符,可换成 TokenCountEstimator |
| 默认分隔符 | ["\n\n", "\n", " ", ""] |
段落、行、句子、词、字符,链式降级 |
| 语言预设 | from_language(Language.JAVA) 按 \nclass 、\npublic 、\nif 等切 |
没有,要自己写 DocumentByRegexSplitter |
Java 那条链我是用 javap -c 在 jar 上确认的。DocumentSplitters.recursive 依次构造段落、行、句子、词四个切分器,词那一层再降级到字符,没有「分隔符列表」这个东西。
有三个地方不能照抄。
- 4000 字这个默认值对中文偏长。上面矩阵里 800 已经是「结构完整优先」的档了,300 的命中率还更高。直接套 4000,一块塞进去好几节,检索精度必掉。
" "这一级对中文几乎没用。中文不靠空格分词,那一层基本不触发,真正生效的是换行和字符级。- Java 侧没有语言预设。想按类、按方法切,得自己写正则。正则写错了不报错,切出来的块只是「看起来正常」。
两个坑
坑一,Intel Mac 装不了本地中文 embedding。报错原文是这样。
vbnet
ai.djl.engine.EngineException: Failed to load Huggingface native library.
Caused by: ai.djl.engine.EngineException: Unexpected flavor: cpu
「Unexpected flavor: cpu」把我往 ONNX 运行时的 flavor 配置上带,查了半天环境变量,什么也没查到。后来把 jar 解包看 native/lib/,才找到真因。DJL 的 tokenizers 从 0.30.0 起不再发布 osx-x86_64 的原生库,而 LangChain4j 默认拉的是 0.36.0,本机 Intel Mac,压根没有对应的 .dylib。逐版本解包确认下来,0.29.0 是最后一个还带这个库的版本。把 ai.djl:api 和 ai.djl.huggingface:tokenizers 一起钉到 0.29.0,测试才过。只钉一个会版本错配。
坑二,正则里的 matches() 让切分只切出一块。第一版的输出是这样。
候选块:全部 31 个,值得问 31 个,涉及 31 个文档
每个文档只切出一块。Pattern.matches() 要求整行完全匹配,可我的正则带了行首锚点。改成 find() 之后是 237 块。这地方的危险在于输出看着完全正常,块数等于文档数,不报错,不警告。