RAG设计之分词器架构实现
文章目录
分词器不属于单纯的文档解析器,也不属于向量模型,它属于 RAG 的词法检索基础层,是“检索质量增强组件”,核心是:
- 离线阶段,它在 chunk 生成之后,把 chunk 正文和标题转成
content_ltks、content_sm_ltks、title_tks等 ES 检索字段;- 在线阶段,它在 query 召回之前,对用户问题做同一套归一化、分词和细粒度扩展,用来构造全文检索表达式。
这样离线文档侧、query 侧 token 空间一致,混合检索里的 BM25/全文检索才能稳定工作。
游戏领域里它尤其重要,因为玩法名、活动名、职业名如果被通用分词切碎,会直接导致召回噪声和漏召。
1. 引言
分析架构之前,先回顾一下RAG 经典架构:
1.1. 离线阶段的位置
离线链路大概是:
|
|
在代码里,chunk 生成后会调用 tokenizer 生成两个核心字段:
content_ltks:粗粒度分词字段content_sm_ltks:细粒度分词字段
|
|
文档标题也会分词,进入标题检索字段:
|
|
在离线阶段把 chunk 正文和标题处理成 Elasticsearch 可检索的词项字段,为后续全文检索和混合检索做索引准备。
1.2. 在线阶段的位置
在线 query 链路大概是:
|
|
所谓的归一化要做:
- 小写化
- 全角转半角
- 繁体转简体
- 清理标点和疑问词
- 调用
rag_tokenizer.tokenize - 调用
fine_grained_tokenize - 查同义词
- 拼 ES
MatchTextExpr
所以rag_tokenizer.py 在线阶段属于“query 分析与召回表达式构造”流程。
1.3. 为什么它横跨离线和在线
因为文档侧和 query 侧必须使用同一套分词逻辑。
如果离线 chunk 被切成:
|
|
但在线 query 被切成:
|
|
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 系统里的上下游依赖图,很明显的可以看到分词器被两条主链路复用:
- 离线入库链路:文档 chunk -> 分词 -> 写入 ES token 字段
- 在线查询链路:用户 query -> 分词/权重/同义词/细粒度扩展 -> 拼 ES 查询表达式
2.1.1. 离线入库链路
业务处理逻辑是:
|
|
在file_parse.py中execute_insert_process() 先调用:
|
|
parse() 里面继续调用 naive.py 的 chunk()进行分块。
chunk() 逻辑是:
- 判断文件类型:pdf/docx/xlsx/txt/md/html/json
- 调不同 parser 把原始文件解析成 sections/tables
- 用 naive_merge() 把连续段落合成 chunk
- 调 tokenize_chunks() / tokenize_table() 给每个 chunk 生成 ES 字段
这里开始进入图里的第一条线:
|
|
真正写 token 字段的是 nlp/init.py:
|
|
这条链路给 RAG 提供了三个重要的信息:
- content_with_weight:原始 chunk 文本,给大模型引用和回答用
- content_ltks:粗粒度分词字段,给 ES 全文检索用
- content_sm_ltks:细粒度分词字段,给弱匹配/残缺问法兜底
比如文档 chunk 是:
|
|
可能会变成:
|
|
这样 ES 里既能搜完整领域词“据点战”,也能搜用户只输入的“据点 奖励”。
2.1.3. 在线 query 链路
这一节就和用户息息相关了,游戏玩家都是从这开始用的系统:
|
|
retrieve_content() 在 retrieval.py,它调用 Dealer.retrieval()。
到 search_v2.py:
|
|
这里同时做两件事:
|
|
FulltextQueryer.question() 在 query.py,它需要处理很多逻辑:
- query 归一化:全角半角、繁简体、标点、疑问词清理
- rag_tokenizer.tokenize() 分词 (这里又需要用到我的分词器)
- term_weight.weights() 给 token 算权重
- synonym.lookup() 找同义词
- fine_grained_tokenize() 做细粒度扩展
- 拼成 MatchTextExpr
最后es 表达式要查这些 ES 字段:
|
|
这些字段有没有很熟悉的?没错,其中就有[2.1.1. 离线入库链路]小节写进去的一部分
|
|
在线查询时会被同一套 query token 命中。
这就是图里最核心的意思: 1.离线:文档用 RagTokenizer 切成 token 存 ES 2. 在线:query 用 RagTokenizer 切成 token 查 ES
两边用同一个 tokenizer,为了让“文档侧 token 空间”和“查询侧 token 空间”一致。
至于ES字段为啥这么设计,得单独记录一篇文章了,毕竟这是另外同事实现的🤣,设计思路我可以搞来。
2.1.4. term_weight链路
图里这段:
|
|
意思是:查询不只是分词,还要给每个词算重要性。
比如:
|
|
怎么/怎么领 这种疑问词权重低,甚至会被清理;
血盟据点战、奖励 权重更高。
term_weight.py 会调用:
|
|
所以它也是 rag_tokenizer 的下游。
2.1.5. rerank/token_similarity链路
ES 初召回后,还会排序,注意⚠️,这里也是分层设计的,至于为什么,详细看[### 3.1. 权重问题]小节的内容
排序不能只看 ES 原始分,还要结合多个维度:
|
|
其中 token_similarity() 还会用 term_weight.weights() 重新计算 query token 和 chunk token 的重合程度。
所以图里的:
|
|
这让分词器不只影响“能不能召回”,也影响“召回后谁排前面”。
2.1.6. fallback链路
图里最后这条链路:
|
|
这是一条兜底的路线,如果 RagTokenizer 对某些游戏领域 query 切得不好,导致召回弱,search_v3 会尝试融合分词 fallback,比如引入额外 token,再重新走 FulltextQueryer.question() 拼查询表达式。
例如我们测试中会遇到如下问题:
|
|
具体的 fallback实现,详见第5章[6. fallback逻辑]。
综上所述,rag_tokenizer.py 是 RAG 词法检索的底座。
- 离线入库时,它把文档 chunk 切成 ES 可查字段;
- 在线查询时,它把用户问题切成 query token、细粒度 token、同义词和权重表达式;
- 检索后重排时,它还参与 token 相似度计算。 这样文档侧和 query 侧使用同一套词法标准,才能让 ES 的全文检索、混合检索和 rerank 接得上。
3. 分词器小细节
看这结构rag_tokenizer.py,也知道了,他就是分词的核心呀!!! 需要处理很多逻辑:
|
|
3.1 归一化
它不是简单 jieba.cut(),他先要归一化,然后才能做分词:
-> 去特殊符号
-> 全角转半角
-> 繁体转简体
-> 判断中英文
-> …
为啥搞得这么复杂,就是因为分词器是底层基础组件,要把能想到的通用操作都处理完备。
query 归一化先把用户输入的自然语言问题整理成更稳定、更适合检索的标准形式。
用户输入很随意,比如中文这样:
|
|
这些问题表达不同,但检索系统需要尽量识别成接近的形式:
|
|
英文归一化,这也是为了统一:
|
|
不过只处理纯英文、下划线、短横线组成的 token。
只有通过了归一化,后面的分词、同义词扩展、BM25、向量检索才能获得一个干净稳定的 query。
借助 HanziConv处理:
|
|
3.2 准备字典
字典首先得有 key:
key_() / rkey_():
|
|
把词转成小写 UTF-8 字符串形式,作为 trie 的 key。
|
|
把词反转后加 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,这么麻烦呢?
因为中文分词有歧义。比如:
|
|
可能切成:
|
|
分次后词后还需要尝试把某些 token 合并merge()回来。
因为有可能:分隔符本身可能是词的一部分,比如游戏术语词、英文数字组合、带符号词。若合并后的词在词典中存在,就合并。
如果领域词典里有“据点战”,并且频率较高,score_() 会更倾向保留完整领域词。这个对游戏资料非常关键,因为玩法名、活动名、道具名如果被切碎,后面 ES 精确召回会变差。
至于怎么切的更多、怎么打的分、怎么切割的,得单独放到下一章[5. 切分的自定义逻辑]详细讲解。
4. 再说粗细粒度分词
还记得【#### 2.1.1. 离线入库链路】记录的content_ltks/content_sm_ltks字段吗? 代表了 chunk 的粗粒度分词和细粒度分词。
其中粗粒度就是直接用 rag_tokenizer.tokenize() 做主分词。它尽量保住领域实体完整性。
比如:
|
|
粗粒度希望切成:
|
|
而不是:
|
|
因为 血盟据点战 是一个完整玩法名,不能被切碎。
4.1 只有粗粒度够用吗?
有可能用户不一定每次都问完整词。文档里可能保存的是:
|
|
用户却问:
|
|
如果索引里只有粗粒度 token 血盟据点战,那用户 query 里的 据点战 有可能匹配不到,召回就会变弱。
所以才需要 fine_grained_tokenize()细粒度。
它做的事可以理解为:在粗粒度实体保留的基础上,再补一份更细的可匹配 token。
当前代码里流程大概是:
|
|
举个理想化例子:
|
|
细粒度 content_sm_ltks 可能补成类似:
|
|
这样 ES 里同时有两套字段:
|
|
在线 query 检索时,FulltextQueryer 会同时查这些字段:
|
|
所以:
- 用户问完整词:
血盟据点战,命中粗粒度字段,精准。 - 用户问半截词:
据点战奖励,细粒度字段也能帮忙召回。 - 用户问很泛:
战奖励,可能召回更多噪声,所以细粒度字段权重更低。
粗粒度负责“不切坏游戏专有名词”,细粒度负责“用户只问一部分时也能搜到”。两者配合完成相对准确的召回。
4.2. 权重问题
分了粗细粒度字段,那哪个重要呢?
由此引出了“权重分配”的问题,设计分三层:字段权重、查询表达式权重、检索融合/重排权重。 这样设计也是经过实践总结的:只有通过了分层权重,才能更好的设计后面的评估系统。
第一层是 ES 字段权重,:
|
|
同一个 query token,命中不同字段,分数不一样。
优先级如下排列:
|
|
这样设计的原因是:在 RAG 里,不是所有命中都同等可信。
比如用户问:
|
|
如果这个词命中了:
|
|
说明它是文档抽取出的关键词,权重最高。
如果命中了:
|
|
说明这个 chunk 所在标题就叫“血盟据点战奖励”,比正文里偶然出现一次更可靠。
如果只命中了:
|
|
说明只是细粒度 token 命中,比如“据点”“奖励”,它更像召回兜底,不能给太高分,否则会召回很多泛泛相关内容。
第二层是 query 表达式内部的权重。
FulltextQueryer.question() 不只是把 query 分词后丢给 ES,它会拼出类似这种查询逻辑:
|
|
里面会有几类权重:
|
|
原因也很实际:
用户搜“据点战奖励”,如果文档写的是“血盟据点战奖励”,细粒度 token 可以召回;但如果用户搜“血盟据点战”,完整词命中应该比“血盟 + 据点 + 战”这种细粒度命中更强。所以粗粒度负责精准,细粒度只是负责兜底。
第三层是全文检索和向量检索的融合权重:
|
|
这个在 ES 层会被 es_conn.py 解析成:
|
|
但这个不能理解成“BM25 不重要”。文本检索仍然很重要,能保证某些老玩家或者专业玩家问:
|
|
向量权重大,是因为还是有很多玩家query 往往是随机问的,太口语化了,比如:
|
|
文档可能写:
|
|
这时候向量相似度更容易召回语义相关内容。
最后还有一层 rerank,不属于 ES 查询表达式,而是 ES 召回后的二次排序。当前 retrieve_content() 会传 vector_similarity_weight=0.6,所以重排阶段可以理解成:
|
|
面对游戏中五花八门的的 query 问句,我没有设计成向量 topK简单做召回,而是通过分层设计,把有可能出问题的环节拆解成可以量化的指标了:
- ES 字段层让标题、关键词、问题字段优先,解决“命中哪里更重要”
- query 表达式层让完整词、短语邻近匹配优于细粒度和同义词扩展,解决“什么类型的命中更可信”
- 召回层用文本检索兜住实体和术语,用向量检索补语义泛化,解决“精确匹配和语义匹配怎么平衡”
- 最后再用 rerank 对候选重新排序,降低同义词和细粒度扩召回带来的噪声,解决“初召回候选里谁最应该排前面”
5. 切分的自定义逻辑
既然“大模型不能理解我们的游戏”,那就靠“领域词典 + 最大匹配 + 歧义评分”实现一个自定义切分,让他们明白。
5.1. 领域词典+评分规则
自定义分词器相对能切准的前提是:专有名词进入了词典,这怎么强调都不为过。
我经过前期的数据清洗、构建了专有名词词典xy3_userdict.txt,标准格式是:
|
|
比如里有:
|
|
rag_tokenizer.py 加载词典时,会把每个词写进 Trie:
|
|
这里存了两份:
|
|
它既能从左往右匹配,也能从右往左匹配。 -> 如果两个结果一致,直接接受 -> 如果两个结果不一致,说明有歧义 -> 对歧义片段 DFS 枚举所有可能切法 -> 用 score_() 打分 -> 选择得分最高的切法
显而易见,重点是 score_(),它不是随便选一个切法,而是按几个因素打分:
|
|
这个逻辑也就是
|
|
以上面的词典为例血盟据点战 9000 n,它会更倾向切成血盟据点战 / 奖励,而不是血盟 / 据点 / 战 / 奖励
因为前者token 数更少、单字更少、领域词频率更高,这多亏了组里算法大佬的分享啊。
这就是“不用 jieba 也能切游戏专名”的关键:不是靠通用分词模型猜,而是靠我们把游戏资料里的玩法名、活动名、系统名、道具名沉淀成领域词典,再用 Trie 和评分规则强约束切分结果。
5.2. 最大匹配
还是以这句血盟据点战奖励为例,如果词典里有这些词:
|
|
那 DFS 可能枚举出多种候选:
|
|
然后评分。
第一种:
|
|
token 少,都是多字词,而且 血盟据点战 在领域词典里频率高,所以分高。
第二种:
|
|
也可以,但 token 更多,整体专名被拆开,分会低一点。
第三种:
|
|
有单字“战”,token 数更多,分更低,通常不会被选中。
所以最终粗粒度字段 content_ltks 更可能保留:
|
|
这个对 RAG 很重要。
因为 ES 里如果存的是:
|
|
用户搜“血盟据点战”,就只能靠多个散词命中,容易召回“血盟系统”“据点介绍”“战斗奖励”这种泛内容。
但如果存的是:
|
|
用户 query 也切出“血盟据点战”,那就是专名对专名,召回会更准。
所以我这套手写分词的最终实践就是:
- 通用词典解决基础中文切词,领域词典补游戏专名
- Trie 负责高效前缀匹配
- 正反向最大匹配发现歧义
- DFS 枚举候选切法
- score_算法用词频、词数、单字比例选择最像领域表达的切法
- 细粒度 token 再兜底用户残缺问法
6. fallback 逻辑
虽然有兜底策略,但是要知道什么时候才需要启动兜底,quality.py实现逻辑:
|
|
也就是说有两个情况需要启动兜底测罗:
- total == 0,完全没召回到
- 返回 chunk 数量 < min(8, requested_size),虽然有结果,但候选太少
第一个很好理解,但是为什么候选太少也算弱?
因为 RAG 不是只要“有一个结果”就够了。后面还要 rerank、组和prompt、引用。
如果 ES 第一阶段只召回 1-2 个 chunk,rerank 没有足够候选可排,最终答案很容易漏信息。
不过对于兜底fallback的理解要正确:它不是重新解析文档,也不是重新写 ES。
它只是在在线 query 阶段重新构造一次全文检索表达式。
|
|
第一次正常召回后,检查:
|
|
业务逻辑上,依然是
|
|
6.1 需要重新分词吗?
需要,但只重新处理 query,不处理文档:
- 文档侧:不变,ES 里还是原来的 content_ltks/content_sm_ltks/title_tks/q_xxx_vec
- query 侧:重新构造一次 MatchTextExpr
但是分词都重新处理了,向量需要重新计算吗? 不重新生成 embedding,复用第一次 query的 embedding。
因为 query 原文没变,只是全文检索 token 扩展了。embedding 对应的是原始 query 语义,没必要再调一次 embedding 模型,省耗时和成本
|
|
所以只需要在 query 侧修改
|
|
这里有两个变化:
- min_match 从 0.3 降到 0.1
- tokenizer_mode 从 rag 变成 fusion
min_match 降低,是让 ES 不要求 query token 命中比例太高。
fusion 模式,是引入额外 token。
额外 token 从哪里来?
在抽象的策略层中,strategies.py封装了不同的 token来源:
|
|
fallback 的额外 token 来自:
|
|
举个🌰,有个玩家问:
|
|
RagTokenizer 可能给出:
|
|
jieba 可能补出:
|
|
fusion 后就可能变成:
|
|
这些额外 token 会被追加到 ES query 里。
6.2 怎么追加?
这节就是保证丢失的召回,能有内容了
|
|
精简后的逻辑:
- 只加原 query 里没有的 token
- 长度小于 2 的 token 不加
- 对 ES 特殊字符转义
- 额外 token boost = 0.6
- 用 OR 接到原查询表达式后面
有个经验值问题,为什么 boost 只有 0.6?
因为这些 token 是 fallback 补召回用的,不应该压过主分词结果。
主分词可能是:
|
|
jieba 额外补的是:
|
|
如果给额外 token 太高权重,ES 可能召回很多“血盟系统”“据点介绍”“战斗奖励”这种噪声。所以它们只做低权重扩展。
最终 query 形态是:
|
|
如果第一次完全没结果,还有额外放宽:
|
|
- 如果限定了 doc_ids,先去掉文档范围限制
- 调整向量 similarity 阈值
- 给系统一次更宽的召回机会
但是这属于“空召回兜底”。
6.3 兜底一定会替换原结果吗?
不会,它有一个简单质量门控:
|
|
只有当fallback 召回总数更多时,才替换主结果:
6.4 完整兜底方案:
最终经过多次测试,我设计的兜底方案是:
|
|
文章作者 沐桢