OceanBase源码解读:全文检索停用词与词项收集器 ob_fts_stop_word

全文索引的质量,很大程度上取决于”哪些词值得进倒排”。用户文档里的 the、is、of 这类高频虚词,以及单个字母、超长乱码,塞进倒排表只会白白膨胀索引、拖慢检索。OceanBase 4.3 的全文检索(FTS)模块专门用一个文件管这件事:storage/fts/ob_fts_stop_word.cpp。它包含两个核心类——ObStopWordChecker(停用词哈希集合,负责判断一个词是否该被丢弃)和 ObAddWord(词项收集器,所有解析器产出的 token 都要过它这道”质检流水线”才能写入倒排)。理解这个文件,就理解了从”分词器吐出一串 token”到”倒排表里一个词项+词频”之间的全部加工逻辑。

一、架构位置:解析器与倒排之间的质检站

先看全景。FTS 的写入链路是:建全文索引时对文档列做分词(ik / ngram / beng / whitespace 等解析器),每个解析器吐出一串 token;这些 token 并不直接落盘,而是先进入 ObFTParseHelper::segment 装配好的 ObAddWord 收集器,经过四步归一化后汇总成 ObFTWordMap(word → 词频),最终生成 doc_length 和词项数组写入索引。而 ObAddWord 内部做停用词过滤时,又依赖全局唯一的 ObStopWordChecker——它挂在 ObFTParsePluginData 单例上,所有租户、所有解析器共享同一份内置停用词表。

这个分工有个精妙之处:停用词表是全局唯一的,但”要不要按词长过滤、要不要大小写归一、要不要聚合词频”这些策略因解析器而异——ik 中文分词四项全开,ngram 只开 casedown + groupby(N-gram 切出的片段谈不上停用词),beng 不做 casedown。这些差异通过每个解析器 get_add_word_flag() 声明的 ObAddWordFlag 位标志传入,ObAddWord 按位灵活组合,一套代码服务所有解析器。

二、ObStopWordChecker:一份常量表 + 一个租户级哈希集合

ObStopWordChecker 的初始化非常直白:创建一个租户级哈希集合,把编译期常量数组 ob_stop_word_list 逐条灌入。三个细节值得注意:

int ObStopWordChecker::init()
{
  int ret = OB_SUCCESS;
  uint64_t tenant_id = MTL_ID();
  if (OB_INVALID_TENANT_ID == tenant_id) {
    tenant_id = OB_SERVER_TENANT_ID;   // 启动早期拿不到租户,回退系统租户
  }
  if (inited_) {
    ret = OB_INIT_TWICE;               // 重复初始化守卫,OceanBase 标准防御
  } else if (OB_FAIL(stopword_set_.create(DEFAULT_STOPWORD_BUCKET_NUM,
                                          "StopWordSet", "StopWordSet", tenant_id))) {
    ...
  } else {
    ObObjMeta stop_meta;
    stop_meta.set_varchar();
    stop_meta.set_collation_type(ObCollationType::CS_TYPE_UTF8MB4_GENERAL_CI);
    stopword_type_.set_meta(stop_meta);
    ...
  }
}

第一,哈希集合带 tenant_id 创建,内存归属租户资源池,这是 OceanBase 多租户内存隔离的基本功;第二,inited_ 只有全部词条装载成功才置 true,失败则保持 false,保证”要么全成、要么未初始化”的原子语义;第三,也是最关键的——停用词集合的排序规则固定为 utf8mb4_general_ci,其中 CI 即 Case Insensitive。也就是说”大小写不敏感的停用词匹配”不是靠逐词 tolower 实现的,而是直接固化在集合的 collation 类型上。

查询接口 check_stopword 展示了一个经典的哈希容器用法陷阱:

ret = stopword_set_.exist_refactored(converted);
if (OB_HASH_NOT_EXIST == ret) {
  is_stopword = false;
  ret = OB_SUCCESS;          // "不存在"是正常业务结果,吞掉特殊错误码
} else if (OB_HASH_EXIST == ret) {
  is_stopword = true;
  ret = OB_SUCCESS;
} else if (OB_SUCC(ret)) {
  ret = OB_ERR_UNEXPECTED;   // exist 不该返回成功,防御性兜底
}

OceanBase 的 hash 容器把”元素存在/不存在”也编码进返回值(OB_HASH_EXIST / OB_HASH_NOT_EXIST),调用方必须手工把这两种”正常态”归零,否则上层会当成错误。入参的词还可能带着任意 collation,所以先用栈上的 ArenaAllocator 做 charset_convert 统一转成 utf8mb4_general_ci 再查——栈上分配器函数级生命周期,出栈即释放,转换结果只是临时比较用,不需要持久内存。

三、ObAddWord:四步质检流水线

ObAddWord 是每个 token 的必经之路,主入口 process_word 是典型的瀑布式早退结构,四步依次执行:

} else if (is_min_max_word(char_cnt)) {
  ++min_max_word_cnt_;                        // 第一步:词长过滤
} else if (OB_FAIL(casedown_word(src_word, dst_word))) {
  ...                                         // 第二步:小写归一
} else if (OB_FAIL(check_stopword(dst_word, is_stopword))) {
  ...
} else if (OB_UNLIKELY(is_stopword)) {
  ++stopword_cnt_;                            // 第三步:停用词过滤
} else if (OB_FAIL(groupby_word(dst_word, word_freq))) {
  ...
} else {
  non_stopword_cnt_ += word_freq;             // 第四步:存活词计入文档词频
}

第一步:词长过滤。字符数不在 [min_token_size, max_token_size] 区间内的直接丢弃。注意比较的是”字符数”而非字节数——UTF-8 下中文一个字算 1,与用户在 PARSER_PROPERTIES 里配置 min/max_token_size 的直觉一致。

第二步:小写归一(casedown)。按 collation 调 ObCharset::tolower。这里有个内存归属的细节:归一化后的词最终要存进 word_map 长期持有,所以必须用外部传入的 allocator_ 分配;对比 check_stopword 里转换结果只用一次就扔、所以可以用栈上临时分配器——临时内存和持久内存分离,是 OceanBase ArenaAllocator 体系的核心纪律。

第三步:停用词过滤。从全局单例 ObFTParsePluginData::instance() 拿共享的 checker。如果解析器的 flag 没声明 stopword(比如 ngram),这一步静默通过。

第四步:词频聚合(groupby)。这就是倒排索引里”一个文档内 word → 出现次数”映射的诞生地:

} else if (OB_FAIL(word_map_.get_refactored(word, word_count))
           && OB_HASH_NOT_EXIST != ret) {
  ...
} else {
  if (OB_HASH_NOT_EXIST == ret) {
    word_count = 1;
    ret = OB_SUCCESS;                          // 又见"吞正常错误码"
  } else {
    word_count += word_freq;
  }
  word_map_.set_refactored(word, word_count, 1/*overwrite*/);
}

先查旧值再累加覆盖写,同一个词出现 10 次最终只在 word_map 里占一个条目、词频为 10。收集完成后,non_stopword_cnt_ 就是这个文档的 doc_length(进入索引的总词频),word_map 本身则变成词项数组——它们一起构成倒排索引的最小写入单元。

四、小结

ob_fts_stop_word.cpp 只有 246 行,却是 FTS 数据质量的守门人。三个值得带走的工程经验:其一,”策略配置 + 位标志组合”让一套收集器适配所有解析器,新增解析器只需声明四个 flag,无需改流水线;其二,collation 选 CI 类型可以直接把大小写不敏感语义下沉到数据结构里,省掉一层显式归一化;其三,OceanBase hash 容器的 OB_HASH_EXIST/NOT_EXIST 返回值约定贯穿全文,每处都要手工归零,读源码时看到这类分支不要再当成错误处理了。至此 fts 分支的解析器族谱(ik / ngram / ngram2 / beng / whitespace)与配套基础设施(helper / property / stop_word / doc_word_iterator)已基本梳理完毕,后续将转入 ob_fts_struct.cpp 与内置解析器注册机制。

发表回复

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