分词器不属于单纯的文档解析器,也不属于向量模型,它属于 RAG 的词法检索基础层,是“检索质量增强组件”,核心是:

  • 离线阶段,它在 chunk 生成之后,把 chunk 正文和标题转成 content_ltkscontent_sm_ltkstitle_tks 等 ES 检索字段;- 在线阶段,它在 query 召回之前,对用户问题做同一套归一化、分词和细粒度扩展,用来构造全文检索表达式。

这样离线文档侧、query 侧 token 空间一致,混合检索里的 BM25/全文检索才能稳定工作。

游戏领域里它尤其重要,因为玩法名、活动名、职业名如果被通用分词切碎,会直接导致召回噪声和漏召。

1. 引言

分析架构之前,先回顾一下RAG 经典架构:

1.1. 离线阶段的位置

离线链路大概是:

1
2
3
4
5
6
文档上传
-> parser 解析 PDF/DOCX/TXT/MD
-> chunk 切分
-> rag_tokenizer 生成 token 字段
-> embedding 生成向量
-> 写入 Elasticsearch

在代码里,chunk 生成后会调用 tokenizer 生成两个核心字段:

  • content_ltks:粗粒度分词字段
  • content_sm_ltks:细粒度分词字段
1
2
d["content_ltks"] = rag_tokenizer.tokenize(t)
d["content_sm_ltks"] = rag_tokenizer.fine_grained_tokenize(d["content_ltks"])

文档标题也会分词,进入标题检索字段:

1
2
"title_tks": rag_tokenizer.tokenize(...)
doc["title_sm_tks"] = rag_tokenizer.fine_grained_tokenize(...)

在离线阶段把 chunk 正文和标题处理成 Elasticsearch 可检索的词项字段,为后续全文检索和混合检索做索引准备。

1.2. 在线阶段的位置

在线 query 链路大概是:

1
2
3
4
5
6
7
8
用户 query
-> query 归一化,详见【3. 分词器小细节】
-> rag_tokenizer 分词
-> term_weight 加权
-> synonym 同义词扩展
-> 构造 ES query_string
-> 全文检索 + 向量检索
-> rerank

所谓的归一化要做:

  • 小写化
  • 全角转半角
  • 繁体转简体
  • 清理标点和疑问词
  • 调用 rag_tokenizer.tokenize
  • 调用 fine_grained_tokenize
  • 查同义词
  • 拼 ES MatchTextExpr

所以rag_tokenizer.py 在线阶段属于“query 分析与召回表达式构造”流程。

1.3. 为什么它横跨离线和在线

因为文档侧和 query 侧必须使用同一套分词逻辑。

如果离线 chunk 被切成:

1
血盟据点战

但在线 query 被切成:

1
血盟 / 据点 / 战

ES 的全文检索匹配就会不稳定。反过来,如果文档侧被切碎,query 侧保留长词,也会对不上。

所以 rag_tokenizer.py 的本质是让文档 chunk token 空间 == 用户 query token 空间

这样 BM25、query_string、token similarity、rerank 前的文本分数才有意义。

2. 整体链路

先串一下 rag_tokenizer.py 的整体链路:

  
flowchart TD
  A["文档解析<br/>rag/app/naive.py"] --> B["nlp/__init__.py<br/>tokenize_chunks/tokenize_table"]
  B --> C["rag_tokenizer.tokenize"]
  C --> D["content_ltks"]
  C --> E["fine_grained_tokenize"]
  E --> F["content_sm_ltks"]
  D --> G["Elasticsearch"]
  F --> G

  H["用户问题"] --> I["query.py<br/>FulltextQueryer.question"]
  I --> C
  I --> J["term_weight.py<br/>weights/pretoken"]
  J --> K["freq/tag/fine_grained_tokenize"]
  K --> C
  I --> L["MatchTextExpr"]
  L --> M["search_v2.py<br/>全文+向量混合检索"]
  M --> N["rerank_by_model/token_similarity"]
  O["search_v3.py"] --> P["弱召回时 fusion fallback"]
  P --> I

这小图是不是看着有点懵逼,没事儿,我稍微拆解一下,就很好懂啦。

这个架构先要明白一点:我的分词器是单独抽象的一层,完全和系统解耦开了,其他同事开发链路即可,这里换成 jieba 或者其他东西先用着,等我开发好了,一换调用即可,不用动其他代码,方便的很。

2.1. 多链路实现细节

这个图可以理解为在 RAG 系统里的上下游依赖图,很明显的可以看到分词器被两条主链路复用:

  1. 离线入库链路:文档 chunk -> 分词 -> 写入 ES token 字段
  2. 在线查询链路:用户 query -> 分词/权重/同义词/细粒度扩展 -> 拼 ES 查询表达式

2.1.1. 离线入库链路

业务处理逻辑是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
上传文档
-> execute_insert_process()
-> parse()
-> naive.chunk()
-> tokenize_chunks()/tokenize_table()
-> nlp.tokenize()
-> rag_tokenizer.tokenize() # 用到了我的分词器
-> content_ltks/content_sm_ltks
-> generate_embedding()
-> ES insert

file_parse.pyexecute_insert_process() 先调用:

1
documents = parse(file_path, parser_mode=parser_mode)

parse() 里面继续调用 naive.pychunk()进行分块。

chunk() 逻辑是:

  1. 判断文件类型:pdf/docx/xlsx/txt/md/html/json
  2. 调不同 parser 把原始文件解析成 sections/tables
  3. 用 naive_merge() 把连续段落合成 chunk
  4. 调 tokenize_chunks() / tokenize_table() 给每个 chunk 生成 ES 字段

这里开始进入图里的第一条线:

1
2
3
文档解析 rag/app/naive.py
-> nlp/__init__.py tokenize_chunks/tokenize_table
-> rag_tokenizer.tokenize

真正写 token 字段的是 nlp/init.py

1
2
3
d["content_with_weight"] = t
d["content_ltks"] = rag_tokenizer.tokenize(t)
d["content_sm_ltks"] = rag_tokenizer.fine_grained_tokenize(d["content_ltks"])

这条链路给 RAG 提供了三个重要的信息:

  1. content_with_weight:原始 chunk 文本,给大模型引用和回答用
  2. content_ltks:粗粒度分词字段,给 ES 全文检索用
  3. content_sm_ltks:细粒度分词字段,给弱匹配/残缺问法兜底

比如文档 chunk 是:

1
血盟据点战每周开启,参与后可获得贡献奖励。

可能会变成:

1
2
3
content_with_weight = 原文
content_ltks = 血盟 据点战 每周 开启 参与 获得 贡献 奖励
content_sm_ltks = 血盟 据点 战 每周 开启 参与 获得 贡献 奖励

这样 ES 里既能搜完整领域词“据点战”,也能搜用户只输入的“据点 奖励”。

2.1.3. 在线 query 链路

这一节就和用户息息相关了,游戏玩家都是从这开始用的系统:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
玩家提问 -> POST /chat_on_docs
-> retrieve_content()
-> Dealer.retrieval()
-> Dealer.search()
-> FulltextQueryer.question()
-> rag_tokenizer.tokenize() # 用到了我的分词器
-> MatchTextExpr
-> ES query_string + vector knn
-> rerank
-> 返回 chunks

retrieve_content()retrieval.py,它调用 Dealer.retrieval()

search_v2.py

1
2
3
matchText, keywords = self.qryr.question(qst, min_match=0.3)
matchDense = self.get_vector(qst, emb_mdl, topk, req.get("similarity", 0.1))
fusionExpr = FusionExpr("weighted_sum", topk, {"weights": "0.05, 0.95"})

这里同时做两件事:

1
2
3
matchText:全文检索表达式
matchDense:query embedding 向量检索
fusionExpr:全文 + 向量混合

FulltextQueryer.question()query.py,它需要处理很多逻辑:

  1. query 归一化:全角半角、繁简体、标点、疑问词清理
  2. rag_tokenizer.tokenize() 分词 (这里又需要用到我的分词器)
  3. term_weight.weights() 给 token 算权重
  4. synonym.lookup() 找同义词
  5. fine_grained_tokenize() 做细粒度扩展
  6. 拼成 MatchTextExpr

最后es 表达式要查这些 ES 字段:

1
2
3
4
5
6
7
"title_tks^10",
"title_sm_tks^5",
"important_kwd^30",
"important_tks^20",
"question_tks^20",
"content_ltks^2",
"content_sm_ltks",

这些字段有没有很熟悉的?没错,其中就有[2.1.1. 离线入库链路]小节写进去的一部分

1
title_tks / title_sm_tks / content_ltks / content_sm_ltks

在线查询时会被同一套 query token 命中。

这就是图里最核心的意思: 1.离线:文档用 RagTokenizer 切成 token 存 ES 2. 在线:query 用 RagTokenizer 切成 token 查 ES

两边用同一个 tokenizer,为了让“文档侧 token 空间”和“查询侧 token 空间”一致。

至于ES字段为啥这么设计,得单独记录一篇文章了,毕竟这是另外同事实现的🤣,设计思路我可以搞来。

2.1.4. term_weight链路

图里这段:

1
2
3
4
query.py
-> term_weight.py weights/pretoken
-> freq/tag/fine_grained_tokenize
-> rag_tokenizer

意思是:查询不只是分词,还要给每个词算重要性。

比如:

1
血盟据点战奖励怎么领

怎么/怎么领 这种疑问词权重低,甚至会被清理;
血盟据点战奖励 权重更高。

term_weight.py 会调用:

1
2
3
rag_tokenizer.freq():看词频
rag_tokenizer.tag():看词性
fine_grained_tokenize():补细粒度 token

所以它也是 rag_tokenizer 的下游。

2.1.5. rerank/token_similarity链路

ES 初召回后,还会排序,注意⚠️,这里也是分层设计的,至于为什么,详细看[### 3.1. 权重问题]小节的内容

排序不能只看 ES 原始分,还要结合多个维度:

1
2
3
4
向量相似度
全文 token 命中相似度
rerank 模型分数
rank feature

其中 token_similarity() 还会用 term_weight.weights() 重新计算 query token 和 chunk token 的重合程度。

所以图里的:

1
2
search_v2.py
-> rerank_by_model/token_similarity

这让分词器不只影响“能不能召回”,也影响“召回后谁排前面”。

2.1.6. fallback链路

图里最后这条链路:

1
2
3
search_v3.py
-> 弱召回时 fusion fallback
-> query.py

这是一条兜底的路线,如果 RagTokenizer 对某些游戏领域 query 切得不好,导致召回弱,search_v3 会尝试融合分词 fallback,比如引入额外 token,再重新走 FulltextQueryer.question() 拼查询表达式。

例如我们测试中会遇到如下问题:

1
2
3
4
玩家问:据点战奖励
主分词召回太少
fallback 补充:据点 / 战 / 奖励 / 血盟据点战
再查一次

具体的 fallback实现,详见第5章[6. fallback逻辑]。

综上所述,rag_tokenizer.pyRAG 词法检索的底座

  • 离线入库时,它把文档 chunk 切成 ES 可查字段;
  • 在线查询时,它把用户问题切成 query token、细粒度 token、同义词和权重表达式;
  • 检索后重排时,它还参与 token 相似度计算。 这样文档侧和 query 侧使用同一套词法标准,才能让 ES 的全文检索、混合检索和 rerank 接得上。

3. 分词器小细节

看这结构rag_tokenizer.py,也知道了,他就是分词的核心呀!!! 需要处理很多逻辑:

1
2
3
4
5
6
7
8
9
原文
-> 归一化
-> 英文走 NLTK + 词形还原
-> 中文走 Trie 词典
-> 正向最大匹配
-> 反向最大匹配
-> 如果两边结果不一致,用 DFS 枚举切法
-> 根据词频/词性打分,选最优切分
-> merge_ 合并部分 token

3.1 归一化

它不是简单 jieba.cut(),他先要归一化,然后才能做分词: -> 去特殊符号 -> 全角转半角 -> 繁体转简体 -> 判断中英文 -> … 为啥搞得这么复杂,就是因为分词器是底层基础组件,要把能想到的通用操作都处理完备。


query 归一化先把用户输入的自然语言问题整理成更稳定、更适合检索的标准形式。

用户输入很随意,比如中文这样:

1
2
3
4
“七殺陣 怎么进??”
“七杀阵咋进去呀”
“七 杀 阵   奖励是啥”
“血盟據點戰有没有奖励?”

这些问题表达不同,但检索系统需要尽量识别成接近的形式:

1
2
3
七杀阵 进
七杀阵 奖励
血盟据点战 奖励

英文归一化,这也是为了统一:

1
2
running -> run
cars -> car

不过只处理纯英文、下划线、短横线组成的 token。

只有通过了归一化,后面的分词、同义词扩展、BM25、向量检索才能获得一个干净稳定的 query。

借助 HanziConv处理:

1
2
3
4
5
txt.lower()
rag_tokenizer.strQ2B(...)
rag_tokenizer.tradi2simp(...)
清理标点空格换行
rmWWW 去掉什么/怎么/哪里/是否//等弱检索词

3.2 准备字典

字典首先得有 key:

key_() / rkey_()

1
2
def key_(self, line):
    return str(line.lower().encode("utf-8"))[2:-1]

把词转成小写 UTF-8 字符串形式,作为 trie 的 key。

1
2
def rkey_(self, line):
    return str(("DD" + (line[::-1].lower())).encode("utf-8"))[2:-1]

把词反转后加 DD 前缀,用来支持后向最大匹配

然后加载字典loadDict_()

  • 读取词典文件词 频率 词性
  • 把词放进 trie
  • 把 log 后的词频和词性保存进去
  • 同时保存反向 key,供后向匹配使用
  • 最后生成 .trie 缓存文件

初始化字典类__init__()

  • DENOMINATOR = 1000000:词频还原用的基准。
  • DIR_ = .../rag/res/xy3:词典路径。
  • 初始化英文,用开源的 NLTK_DATA 的 stemmer、lemmatizer。
  • SPLIT_CHAR:用于把中文、英文、数字、标点拆成不同片段。
  • 优先加载 xy3xxx.txt.trie 缓存。
  • 如果缓存不存在或损坏,就从 xy3xxx.txt 重新构建 trie。

这是tokenizer 的基础,没有词典就只能退化成字符级切分。

3.3 多切词

为什么要正向 + 反向 + DFS,这么麻烦呢?

因为中文分词有歧义。比如:

1
血盟据点战奖励

可能切成:

1
2
血盟 / 据点战 / 奖励
血盟 / 据点 / 战 / 奖励

分次后词后还需要尝试把某些 token 合并merge()回来。

因为有可能:分隔符本身可能是词的一部分,比如游戏术语词、英文数字组合、带符号词。若合并后的词在词典中存在,就合并。

如果领域词典里有“据点战”,并且频率较高,score_() 会更倾向保留完整领域词。这个对游戏资料非常关键,因为玩法名、活动名、道具名如果被切碎,后面 ES 精确召回会变差。 至于怎么切的更多、怎么打的分、怎么切割的,得单独放到下一章[5. 切分的自定义逻辑]详细讲解。

4. 再说粗细粒度分词

还记得【#### 2.1.1. 离线入库链路】记录的content_ltks/content_sm_ltks字段吗? 代表了 chunk 的粗粒度分词细粒度分词

其中粗粒度就是直接用 rag_tokenizer.tokenize() 做主分词。它尽量保住领域实体完整性

比如:

1
血盟据点战在哪里参加

粗粒度希望切成:

1
血盟据点战 / 哪里 / 参加

而不是:

1
血盟 / 据点 / 战 / 哪里 / 参加

因为 血盟据点战 是一个完整玩法名,不能被切碎。

4.1 只有粗粒度够用吗?

有可能用户不一定每次都问完整词。文档里可能保存的是:

1
血盟据点战

用户却问:

1
2
3
据点战奖励
血盟战怎么进
据点在哪里打

如果索引里只有粗粒度 token 血盟据点战,那用户 query 里的 据点战 有可能匹配不到,召回就会变弱。

所以才需要 fine_grained_tokenize()细粒度。

它做的事可以理解为:在粗粒度实体保留的基础上,再补一份更细的可匹配 token。

当前代码里流程大概是:

1
2
3
4
5
输入粗粒度 token
-> 如果中文 token 足够多,进入细粒度逻辑
-> 对长度 >= 3 的中文词尝试再次 DFS 切分
-> 取一个更细的候选切法
-> 输出细粒度 token

举个理想化例子:

1
2
粗粒度 content_ltks:
血盟据点战 奖励 说明

细粒度 content_sm_ltks 可能补成类似:

1
血盟 据点 战 奖励 说明

这样 ES 里同时有两套字段:

1
2
content_ltks      偏精准:血盟据点战
content_sm_ltks   偏召回:血盟 / 据点 / 战

在线 query 检索时,FulltextQueryer 会同时查这些字段:

1
2
3
4
"title_tks^10",
"title_sm_tks^5",
"content_ltks^2",
"content_sm_ltks",

所以:

  • 用户问完整词:血盟据点战,命中粗粒度字段,精准。
  • 用户问半截词:据点战奖励,细粒度字段也能帮忙召回。
  • 用户问很泛:战奖励,可能召回更多噪声,所以细粒度字段权重更低。

粗粒度负责“不切坏游戏专有名词”,细粒度负责“用户只问一部分时也能搜到”。两者配合完成相对准确的召回。

4.2. 权重问题

分了粗细粒度字段,那哪个重要呢?

由此引出了“权重分配”的问题,设计分三层:字段权重、查询表达式权重、检索融合/重排权重。 这样设计也是经过实践总结的:只有通过了分层权重,才能更好的设计后面的评估系统

第一层是 ES 字段权重,:

1
2
3
4
5
6
7
8
9
[
  "title_tks^10",
  "title_sm_tks^5",
  "important_kwd^30",
  "important_tks^20",
  "question_tks^20",
  "content_ltks^2",
  "content_sm_ltks",
]

同一个 query token,命中不同字段,分数不一样。

优先级如下排列:

1
2
3
4
5
6
important_kwd^30
> important_tks^20 / question_tks^20
> title_tks^10
> title_sm_tks^5
> content_ltks^2
> content_sm_ltks^1

这样设计的原因是:在 RAG 里,不是所有命中都同等可信

比如用户问:

1
血盟据点战奖励

如果这个词命中了:

1
important_kwd

说明它是文档抽取出的关键词,权重最高。

如果命中了:

1
title_tks

说明这个 chunk 所在标题就叫“血盟据点战奖励”,比正文里偶然出现一次更可靠。

如果只命中了:

1
content_sm_ltks

说明只是细粒度 token 命中,比如“据点”“奖励”,它更像召回兜底,不能给太高分,否则会召回很多泛泛相关内容。

第二层是 query 表达式内部的权重。

FulltextQueryer.question() 不只是把 query 分词后丢给 ES,它会拼出类似这种查询逻辑:

1
血盟 OR 据点战 OR ("血盟 据点战"~2)^1.5

里面会有几类权重:

1
2
3
4
原始词 / 粗粒度词:主权重
细粒度词:补召回,权重更低
同义词:扩召回,权重更低,例如 ^0.2
短语邻近匹配:更可信,权重更高,例如 ^1.5

原因也很实际:
用户搜“据点战奖励”,如果文档写的是“血盟据点战奖励”,细粒度 token 可以召回;但如果用户搜“血盟据点战”,完整词命中应该比“血盟 + 据点 + 战”这种细粒度命中更强。所以粗粒度负责精准,细粒度只是负责兜底。

第三层是全文检索和向量检索的融合权重:

1
FusionExpr("weighted_sum", topk, {"weights": "0.1, 0.9"})

这个在 ES 层会被 es_conn.py 解析成:

1
2
文本检索权重:0.1
向量检索权重:0.9

但这个不能理解成“BM25 不重要”。文本检索仍然很重要,能保证某些老玩家或者专业玩家问:

1
2
3
4
5
精确术语命中
标题 / 关键词字段加权
领域词约束
候选过滤
避免纯向量语义漂移

向量权重大,是因为还是有很多玩家query 往往是随机问的,太口语化了,比如:

1
玩家怎么提升战力

文档可能写:

1
角色养成、装备强化、技能升级、坐骑培养

这时候向量相似度更容易召回语义相关内容。

最后还有一层 rerank,不属于 ES 查询表达式,而是 ES 召回后的二次排序。当前 retrieve_content() 会传 vector_similarity_weight=0.6,所以重排阶段可以理解成:

1
2
最终排序 ≈ 0.4 * 关键词/结构化命中分
       + 0.6 * rerank 语义相关分

面对游戏中五花八门的的 query 问句,我没有设计成向量 topK简单做召回,而是通过分层设计,把有可能出问题的环节拆解成可以量化的指标了:

  1. ES 字段层让标题、关键词、问题字段优先,解决“命中哪里更重要”
  2. query 表达式层让完整词、短语邻近匹配优于细粒度和同义词扩展,解决“什么类型的命中更可信”
  3. 召回层用文本检索兜住实体和术语,用向量检索补语义泛化,解决“精确匹配和语义匹配怎么平衡”
  4. 最后再用 rerank 对候选重新排序,降低同义词和细粒度扩召回带来的噪声,解决“初召回候选里谁最应该排前面”

5. 切分的自定义逻辑

既然“大模型不能理解我们的游戏”,那就靠“领域词典 + 最大匹配 + 歧义评分”实现一个自定义切分,让他们明白。

5.1. 领域词典+评分规则

自定义分词器相对能切准的前提是:专有名词进入了词典,这怎么强调都不为过。

我经过前期的数据清洗、构建了专有名词词典xy3_userdict.txt,标准格式是:

1
词 频率 词性

比如里有:

1
2
3
血盟据点战 9000 n
装备强化 9000 n
跨服阪泉擂台赛 9000 n

rag_tokenizer.py 加载词典时,会把每个词写进 Trie:

1
2
self.trie_[self.key_(line[0])] = (F, line[2])
self.trie_[self.rkey_(line[0])] = 1

这里存了两份:

1
2
正向 key:血盟据点战
反向 key:战点据盟血

它既能从左往右匹配,也能从右往左匹配。 -> 如果两个结果一致,直接接受 -> 如果两个结果不一致,说明有歧义 -> 对歧义片段 DFS 枚举所有可能切法 -> 用 score_() 打分 -> 选择得分最高的切法

显而易见,重点是 score_(),它不是随便选一个切法,而是按几个因素打分:

1
return tks, B / len(tks) + L + F

这个逻辑也就是

1
2
3
B / len(tks):token 越少越好,倾向保留长词
L:多字词比例越高越好,单字词越多越差
F:词典频率越高越好

以上面的词典为例血盟据点战 9000 n,它会更倾向切成血盟据点战 / 奖励,而不是血盟 / 据点 / 战 / 奖励

因为前者token 数更少、单字更少、领域词频率更高,这多亏了组里算法大佬的分享啊。

这就是“不用 jieba 也能切游戏专名”的关键:不是靠通用分词模型猜,而是靠我们把游戏资料里的玩法名、活动名、系统名、道具名沉淀成领域词典,再用 Trie 和评分规则强约束切分结果。

5.2. 最大匹配

还是以这句血盟据点战奖励为例,如果词典里有这些词:

1
2
3
4
血盟
据点战
血盟据点战
奖励

那 DFS 可能枚举出多种候选:

1
2
3
血盟据点战 / 奖励
血盟 / 据点战 / 奖励
血盟 / 据点 / 战 / 奖励

然后评分。

第一种:

1
血盟据点战 / 奖励

token 少,都是多字词,而且 血盟据点战 在领域词典里频率高,所以分高。

第二种:

1
血盟 / 据点战 / 奖励

也可以,但 token 更多,整体专名被拆开,分会低一点。

第三种:

1
血盟 / 据点 / 战 / 奖励

有单字“战”,token 数更多,分更低,通常不会被选中。

所以最终粗粒度字段 content_ltks 更可能保留:

1
血盟据点战 奖励

这个对 RAG 很重要。

因为 ES 里如果存的是:

1
血盟 据点 战 奖励

用户搜“血盟据点战”,就只能靠多个散词命中,容易召回“血盟系统”“据点介绍”“战斗奖励”这种泛内容。

但如果存的是:

1
血盟据点战 奖励

用户 query 也切出“血盟据点战”,那就是专名对专名,召回会更准。

所以我这套手写分词的最终实践就是:

  • 通用词典解决基础中文切词,领域词典补游戏专名
  • Trie 负责高效前缀匹配
  • 正反向最大匹配发现歧义
  • DFS 枚举候选切法
  • score_算法用词频、词数、单字比例选择最像领域表达的切法
  • 细粒度 token 再兜底用户残缺问法

6. fallback 逻辑

虽然有兜底策略,但是要知道什么时候才需要启动兜底,quality.py实现逻辑:

1
2
3
4
5
6
def is_weak_recall(total: int, ids: list[str], requested_size: int) -> bool:
    if total == 0:
        return True

    min_expected = min(8, requested_size)
    return len(ids) < min_expected

也就是说有两个情况需要启动兜底测罗:

  1. total == 0,完全没召回到
  2. 返回 chunk 数量 < min(8, requested_size),虽然有结果,但候选太少

第一个很好理解,但是为什么候选太少也算弱?
因为 RAG 不是只要“有一个结果”就够了。后面还要 rerank、组和prompt、引用。 如果 ES 第一阶段只召回 1-2 个 chunk,rerank 没有足够候选可排,最终答案很容易漏信息。


不过对于兜底fallback的理解要正确:它不是重新解析文档,也不是重新写 ES。

它只是在在线 query 阶段重新构造一次全文检索表达式

1
2
3
4
match_text, keywords = self.qryr.question(qst, min_match=0.3)
match_dense = self.get_vector(qst, emb_mdl, topk, req.get("similarity", 0.1))
fusion_expr = FusionExpr("weighted_sum", topk, {"weights": "0.05, 0.95"})
res = self.dataStore.search(...)

第一次正常召回后,检查:

1
2
if is_weak_recall(total, ids, ps):
    fallback_res, fallback_total, fallback_keywords = self._fallback_search(...)

业务逻辑上,依然是

1
2
3
4
先走标准 RagTokenizer 检索
-> 看召回结果是否太少
-> 如果弱召回,再走 fallback 检索
-> fallback 更好才替换原结果

6.1 需要重新分词吗?

需要,但只重新处理 query,不处理文档:

  • 文档侧:不变,ES 里还是原来的 content_ltks/content_sm_ltks/title_tks/q_xxx_vec
  • query 侧:重新构造一次 MatchTextExpr

但是分词都重新处理了,向量需要重新计算吗? 不重新生成 embedding,复用第一次 query的 embedding。

因为 query 原文没变,只是全文检索 token 扩展了。embedding 对应的是原始 query 语义,没必要再调一次 embedding 模型,省耗时和成本

1
2
3
4
5
6
7
8
fallback_dense = MatchDenseExpr(
    match_dense.vector_column_name,
    match_dense.embedding_data,
    match_dense.embedding_data_type,
    match_dense.distance_type,
    match_dense.topn,
    {"similarity": fallback_similarity},
)

所以只需要在 query 侧修改

1
2
3
fallback_match_text, fallback_keywords = self.qryr.question(
    qst, min_match=0.1, tokenizer_mode="fusion"
)

这里有两个变化:

  1. min_match 从 0.3 降到 0.1
  2. tokenizer_mode 从 rag 变成 fusion

min_match 降低,是让 ES 不要求 query token 命中比例太高。
fusion 模式,是引入额外 token。


额外 token 从哪里来?

在抽象的策略层中,strategies.py封装了不同的 token来源:

1
2
3
4
5
6
7
8
def rag_tokens(text: str) -> list[str]:
    return _clean_tokens(rag_tokenizer.tokenize(text).split())

def jieba_tokens(text: str) -> list[str]:
    return _clean_tokens(jieba.lcut(text))

def fusion_tokens(text: str) -> list[str]:
    return _dedupe(rag_tokens(text) + jieba_tokens(text))

fallback 的额外 token 来自:

1
2
3
4
5
6
7
RagTokenizer token
+
jieba token
+
去重
+
去掉空 token / 标点 token

举个🌰,有个玩家问:

1
据点战奖励怎么领

RagTokenizer 可能给出:

1
据点战 奖励 领

jieba 可能补出:

1
据点 战 奖励 怎么 领

fusion 后就可能变成:

1
据点战 奖励 领 据点 战

这些额外 token 会被追加到 ES query 里。

6.2 怎么追加?

这节就是保证丢失的召回,能有内容了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def _append_fusion_terms(self, query, txt, keywords, boost=0.6):
    extra_terms = []
    for tk in fusion_tokens(txt):
        tk = re.sub(r"[ \\\"']+", "", tk)
        tk = FulltextQueryer.subSpecialChar(tk)
        if len(tk) < 2 or tk in keywords:
            continue
        keywords.append(tk)
        extra_terms.append(f'"{tk}"^{boost}' if tk.find(" ") > 0 else f"{tk}^{boost}")

    if not extra_terms:
        return query

    return f"({query}) OR ({' '.join(extra_terms)})"

精简后的逻辑:

  1. 只加原 query 里没有的 token
  2. 长度小于 2 的 token 不加
  3. 对 ES 特殊字符转义
  4. 额外 token boost = 0.6
  5. 用 OR 接到原查询表达式后面

有个经验值问题,为什么 boost 只有 0.6

因为这些 token 是 fallback 补召回用的,不应该压过主分词结果。

主分词可能是:

1
血盟据点战

jieba 额外补的是:

1
血盟 / 据点 / 战

如果给额外 token 太高权重,ES 可能召回很多“血盟系统”“据点介绍”“战斗奖励”这种噪声。所以它们只做低权重扩展。

最终 query 形态是:

1
2
3
(原来的 RagTokenizer 查询表达式)
OR
(据点^0.6 战^0.6 奖励^0.6)

如果第一次完全没结果,还有额外放宽:

1
2
3
if total == 0:
    fallback_filters.pop("doc_ids", None)
    fallback_similarity = 0.17
  1. 如果限定了 doc_ids,先去掉文档范围限制
  2. 调整向量 similarity 阈值
  3. 给系统一次更宽的召回机会

但是这属于“空召回兜底”。

6.3 兜底一定会替换原结果吗?

不会,它有一个简单质量门控:

1
2
3
4
if fallback_total > total or len(fallback_ids) > len(ids):
    return fallback_res, fallback_total, fallback_keywords

return None, total, []

只有当fallback 召回总数更多时,才替换主结果:

6.4 完整兜底方案:

最终经过多次测试,我设计的兜底方案是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
用户 query
-> search_v3 第一次检索
   -> RagTokenizer 分词
   -> query_string + vector knn
   -> ES 返回结果
-> 判断弱召回
   -> total == 0 或 ids 数量 < min(8, requested_size)
-> 触发 fallback
   -> fusion_tokens = RagTokenizer tokens + jieba tokens
   -> 重新构造 MatchTextExpr
   -> min_match 降到 0.1
   -> 复用第一次 query embedding
   -> 重新查 ES
-> 如果 fallback 候选更多
   -> 用 fallback 结果
-> 否则
   -> 保留第一次结果