Skip to content

feat: support nested tokenizer wrappers - #1103

Merged
longbinlai merged 12 commits into
alibaba:mainfrom
Spockkk0225:feat/fts-tokenizer-wrappers
Sep 23, 2026
Merged

longbinlai merged 12 commits into
alibaba:mainfrom
Spockkk0225:feat/fts-tokenizer-wrappers

Conversation

@Spockkk0225

@Spockkk0225 Spockkk0225 commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

What do these changes do?

  • Support recursive tokenizer composition for FTS wrapper tokenizers.
  • Use unicode61 as the default base tokenizer for porter while allowing chains such as porter jieba.
  • Preserve the complete tokenizer specification used by the FTS index.
  • Add C++ and Python coverage for mixed Chinese and English retrieval and document the wrapper behavior.

重构了现有的Tokenizer类,现在tokenizer能够支持嵌套,即FTS5中的wrapper。
目前porter和stopwords都会做成wrapper的形式,而不是在每个tokenizer中重复编写一遍过滤函数。
重构后可以各种配置选项(包括大小写敏感、特殊字符的处理等)都可以注册为一个独立的wrapper,与现有的tokenizer解耦。

Related issue number

Fixes #1101

@Spockkk0225 Spockkk0225 changed the title feat(fts): support nested tokenizer wrappers feat: support nested tokenizer wrappers Sep 21, 2026
StopwordTokenizerDelete,
StopwordTokenizerTokenize};
const auto create_code =
api->xCreateTokenizer_v2(api, "stopwords", context.get(), &tokenizer_api,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里把注册的 wrapper 名从 builtin_stopwords 改成了 stopwords,需要明确一下磁盘格式兼容策略。

#1096 引入 stopwords 时写入磁盘的 spec 是 tokenize='builtin_stopwords unicode61' 这类形式(jieba 是裸 'jieba' + 内联过滤)。改名后旧 spec 里的 tokenizer 名不再注册。我查了 pinned SQLite 源码确认失败路径:tokenizer 是惰性加载的(fts5_config.c:811sqlite3Fts5LoadTokenizer),首次 tokenize 时才报 no such tokenizer: builtin_stopwordsfts5_main.c:3539)——即旧索引文件能打开(ValidateExistingTable 只检查表存在),但第一次查询/插入才报错,且报错信息对用户没有指引。旧的 'jieba' spec 索引能打开,但内联 stopword 过滤已删,过滤会静默丢失:含 stopword 的混合查询(如 the database)从"能搜到"变成"搜不到"。

影响范围我核实过:v0.2.0 正式版(最新 tag)的 BuiltinFTSTokenizer::Register 是空实现,磁盘 spec 就是裸 'unicode61'/'jieba',这些名字在新代码里仍能解析,正式版用户的索引不受影响;受影响的只有 #1096 之后、本 PR 之前的开发版/weekly wheel 创建的索引。

建议二选一:

  • (a) wrapper 同时以旧名 "builtin_stopwords" 再注册一个 alias(多一次 xCreateTokenizer_v2 调用即可),旧索引无缝可用;或
  • (b) ValidateExistingTablesqlite_master 读出原 spec 与 full_tokenizer_name_ 比对,不一致时在 open 阶段抛清晰错误(提示 DROP INDEX 重建),把莫名的运行时 SQLite 报错变成可操作的指引。

无论选哪个,fts_search.md 里 "Index checkpoints created by earlier versions remain compatible" 这句承诺也请按最终决策同步更新。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

现在如果stopwords=none,就不会额外创建一个stopwords wrapper,用户看到的行为和v0.2.0一致。
文档已经修改为兼容v0.2.0,开发版本不考虑兼容。
b4f4dd0

db.close()


def test_porter_wrapper_uses_jieba_for_mixed_text(tmp_path):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

建议补一个 wrapper spec 的 reopen/持久化用例。本 PR 改动的核心机制正是"写入表的 tokenize spec"与"reopen 时按名注册"的对应关系,但现有 porter jieba 用例(这里和 C++ 侧)都只覆盖了单进程内的 create + search。

可以仿照上面 test_fts_stopwords_persist_across_checkpoint 的写法:CREATE INDEX ... WITH (tokenizer = 'porter jieba') → close → 重开库 → 验证中文分词(如 向量)和英文词干搜索(如 embedding 命中 embeddings)仍然正常。这样能锁住 reopen 后注册路径不再退化的行为。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

增加了测试,覆盖了所有tokenizer组合的reopen恢复。
5c33146

auto stopwords = std::move(option->second);
config.erase(option);
LoadStopwords(stopwords);
base_tokenizer_ = Create(std::move(config), full_name);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

将 Jieba 的内联 stopword 过滤改为 wrapper 后,Jieba 索引和查询也会进入 StopwordTokenFilter。目前每个 token 都执行 std::string(token, token_size) 再查 unordered_set,长 token 会产生逐 token 堆分配,这是 Jieba 路径新增的热路径开销。建议使用支持 heterogeneous lookup 的透明 hash/equality,直接以 std::string_view 查询;或者至少补一组前后 tokenizer benchmark,确认该抽象没有明显降低建索引/查询吞吐。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unordered_map支持heterogeneous lookup,允许接收string或者string_view,避免stopwords判断时的拷贝构造开销。
4847798

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个功能需要c++20,wheel打包的c++版本较低,重构后全部统一使用unordered_map<string_view>,不再用异构输入。
d3acb31

StopwordTokenizerDelete,
StopwordTokenizerTokenize};
const auto create_code =
api->xCreateTokenizer_v2(api, "stopwords", context.get(), &tokenizer_api,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

现在 stopwords 被提升为可组合 wrapper,建议同时补齐其 create callback 的参数校验。StopwordTokenizerCreate 会直接读取 arguments[0],但没有检查 context/output/arguments 以及 argument_count >= 1。正常生成的 spec 都带 base tokenizer,不过损坏或手工生成的持久化 FTS 表可能让这里越界,而不是返回可诊断的 SQLITE_ERROR。建议在读取 arguments[0] 前统一校验。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stopwords是内部wrapper,不对用户暴露,这里必定保证操作合法,无需防御检查。

config.erase(option);
} else {
throw std::invalid_argument("Unsupported FTS tokenizer: " + name);
name.erase(0, name.find_first_not_of(' ', separator));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里对尾部空格的处理会让 tokenizer = "porter "tokenizer = "porter" 表现不一致:find_first_not_of 返回 nposname 被清空,但 tokenizer key 仍留在 config 中;随后 BuiltinFTSTokenizer 认为显式指定了 base,不会补默认的 unicode61,递归解析空名称并报 Unsupported FTS tokenizer。建议 trim 后若 remainder 为空就删除该 key,并补 porter porter porter jieba 的用例。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

额外支持过滤句末的空格。
1b8953e

longbinlai
longbinlai previously approved these changes Sep 22, 2026

@longbinlai longbinlai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Comment thread extension/fts/src/fts_tokenizer.cc Outdated
fts5_tokenizer_v2 base_api{};
Fts5Tokenizer* base_tokenizer{};
const std::unordered_set<std::string>* stopwords{};
const std::unordered_set<std::string_view>* stopwords{};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里为什么要用一个 裸指针?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

现在stopwords改用引用,并且使用花括号初始化兼容低版本c++。其他成员变量都要和sqlite对接,保留裸指针。
701c49e

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

所以这个裸指针是谁在own,生命周期是什么呀

@longbinlai longbinlai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@longbinlai
longbinlai merged commit cf6682d into alibaba:main Sep 23, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support Nested Tokenizers for FTS Indexes

3 participants