全文索引的质量,很大程度上取决于”哪些词值得进倒排”。用户文档里的 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 与内置解析器注册机制。