OceanBase源码解读:IK中文分词解析器 ob_ik_ft_parser

全文检索(Full Text Search)的第一步永远是把一整段文本切成一个个”词”(token),这一步叫分词。对英文来说按空格切就够了,但中文没有空格边界,”我喜欢OceanBase数据库”必须切成”我 / 喜欢 / OceanBase / 数据库”才能建倒排索引。OceanBase 4.3 引入全文检索能力时,在 storage/fts 模块内置了 IK 中文分词器作为默认选择,ob_ik_ft_parser.cpp 就是它的核心实现。这个文件不足五百行,却浓缩了一套完整的流水线设计:插件描述符 ObIKFTParserDesc 对外提供工厂入口,解析器本体 ObIKFTParser 同时扮演迭代器角色,内部由”四类分词处理器 + 一个仲裁器”接力完成切词与歧义裁决,词典则交给全局词典枢纽 ObFTDictHub 统一托管缓存。本文沿着”初始化装配 → 懒产出拉取 → 批次切分 → 仲裁输出”的主线,把这个中文分词器的骨架讲清楚。

一、架构位置与双层结构

在 OceanBase 全文索引的建立链路里,上层引擎拿到一列文本数据后,会调用分词器插件把文本变成词项流(token stream),词项再写入全文倒排索引。分词器在 OB 里以插件形式注册,每种分词器由两部分组成:描述符(Desc)负责对外元信息与生命周期,解析器(Parser)负责真正的切词。IK 分词器对应的就是 ObIKFTParserDesc 与 ObIKFTParser 这一对。设计上有个巧妙之处:ObIKFTParser 本身实现了 ObITokenIterator 接口,工厂方法 segment() 返回的迭代器就是解析器自己,上层逐词调用 get_next_token() 拉取即可,不需要额外的中间对象。

描述符还向索引层声明了词项归一化约定:casedown 表示英文统一转小写、groupby_word 表示相同词项要聚合计数。这些约定决定了倒排索引建词项表时的合并策略——分词器只管切词,归一化与词频聚合由索引层按声明执行,职责边界干净。

二、核心数据结构

TokenizeContext(分词上下文)是整条流水线的”工作台”:持有 collation 排序规则、原始全文缓冲、字符游标、各分词处理器累计的中间候选词,以及最终的 result_list 输出列表。所有处理器都围绕同一个上下文协作,字符游标由上下文统一推进。

segmenters_(分词器链)是四个有序的处理器的列表,顺序不可乱:

  • ObIKLetterProcessor:字母与英文单词,做大小写归一;
  • ObIKQuantifierProcessor:中文数量词(依赖量词词典);
  • ObIKCJKProcessor:中日韩文字处理(依赖主词典,IK 的灵魂);
  • ObIKSurrogateProcessor:UTF-16 代理对兜底(emoji 等 4 字节字符)。

量词必须排在 CJK 之前:连续的”数字+量词”(如”三个”)要先被量词处理器认领,否则会被 CJK 处理器拆散成单字。

词典三件套:main_dict 主词典(中文核心词库)、quan_dict 量词词典、stopword 停用词表,统一限定 utf8mb4 字符集与二元(BIN)排序规则,保证词典查找按字节精确匹配、不受业务表 collation 影响。三本词典都通过全局 ObFTDictHub 托管:优先加载现成缓存,缓存不存在才首次构建,构建结果全租户共享。

三、初始化瀑布:四步装配

init() 的初始化顺序有严格依赖,任何一步失败都会统一走 reset() 清理,保证”要么全成、要么干净退出”:

int ObIKFTParser::init(const ObFTParserParam &param)
{
  // 1) 校验并解析排序规则,非法直接报错
  if (OB_ISNULL(param.cs_) || OB_ISNULL(param.cs_->name)) {
    ret = OB_INVALID_ARGUMENT;
  } else if (CS_TYPE_INVALID ==
      (coll_type_ = ObCharset::collation_type(param.cs_->name))) {
    ret = OB_INVALID_ARGUMENT;
  // 2) 装配词典三件套(main / quan / stopword)
  } else if (OB_FAIL(init_dict(param))) {
  // 3) 创建分词上下文,同时解析 SMART / MAX_WORD 模式
  } else if (OB_FAIL(init_ctx(param))) {
  // 4) 按序装配四个分词处理器
  } else if (OB_FAIL(init_segmenter(param))) {
  }
  if (OB_FAIL(ret)) {
    reset();          // 失败统一清理
  } else {
    is_inited_ = true;
  }
  return ret;
}

上下文创建时顺带解析分词模式:SMART 是智能切分,仲裁后只输出最合理的词项组合;MAX_WORD 是最细粒度,输出所有可能的词项组合。用户建全文索引时通过参数选择,解析语义完全由这一个开关控制。

四、关键流程:懒产出 + 批次切分 + 仲裁

上层对分词器的消费方式是迭代器式的:反复调用 get_next_token() 直到 OB_ITER_END。这里有个经典的惰性求值设计——produce() 只在结果列表空了才干活:

int ObIKFTParser::produce()
{
  // 结果列表还有存货就直接返回,不干活
  while (OB_SUCC(ret) && ctx_->result_list().empty()
         && !ctx_->iter_end()) {
    if (OB_FAIL(process_next_batch())) { /* 切下一批 */ }
  }
  return ret;
}

process_next_batch() 是批次切分的主流程,分三步走。第一步逐字符消费:每次从上下文取出当前字符及其类型,交给分词器链轮流处理——每个处理器自己判断这个字符属不属于自己管辖,认领的就开始累计候选词,然后游标前移。第二步批次截断:当已消费字符数超过 SEGMENT_LIMIT 且当前字符恰好是空白、标点这类”无用字符”时提前收批——特意选在自然分隔点切批,既避免把一个词切成两半,也让每批的内存占用可控:

while (OB_SUCC(ret) && !do_seg && !ctx_->iter_end()) {
  // 取当前字符与类型,交给全部分词器轮流处理
  ctx_->current_char(ch, char_len);
  ctx_->current_char_type(type);
  process_one_char(*ctx_, ch, char_len, type);
  // 批次截断:超限 且 遇到空白/标点等自然分隔点
  if (ctx_->handle_size() > SEGMENT_LIMIT
      && type == ObFTCharUtil::CharType::USELESS) {
    do_seg = true;
  }
  ctx_->step_next();
}
// 本批结束:仲裁器做歧义裁决,输出最终词项
ObIKArbitrator arb;
arb.process(*ctx_);
arb.output_result(*ctx_);

第三步是仲裁:一批字符消费完后,ObIKArbitrator 对所有中间候选词做歧义裁决(最短路径/最大匹配策略),按 SMART 或 MAX_WORD 模式过滤后写入 result_list。也就是说,四个处理器只负责”提出候选”,最终哪个候选能成为输出词项,由仲裁器统一裁决——提出与裁决分离,这正是 IK 风格分词器的精髓。

get_next_token() 里还有一个值得注意的细节:停用词过滤的代码被注释掉了。因为停用词已经在仲裁阶段被过滤,这里不再重复查表——省掉一次词典匹配,是典型的”在正确的层做正确的事”。

五、设计动机小结

回顾这个不到五百行的文件,有三点设计值得学习。其一是流水线 + 单一职责:每类处理器只关心”自己认得什么字符”,认领后独立累计候选,扩展新语言只需增加处理器;提出候选与仲裁输出分离,策略可独立演化。其二是惰性批次产出:上层要多少切多少,批次大小受 SEGMENT_LIMIT 约束且只在自然分隔点截断,大文本分词的内存峰值可控。其三是词典与解析分离:三本词典全部托管给全局 DictHub 缓存,load-or-build 两段式让首次构建的成本只付一次,后续所有解析器实例共享;词典固定 BIN 排序规则又保证了查找的正确性不受业务 collation 影响。对于要在数据库内核里做中文全文检索的场景,这套骨架(处理器链 + 仲裁器 + 词典枢纽)即使脱离 OceanBase 也极具参考价值。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注