同一份知识库,4 种切分 × 3 种召回,纯关键词比纯向量高 13 个点

7 阅读11分钟

这次跑了什么

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 就得配向量库」这个默认动作,在这批语料上是站不住的。混合召回的分工也说不上巧妙,关键词负责专有名词和代码块,向量负责同义改写。跨块两边都弱,这个问题后面单独说。

先得让「答对」能判

上面这些百分比能算出来,前提是每道题只有一个答案,而且脚本能判对错。

判据用的是「源文件 + 唯一片段」,不是块编号。块编号会随切分方式变,拿它当判据,换一个切分器三十条题就全废了,而这篇文章要做的恰好就是换切分器。

{
  "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 字钉死,四种切分才站到同一杆秤上。

切分块/重叠块数召回命中率literalparaphrasestructurecrossblockconfusable平均上下文表格完整代码完整
按字符300/30431向量33%38%25%50%50%0%1180 字55%55%
按字符300/30431混合60%75%50%83%50%25%1181 字——
按字符300/30431父块53%75%50%50%50%25%984 字——
按字符800/80172向量30%25%38%17%75%0%782 字75%80%
按字符800/80172混合47%25%50%67%100%0%792 字——
按字符800/80172父块47%63%38%50%50%25%631 字——
按段落300/30556向量43%50%50%50%25%25%1077 字68%82%
按段落300/30556混合70%75%75%100%25%50%1057 字——
按段落300/30556父块43%63%25%50%50%25%1899 字——
按段落800/80196向量30%50%25%17%50%0%839 字94%95%
按段落800/80196混合67%63%63%83%100%25%752 字——
按段落800/80196父块50%63%50%50%50%25%705 字——
按句子300/30453向量37%38%38%33%50%25%1124 字6%18%
按句子300/30453混合67%75%75%83%50%25%1139 字——
按句子300/30453父块50%75%38%50%50%25%1239 字——
按句子800/80191向量33%25%38%17%75%25%810 字45%69%
按句子800/80191混合57%38%63%83%100%0%795 字——
按句子800/80191父块50%63%50%50%50%25%792 字——
递归300/30563向量40%50%50%33%25%25%1091 字70%80%
递归300/30563混合73%75%75%100%25%75%1068 字——
递归300/30563父块43%63%25%50%50%25%1918 字——
递归800/80196向量30%50%25%17%50%0%851 字94%95%
递归800/80196混合67%63%63%83%100%25%752 字——
递归800/80196父块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.053%
0.2 / 0.867%
0.3 / 0.767%
0.4 / 0.673%
0.5 / 0.573%
0.6 / 0.473%
0.7 / 0.363%
0.8 / 0.260%
1.0 / 0.040%

0.4 到 0.6 这三档结果完全相同,取哪个都一样。往下到 0.3 掉到 67%,往上到 0.7 掉到 63%。是个很宽的平顶,不是某个幸运点。

预算(字)命中率实际平均用字
30030%242
60050%517
90057%770
120073%1067
180077%1708
240080%2281
400083%3892

900 到 1200 加了 16 个点,1200 到 1800 只加 4 个点,1800 之后每翻一倍换 3 个点。1200 就是「再加不划算」的那个位置。

minScore命中率平均取块数
-1.073%4.6
0.073%4.6
0.273%4.6
0.373%4.6
0.473%4.6
0.560%4.4
0.630%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。报错原文是这样。

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 块。这地方的危险在于输出看着完全正常,块数等于文档数,不报错,不警告。