OceanBase源码解读:N-gram全文分词解析器 ob_ngram_ft_parser

在 OceanBase 4.3 的全文检索(FTS)体系里,分词器是以插件形式挂载的,前面几篇我们已经看过空白分词器和 IK 中文分词器。这篇要解读的是家族里最”简单粗暴”的一位——N-gram 分词器(源码:storage/fts/ob_ngram_ft_parser.cpp,全文仅 156 行)。它的切词哲学是不依赖任何词典、不做任何语言理解,直接把文本按 N 个字符的滑动窗口硬切:比如 “数据库” 以 ngram_token_size=2 切出来就是 “数据”、”据库” 两个词项。好处是天然支持任意语言、绝对零漏召回(任何查询串都能命中);代价是词项数量暴涨、索引体积膨胀,而且词项没有语义边界。全文检索在建索引和查询两侧都调用同一个分词器,所以两侧的一致性由插件机制天然保证。

双层结构:Parser 与 Desc

和所有 FTS 分词插件一样,ngram 采用了统一的”双层结构”:ObNgramFTParser 是具体干活的迭代器,持有切词实现 ObFTNgramImpl(在 utils/ob_ft_ngram_impl.h 中),逐个产出词项;ObNgramFTParserDesc 是插件工厂和门面,全局一份,负责生命周期管理和按需分配 Parser 实例。这种”Desc 一对多 Parser”的模式,让每次查询或写索引都能拥有独立的迭代器状态,互不干扰。

//【架构示意】
ObNgramFTParserDesc(全局唯一,插件工厂)
   └── segment(param, iter)   ← 每次分词请求现场 new 一个
         └── ObNgramFTParser(迭代器实例)
               └── ObFTNgramImpl(真正的滑动窗口切词实现)

核心数据结构与初始化

Parser 本身几乎没有状态,只有两个成员:ngram_impl_(值成员,直接内联构造)和 is_inited_(初始化守卫)。初始化入口 init() 遵循整个解析器族的统一惯例:先用 is_inited_ 拦截重复初始化(返回 OB_INIT_TWICE),再校验入参四件套——param 指针、字符集 cs_、全文文本 fulltext_、文本长度 ft_length_,最后把参数转发给 ngram_impl_。值得注意的是,min/max 两个 token size 都传了 ngram_token_size_,即 N-gram 窗口是固定的,不存在变长窗口。

int ObNgramFTParser::init(ObFTParserParam *param)
{
  int ret = OB_SUCCESS;
  if (OB_UNLIKELY(is_inited_)) {
    ret = OB_INIT_TWICE;                 // 重复初始化直接拒绝
  } else if (OB_ISNULL(param)
      || OB_ISNULL(param->cs_)
      || OB_ISNULL(param->fulltext_)
      || OB_UNLIKELY(0 >= param->ft_length_)) {
    ret = OB_INVALID_ARGUMENT;           // 入参四件套校验
  } else if (OB_FAIL(ngram_impl_.init(param->cs_,
                                      param->fulltext_,
                                      param->ft_length_,
                                      param->ngram_token_size_,   // min
                                      param->ngram_token_size_))) { // max:固定窗口
    LOG_WARN("fail to init ngram impl", K(ret), KPC(param));
  } else {
    is_inited_ = true;                   // 全部成功才置位,原子语义
  }
  if (OB_FAIL(ret) && OB_UNLIKELY(!is_inited_)) {
    reset();                             // 失败统一收尾,不留半初始化对象
  }
  return ret;
}

这段代码体现了 OceanBase 一贯的”要么完全初始化、要么完全没初始化”的原子性设计:只有所有步骤都成功才置 is_inited_,任何失败都会触发统一 reset,杜绝半初始化对象流向调用方。

词项迭代:get_next_token

Parser 实现 ObITokenIterator 接口,核心是 get_next_token()。它的出参是一个四元组:word 指向原文内部的词项指针(不拷贝,零开销)、word_len 是字节长度、char_len 是字符数(多字节字符集下两者不同)、word_freq 是词频——恒为 1,因为词频统计由上层的 ObAddWord 收集器负责归并,迭代器只管按顺序吐词。

int ObNgramFTParser::get_next_token(
    const char *&word, int64_t &word_len,
    int64_t &char_len, int64_t &word_freq)
{
  int ret = OB_SUCCESS;
  // ... 出参先清零,保证失败时状态干净
  if (OB_UNLIKELY(!is_inited_)) {
    ret = OB_NOT_INIT;
  } else if (OB_FAIL(ngram_impl_.get_next_token(word, word_len,
                                                char_len, word_freq))) {
    if (OB_ITER_END == ret) {
      // 流结束是正常信号,不打 WARN,上层靠它判断迭代完成
    } else {
      LOG_WARN("fail to get next token", K(ret));
    }
  }
  return ret;
}

本层几乎是纯透传——所有滑动窗口的切词细节都在 ObFTNgramImpl 里,Parser 只做状态守卫和错误翻译。一个容易被忽略的细节是 OB_ITER_END 被特意排除在告警之外:它是迭代协议的正常终止信号,如果打成 WARN 会污染日志。

插件工厂:segment 与 free_token_iter

Desc::segment() 是插件工厂的核心方法,展示了 OceanBase 插件内存纪律的教科书式写法:用 param->allocator_(调用方传入的扫描级内存分配器)分配 Parser;任何一步失败都走 OB_DELETex 回滚;成功则把所有权移交给出参 iter。上层用完后必须配对调用 free_token_iter() 归还。

int ObNgramFTParserDesc::segment(
    ObFTParserParam *param, ObITokenIterator *&iter) const
{
  // ... 状态与入参校验
  } else if (OB_ISNULL(parser = OB_NEWx(ObNgramFTParser,
                                        param->allocator_))) {
    ret = OB_ALLOCATE_MEMORY_FAILED;     // 分配失败
  } else {
    if (OB_FAIL(parser->init(param))) {
      // init 失败不 return,落到下面的统一回滚
    } else {
      iter = parser;                     // 所有权移交
    }
  }
  if (OB_FAIL(ret)) {
    OB_DELETex(ObNgramFTParser, param->allocator_, parser);  // 统一回滚
  }
  return ret;
}

void ObNgramFTParserDesc::free_token_iter(
    ObFTParserParam *param, ObITokenIterator *&iter) const
{
  if (OB_NOT_NULL(iter)) {
    abort_unless(nullptr != param);
    abort_unless(nullptr != param->allocator_);
    iter->~ObITokenIterator();           // 显式析构(placement new 的逆操作)
    param->allocator_->free(iter);       // 回到原分配器
  }
}

free_token_iter 里的两个 abort_unless 值得注意:迭代器从哪个分配器来,就必须回哪个分配器去,跨池释放在这种体系里是致命错误,所以直接用 abort 而不是返回错误码。

词项归一化:get_add_word_flag

最后一个函数是向词项收集器声明归一化策略:ngram 只声明 casedown(转小写)和 groupby_word(同词归并计频)。对比其他分词器颇有意味——空白分词器还额外声明了 min_max_word(最短/最长词过滤)和 stop_word(停用词),而 N-gram 不做任何停用词或词长过滤。这不是偷懒,而是语义决定设计:N-gram 切出来的本来就是无语义的字符片段,”停用词”这个概念对它毫无意义。

小结

ob_ngram_ft_parser.cpp 只有 156 行,却浓缩了 OceanBase 插件体系的三个关键设计:其一,双层结构(Desc 工厂 + Parser 迭代器)让有状态的分词过程可以按请求隔离;其二,分配器纪律(谁分配谁释放、失败统一回滚、abort 防跨池释放)贯穿所有内存操作;其三,迭代协议(OB_ITER_END 作为正常终止信号、出参先清零)让上层调用既简单又安全。真正有意思的切词算法藏在 ObFTNgramImpl 里——那个滑动窗口如何处理多字节字符集和分隔符边界,值得我们下篇文章继续深挖。

发表回复

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