GRDB.swift 自定义 FTS5 Tokenizer 完全指南从协议原理到同义词与拉丁字符容错实战【免费下载链接】GRDB.swiftA toolkit for SQLite databases, with a focus on application development项目地址: https://gitcode.com/GitHub_Trending/gr/GRDB.swiftGRDB.swift 基于 SQLite 的可扩展全文检索引擎 FTS5允许开发者自定义 tokenizer分词器并扩展 SQLite 内建分词器的能力。本文以 Documentation/FTS5Tokenizers.md 为骨架结合仓库源码 GRDB/FTS/FTS5CustomTokenizer.swift、GRDB/FTS/FTS5WrapperTokenizer.swift 与完整测试用例 Tests/GRDBTests/FTS/FTS5CustomTokenizerTests.swift系统讲解 FTS5Tokenizer / FTS5CustomTokenizer / FTS5WrapperTokenizer 三个协议的分层设计并给出可直接落地的同义词与拉丁字符容错实现帮助你为多语言、多拼写变体的应用构建精确匹配的全文检索方案。Tokenizer 与全文检索的关系Tokenizer 把文本切分为 token词元。例如一个 tokenizer 可以把 SQLite is a database engine 切成 SQLite、is、a、database、engine 五个 token。FTS5 使用 tokenizer 对索引文档和搜索模式两方都做分词。当文档与搜索模式产生完全一致的 token 时二者匹配。因此 tokenizer 的变换策略直接决定了哪些文本能互相匹配所有 SQLite 内建 tokenizer 都会把 SQLite 与 sqlite 统一成小写 token sqlite这正是它们大小写不敏感的原因。概括地说不同的 tokenizer 通过给输入文本施加不同的变换来获得不同的匹配行为。asciitokenizer 把所有 ASCII 字符转为小写。SQLite is a database engine 产生 sqlite、is、a、database、engine查询 SQLITE DATABASE 能匹配因为其 token sqlite 与 database 都存在于文档中。unicode61tokenizer 会去掉拉丁字符的变音符diacritics。与 ascii 不同它能让 Jérôme 匹配 Jerome因为二者都产生 jerome。portertokenizer 把英文单词还原为词根database engine 产生 databas 与 engin查询 database engines 能匹配因为它产生同样的 token。但内建 tokenizer 无法让 first 匹配 1st因为它们产生的是 first 与 1st 两个不同 token也无法让 Grossmann 匹配 Großmann因为二者产生 grossmann 与 großmann。自定义 tokenizer 正是为解决这类问题而生本文后面的同义词示例会把 first 与 1st 都切为同义词 token 使二者互匹配拉丁字符示例则把 Grossmann、Großmann 统一转成 grossmann。官方文档列出的典型用例还包括fi 匹配连字 fiUFB01、Encyclopaedia 匹配 Encyclopædia、Mueller 匹配 Müller、romaji 匹配 ローマ字、pinyin 匹配 拼音以及屏蔽 the 等停用词。Tokenizer 协议体系三层设计GRDB 通过三个协议让开发者使用和定义 FTS5 tokenizer它们之间是逐层继承、逐层抽象的关系FTS5Tokenizer所有 FTS5 tokenizer 的协议包括内建的 ascii、unicode61、porter。FTS5CustomTokenizer底层自定义协议让自定义 tokenizer 直接对接原始 FTS5 C API。FTS5WrapperTokenizer高层自定义协议用于对另一个 tokenizer 产生的 token 做后处理。这条继承链在源码中清晰可见FTS5CustomTokenizer: FTS5Tokenizer见 GRDB/FTS/FTS5CustomTokenizer.swiftFTS5WrapperTokenizer: FTS5CustomTokenizer见 GRDB/FTS/FTS5WrapperTokenizer.swift。绝大多数自定义场景只需要最上层的FTS5WrapperTokenizer。使用自定义 Tokenizer 的三步流程只要自定义类型遵循FTS5CustomTokenizer或FTS5WrapperTokenizer它就能驱动 FTS5 引擎使用流程固定为三步。第一步注册自定义 tokenizer 到数据库。推荐把注册放进Configuration.prepareDatabase这样无论DatabaseQueue还是DatabasePool都会在每次建立连接时自动完成注册class MyTokenizer : FTS5CustomTokenizer { ... } var config Configuration() config.prepareDatabase { db in db.add(tokenizer: MyTokenizer.self) } let dbQueue try DatabaseQueue(path: dbPath, configuration: config)db.add(tokenizer:)在源码中通过xCreateTokenizerC 回调把 tokenizer 的名字与构造器注册进 FTS5 API见 GRDB/FTS/FTS5CustomTokenizer.swift。注意tokenizer 实例的生命周期由Unmanaged.passRetained托管直到xDeleteTokenizer被调用才释放——这保证了 SQLite 在其存续期内总能安全访问你的实例。第二步创建使用该自定义 tokenizer 的全文表try db.create(virtualTable: documents, using: FTS5()) { t in t.tokenizer MyTokenizer.tokenizerDescriptor() t.column(content) }tokenizerDescriptor()是FTS5CustomTokenizer的默认扩展它把 tokenizer 名字与可选参数拼接为FTS5TokenizerDescriptor见 GRDB/FTS/FTS5CustomTokenizer.swift。db.create(virtualTable:using:_:)在底层会把描述符渲染进CREATE VIRTUAL TABLE语句的tokenize...子句见 GRDB/QueryInterface/Schema/VirtualTableModule.swift。第三步以常规方式写入并查询全文表完全不需要感知自定义 tokenizer 的存在try db.execute(sql: INSERT INTO documents VALUES (?), arguments: [...]) try Document(content: ...).insert(db) let pattern FTS5Pattern(matchingAnyTokenIn:...) let documents try Document.matching(pattern).fetchAll(db)全文表的写入与查询细节可参考 README.md 与 Documentation/FullTextSearch.md。FTS5Tokenizer所有 tokenizer 的基协议FTS5Tokenizer是所有 FTS5 tokenizer 的协议也是 GRDB 对内建 tokenizer 的统一定义。它只要求一个与底层xTokenizeC 函数见 SQLite 官方文档对应的分词方法typealias FTS5TokenCallback convention(c) ( _ context: UnsafeMutableRawPointer?, _ flags: Int32, _ pToken: UnsafePointerInt8?, _ nToken: Int32, _ iStart: Int32, _ iEnd: Int32) - Int32 protocol FTS5Tokenizer : class { func tokenize( context: UnsafeMutableRawPointer?, tokenization: FTS5Tokenization, pText: UnsafePointerInt8?, nText: Int32, tokenCallback: FTS5TokenCallback?) - Int32 }FTS5Tokenization是描述分词原因tokenization reason的 OptionSet源码中对应 SQLite 的FTS5_TOKENIZE_*常量见 GRDB/FTS/FTS5Tokenizer.swift.queryFTS5_TOKENIZE_QUERY正在为搜索模式的MATCH分词.prefixFTS5_TOKENIZE_PREFIX正在为前缀查询分词.documentFTS5_TOKENIZE_DOCUMENT正在为插入 FTS 表的文档分词.auxFTS5_TOKENIZE_AUX辅助分词。FTS5Tokenization可组合使用例如tokenization.contains(.query)用来判断当前是否处于查询分词阶段——这正是同义词示例用来避免双向都产生同义词的判断依据。可以通过Database.makeTokenizer()实例化 tokenizer包括内建 tokenizerlet unicode61 try db.makeTokenizer(.unicode61()) // FTS5TokenizermakeTokenizer在底层通过xFindTokenizer查找已注册 tokenizer 并调用其xCreate构造见 GRDB/FTS/FTS5Tokenizer.swift。源码还为协议提供了两个便捷方法方便在测试与调试中直接观察分词结果// tokenize(document:) 模拟文档插入时的分词 try tokenizer.tokenize(document: foo bar) // [(foo, flags), (bar, flags)] // tokenize(query:) 模拟 MATCH 搜索模式的分词 try tokenizer.tokenize(query: foo bar) // [(foo, flags), (bar, flags)]在测试 Tests/GRDBTests/FTS/FTS5CustomTokenizerTests.swift 中SynonymsTokenizer.tokenize(document:)返回的 token 与 flags 数组精确验证了同义词展开与.colocated标记行为是调试自定义 tokenizer 的有力工具。FTS5TokenizerDescriptortokenizer 的配置描述在深入自定义协议前需要先认识与 FTS5 配套的FTS5TokenizerDescriptor——它描述了 tokenizer 的名字与参数并被t.tokenizer、makeTokenizer共同使用源码见 GRDB/FTS/FTS5TokenizerDescriptor.swift。init(components:)用原始组件数组构建描述符例如[porter, unicode61, remove_diacritics, 0]前提是组件数组不能为空。.ascii(separators:tokenCharacters:)构建 ascii 描述符可自定义分隔符集合与 token 字符集合。.porter(wrapping:)构建 porter 描述符可指定被包装的基础 tokenizer默认 unicode61。.unicode61(diacritics:categories:separators:tokenCharacters:)构建 unicode61 描述符。diacritics取FTS5.Diacritics枚举默认.removeLegacy对应remove_diacritics1.keep对应0.remove对应2remove_diacritics2需要 SQLite 3.27.0categories默认为空串此时 SQLite 采用 L* N* Co 的 Unicode 类别集合separators与tokenCharacters默认都为空集。描述符的components直接对应 SQLitetokenize配置字符串的各个参数。例如unicode61(removeDiacritics: false)会生成[unicode61, remove_diacritics, 0]。FTS5CustomTokenizer底层自定义协议FTS5CustomTokenizer是底层自定义协议用于直接对接原始 FTS5 C API 的场景protocol FTS5CustomTokenizer : FTS5Tokenizer { static var name: String { get } init(db: Database, arguments: [String]) throws }自定义 tokenizer 与内建 tokenizer 一样拥有名字。不要使用 ascii、porter、unicode61 这些已被占用的名字否则会与内建 tokenizer 冲突final class MyTokenizer : FTS5CustomTokenizer { static let name custom }SQLite 在需要 token 时实例化 tokenizer。init(db:arguments:)的arguments参数是字符串数组自定义 tokenizer 可以按自己的用途解释它。在下面的例子中参数将是[arg1, arg2]// CREATE VIRTUAL TABLE documents USING fts5( // tokenizecustom arg1 arg2, // authors, title, body // ) try db.create(virtualTable: documents, using: FTS5()) { t in t.tokenizer MyTokenizer.tokenizerDescriptor(arguments: [arg1, arg2]) t.column(authors) t.column(title) t.column(body) }FTS5CustomTokenizer继承自FTS5Tokenizer通过tokenize(context:tokenization:pText:nText:tokenCallback:)执行分词。该底层方法对应 SQLite 官方文档的xTokenize函数各参数含义如下context不透明指针作为tokenCallback的第一个参数传入tokenizationFTS5 请求分词的原因pText待分词文本的字节指针可能不以 NUL 结尾nText待分词文本的字节数tokenCallback为每个找到的 token 调用的回调函数对应 SQLite 的xToken回调context不透明指针flags告诉 FTS5 如何登记该 token 的标记pTokentoken 的字节指针可能不以 NUL 结尾nTokentoken 的字节数iStarttoken 在输入文本中的字节偏移iEndtoken 在输入文本中的结束字节偏移。由于直接处理原始字节缓冲区与convention(c)回调相当繁琐源码测试中给出了一个完整的底层实现范例StopWordsTokenizer与NFKCTokenizer见 Tests/GRDBTests/FTS/FTS5CustomTokenizerTests.swift。它们都是包装 unicode61 但拦截其 token的模式先通过withUnsafeMutablePointer把自定义上下文传给内层 tokenizer在内层回调里把原始字节还原为String做处理再调用原始tokenCallback反馈给 SQLite。可以看到即使是底层协议社区也习惯把它当作半包装来用——这正是 FTS5WrapperTokenizer 存在的原因。FTS5WrapperTokenizer高层包装协议FTS5WrapperTokenizer是高层自定义协议它为低层tokenize方法提供了默认实现让采用者不必接触原始 FTS5 C API 的字节缓冲区。它把最难的分词工作交给另一个 tokenizer被包装的 tokenizer自己只负责对被包装 tokenizer 产出的 token 做后处理protocol FTS5WrapperTokenizer : FTS5CustomTokenizer { var wrappedTokenizer: any FTS5Tokenizer { get } func accept( token: String, flags: FTS5TokenFlags, for tokenization: FTS5Tokenization, tokenCallback: FTS5WrapperTokenCallback) throws }与所有自定义 tokenizer 一样包装型 tokenizer 也必须有自己的名字final class MyTokenizer : FTS5WrapperTokenizer { static let name custom }wrappedTokenizer属性是必须实现的被包装 tokenizer应在初始化器中只实例化一次final class MyTokenizer : FTS5WrapperTokenizer { let wrappedTokenizer: any FTS5Tokenizer init(db: Database, arguments: [String]) throws { // Wrap the unicode61 tokenizer wrappedTokenizer try db.makeTokenizer(.unicode61()) } }包装型 tokenizer 需要实现accept(token:flags:for:tokenCallback:)方法。例如一个简单透传的 tokenizer 如下final class MyTokenizer : FTS5WrapperTokenizer { func accept( token: String, flags: FTS5TokenFlags, for tokenization: FTS5Tokenization, tokenCallback: FTS5WrapperTokenCallback) throws { // pass through try tokenCallback(token, flags) } }各参数含义token被包装 tokenizer 产出的 token可以原样忽略、改写或倍增为多个同义词tokenization说明 FTS5 当前是对文档还是对搜索模式分词某些 tokenizer 会依据该参数产出不同的 tokentokenCallback输出自定义 token 时调用的回调函数。实现accept方法时有两条必须遵守的规则tokenCallback抛出的错误不能被捕获——它们是在通知 FTS5 立即终止分词过程flags参数应原样传给tokenCallback除非在产出同义词时与.colocated标记做并集union。这两条规则同时写入了源码协议注释见 GRDB/FTS/FTS5WrapperTokenizer.swift。FTS5TokenFlags目前公开了.colocated一个成员对应 SQLite 的FTS5_TOKEN_COLOCATED见 GRDB/FTS/FTS5WrapperTokenizer.swift。从源码实现看FTS5WrapperTokenizer的默认tokenize会把自定义上下文打包进FTS5WrapperContext用withUnsafeMutablePointer转发给被包装 tokenizer 的tokenize在内层回调中把 token 字节还原为String后调用accept再把accept产出的每个自定义 token 重新编码为字节并通过tokenCallback注入 SQLite见 GRDB/FTS/FTS5WrapperTokenizer.swift。整个过程中iStart、iEnd偏移保持不变因此同义词在短语查询phrase query中也能保持相对位置正确。动态选择被包装的 Tokenizer被包装的 tokenizer 既可以硬编码也可以在运行时根据参数选择。例如下面的 tokenizer 默认包装 unicode61但允许通过参数指定其他 tokenizer与 porter 的包装机制类似final class MyTokenizer : FTS5WrapperTokenizer { static let name custom let wrappedTokenizer: any FTS5Tokenizer init(db: Database, arguments: [String]) throws { if arguments.isEmpty { wrappedTokenizer try db.makeTokenizer(.unicode61()) } else { let descriptor FTS5TokenizerDescriptor(components: arguments) wrappedTokenizer try db.makeTokenizer(descriptor) } } }参数在虚拟表创建时提供// CREATE VIRTUAL TABLE documents USING fts5( // tokenizecustom, // content // ) try db.create(virtualTable: documents, using: FTS5()) { t in // Wraps the default unicode61 t.tokenizer MyTokenizer.tokenizerDescriptor() t.column(content) } // CREATE VIRTUAL TABLE documents USING fts5( // tokenizecustom ascii // content // ) try db.create(virtualTable: documents, using: FTS5()) { t in // Wraps ascii let ascii FTS5TokenizerDescriptor.ascii() t.tokenizer MyTokenizer.tokenizerDescriptor(arguments: ascii.components) t.column(content) }这种参数驱动包装目标的模式在测试中同样被使用CustomizedUnicode61WrappingTokenizer包装了自定义的 unicode61 配置含自定义分隔符 X并用透传回调验证了abcXdef能被切为 abc 与 def 两个 token见 Tests/GRDBTests/FTS/FTS5WrapperTokenizerTests.swift。示例同义词——让 first 匹配 1stFTS5 允许 tokenizer 产生同义词从而让 first 匹配 1st。同义词的完整机制在 SQLite 官方文档 中有详细介绍包含多种实现方法请仔细阅读后选择适合你的那种。下面采用方法 (3)单个词条为 FTS 索引提供多个同义词。文档 I won first place 被分词后索引中会同时出现 i、won、first、1st、place 这些词条。同时需要遵从 SQLite 的官方建议使用方法 (2) 或 (3) 时tokenizer 只能在对文档文本或查询文本分词时提供同义词不能同时对两者提供。虽然不会产生错误但会降低效率。下面的实现与 Tests/GRDBTests/FTS/FTS5WrapperTokenizerTests.swift 中的SynonymsTokenizer一致只有在.document分词时才展开同义词查询分词直接透传final class SynonymsTokenizer : FTS5WrapperTokenizer { static let name synonyms let wrappedTokenizer: any FTS5Tokenizer let synonyms: [SetString] [[first, 1st]] init(db: Database, arguments: [String]) throws { wrappedTokenizer try db.makeTokenizer(.unicode61()) } func synonyms(for token: String) - SetString? { synonyms.first { $0.contains(token) } } func accept(token: String, flags: FTS5TokenFlags, for tokenization: FTS5Tokenization, tokenCallback: FTS5WrapperTokenCallback) throws { if tokenization.contains(.query) { // Dont look for synonyms when tokenizing queries try tokenCallback(token, flags) return } guard let synonyms synonyms(for: token) else { // Token has no synonym try tokenCallback(token, flags) return } for (index, synonym) in synonyms.enumerated() { // Notify each synonym, and set the colocated flag for all but the first let synonymFlags (index 0) ? flags : flags.union(.colocated) try tokenCallback(synonym, synonymFlags) } } }要点解读只在文档分词时展开同义词tokenization.contains(.query)分支直接透传原始 token避免同义词同时注入索引与查询造成冗余开销.colocated标记除第一个同义词外其余同义词都必须带上.colocatedFTS5_TOKEN_COLOCATED表示它们与被替换 token 位于同一位置colocated这样才能保证短语查询与命中高亮的正确性。测试 Tests/GRDBTests/FTS/FTS5WrapperTokenizerTests.swift 验证了完整行为插入 first foo 与 1st bar 两篇文档后MATCH first与MATCH 1st都命中 2 篇短语查询first foo、1st foo、first bar、1st bar均命中 1 篇前缀查询fi*与1s*也都命中 2 篇。同时tokenize(document:)输出的 flags 序列验证了首个 token 无标记、后续同义词带.colocated的精确行为见 Tests/GRDBTests/FTS/FTS5CustomTokenizerTests.swift。示例拉丁字符容错——让 Grossmann 匹配 Großmann使用拉丁字母的语言拥有丰富的排版、历史与地域特征变音符diacritics、连字ligatures、无点 idotless I等例如 Großmann、fidélité含连字 fi UFB01、Diyarbakır。这类语料做全文检索时通常需要输入容错让 encyclopaedia 能匹配 EncyclopædiaGrossmann 能匹配 GroßmannJerome 能匹配 Jérôme。德语还有一个特殊需求Mueller 与 Muller 都应匹配 Müller但 Bauer 不应匹配 Baur因为只有 ü 同时接受 u 与 ue 两种写法。官方文档表示欢迎贡献一个专门讲解德语场景的章节。自定义 FTS5 tokenizer 可以提供模糊拉丁匹配当 Grossmann、Großmann、GROSSMANN 都被转换为 grossmann 后三者即可互相匹配。实现策略是包装内建 unicode61它擅长按空格与标点切分文本并把每个 token 变换为小写、纯 ASCII 的形式。包装机制由FTS5WrapperTokenizer提供字符串变换则由 Foundation 的 String.applyingTransform 提供final class LatinAsciiTokenizer : FTS5WrapperTokenizer { static let name latinascii let wrappedTokenizer: any FTS5Tokenizer init(db: Database, arguments: [String]) throws { wrappedTokenizer try db.makeTokenizer(.unicode61()) } func accept(token: String, flags: FTS5TokenFlags, for tokenization: FTS5Tokenization, tokenCallback: FTS5WrapperTokenCallback) throws { if let token token.applyingTransform(StringTransform(Latin-ASCII; Lower), reverse: false) { try tokenCallback(token, flags) } } }StringTransform(Latin-ASCII; Lower)的变换链路会把拉丁字符转写为最接近的 ASCII 字符ß→ss、æ→ae、带音字母去音并转为小写若变换结果不存在则跳过该 token。使用前记得注册dbQueue.add(tokenizer: LatinAsciiTokenizer.self) // or dbPool.add dbQueue.inDatabase { db in try db.create(virtualTable: documents, using: FTS5()) { t in t.tokenizer LatinAsciiTokenizer.tokenizerDescriptor() t.column(authors) t.column(title) t.column(body) } }测试 Tests/GRDBTests/FTS/FTS5WrapperTokenizerTests.swift 给出了对照实验同一份含 aimé fidélité Encyclopædia Großmann Diyarbakır 的文档使用内建 unicode61 时查询 aime fidelite encyclopaedia grossmann diyarbakir 命中 0 篇改用LatinAsciiTokenizer后命中 1 篇。该测试还同时验证了组合变音符aime\u{0301}分解形式也能与预组合形式aimé互相匹配。类似地测试 Tests/GRDBTests/FTS/FTS5CustomTokenizerTests.swift 中的NFKCTokenizer通过precomposedStringWithCompatibilityMappingNFKC 归一化让 aimefi 匹配 aiméfiUFB01 连字展示了同一模式在不同 Unicode 归一化策略下的应用。实战要点与注意事项1. 注册时机与作用域。自定义 tokenizer 必须在使用它的连接上注册。DatabaseQueue单连接场景可直接在prepareDatabase中注册DatabasePool多连接场景必须用dbPool.add(tokenizer:)或prepareDatabase保证每个连接都能找到 tokenizer否则建表会失败。测试 Tests/GRDBTests/FTS/FTS5WrapperTokenizerTests.swift 展示了DatabasePool下的正确写法。2. 名字唯一性。static let name是 tokenizer 的全局标识不能与内建名ascii、porter、unicode61或其他已注册自定义名冲突。3. 分词一致性。索引分词与查询分词必须使用同一个tokenizer 描述符否则索引 token 与查询 token 无法对齐、必然失配。对同一张表改变tokenizer描述符需要重建虚拟表。4. 高效同义词策略。遵循 SQLite 官方建议只在文档分词或查询分词之一产生同义词不要两者都产生同义词必须正确携带.colocated标记以保证短语查询正确。5. 测试驱动开发。仓库测试提供了三层验证手段直接用 SQL 的MATCH断言命中数端到端、用tokenize(document:)/tokenize(query:)断言 token 序列与 flags单元级、用 DatabaseQueue 与 DatabasePool 双路径验证注册可靠性。自定义 tokenizer 上线前建议仿照 Tests/GRDBTests/FTS/FTS5WrapperTokenizerTests.swift 建立同等覆盖。6. 性能与底层开销。每个被包装 token 都会经历字节→String→accept→字节的往返见 GRDB/FTS/FTS5WrapperTokenizer.swift高吞吐场景下应避免在accept中做重量级计算若确需极致性能可退回FTS5CustomTokenizer直接操作字节缓冲。这一取舍在官方文档中同样被提及分词很困难字节缓冲指针也不易处理——这正是推荐优先使用FTS5WrapperTokenizer的原因。【免费下载链接】GRDB.swiftA toolkit for SQLite databases, with a focus on application development项目地址: https://gitcode.com/GitHub_Trending/gr/GRDB.swift创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考