Skip to content

Swift 全文搜索

qiuwenchen edited this page Mar 7, 2024 · 6 revisions

全文搜索(Full-Text-Search,简称 FTS),是 SQLite 提供的功能之一。它支持更快速、更便捷地搜索数据库内的信息,常用于应用内的全局搜索等功能。

WCDB 内建了全文搜索的支持,对中文、日文等非空格分割的语言做了针对性的优化;对英文做了词性还原,使搜索不受词形、时态的限制;从而使搜索更加精确。

虚拟表映射

虚拟表是 SQLite 的一个特性,可以更自由地自定义数据库操作的行为。在模型绑定一章,我们提到了虚拟表映射,但没有具体介绍。而在全文搜索中,它是不可或缺的一部分。下面是全文搜索的虚拟表映射的配置示例:

final class SampleFTS: TableCodable {
    var identifier: Int = 0
    var summary: String? = nil
    var description: String? = nil
        
    enum CodingKeys: String, CodingTableKey {
        typealias Root = SampleFTS
        case identifier
        case summary
        case description
        static let objectRelationalMapping = TableBinding(CodingKeys.self) {
            BindVirtualTable(withModule: .FTS5, //设置fts版本
                             and: BuiltinTokenizer.Verbatim) //设置分词器
            //设置identifier列不建立fts索引
            BindColumnConstraint(identifier, isNotIndexed: true)
        }
    }
}

// 注册本数据库需要用到的分词器
database.add(tokenizer: BuiltinTokenizer.Verbatim)
// 创建虚表
try database.create(virtualTable: "sampleVirtualTable", of: SampleFTS.self)

全文搜索的虚拟表映射一般只需定义模块和分词器即可,这里还使用FTS专用的列约束BindColumnConstraint(:isNotIndexed:)设置identifier这个不参与全文搜索的列不建索引,这样可以节省空间。定义完成后,先调用add(tokenizer:)接口往数据库中注册分词器,这个操作要在使用到这个分词器之前执行。定义完成后,调用 create(virtualTable:of:) 接口,则会根据字段映射和虚拟表映射创建虚拟表。

全文搜索的虚拟表映射一般只需定义模块和分词器即可。而若无特殊需求,使用 FTS3 和 WCDB 分词器即可。 定义完成后,调用 create(virtualTable:of:) 接口,则会根据字段映射和虚拟表映射创建虚拟表。

建立索引

全文搜索的速度依赖于其索引。

let english = SampleFTS()
english.identifier = 1
english.summary = "WCDB is a cross-platform database framework developed by WeChat."
english.description = "WCDB is an efficient, complete, easy-to-use mobile database framework used in the WeChat application. It can be a replacement for Core Data, SQLite & FMDB."

let chinese = SampleFTS()
chinese.identifier = 2
chinese.summary = "WCDB 是微信开发的跨平台数据库框架"
chinese.description = "WCDB 是微信中使用的高效、完整、易用的移动数据库框架。它可以作为 CoreData、SQLite 和 FMDB 的替代。"

try database.insert(objects: english, chinese, intoTable: "sampleVirtualTable")

建立索引的操作与普通表插入数据基本一致。

根据索引查找数据

全文搜索与普通表不同,必须使用 match 函数进行查找。

let objectMatchFrame: SampleFTS? = try database.getObject(from: "sampleVirtualTable", where: SampleFTS.Properties.summary.match("frame*"))
print(objectMatchFrame.summary) // 输出 "WCDB is a cross-platform database framework developed by WeChat."

// 词形还原特性,通过 "efficiency" 也可以搜索到 "efficient"
let objectMatchEffiency: Sample? = try database.getObject(from: "sampleVirtualTable", where: SampleFTS.Properties.description.match("efficiency"))
print(objectMatchEffiency.description) // 输出 "WCDB is an efficient, complete, easy-to-use mobile database framework used in the WeChat application. It can be a replacement for Core Data, SQLite & FMDB."

SQLite 分词必须从首字母查起,如"frame*",而类似"*amework"这样从单词中间查起是不支持的。

全表查询

全文搜索中有一列隐藏字段,它与表名一致。通过它可以对全表的所有字段进行查询。

let tableColumn = Column(named: "sampleVirtualTable")

let objects: [SampleFTS] = try database.getObject(from: "sampleVirtualTable", where: tableColumn.match("SQLite"))

print(objects[0].description) // 输出 "WCDB is an efficient, complete, easy-to-use mobile database framework used in the WeChat application. It can be a replacement for Core Data, SQLite & FMDB."
print(objects[1].description) // 输出 "WCDB 是微信中使用的高效、完整、易用的移动数据库框架。它可以作为 CoreData、SQLite 和 FMDB 的替代。"

分词器

分词器是全文搜索的关键模块,它实现将输入内容拆分成多个Token并提供这些Token的位置,搜索引擎再对这些Token建立索引。

SQLite的FTS组件有提供内置的分词器,同时还支持自定义分词器。WCDB 在 SQLite 原有分词器的基础上,自己实现了下面三个分词器:

  • BuiltinTokenizer.OneOrBinary,用于FTS3。
  • BuiltinTokenizer.Verbatim,用于 FTS5,逻辑上和BuiltinTokenizer.OneOrBinary基本一致。
  • BuiltinTokenizer.Pinyin,用于 FTS5,可以实现拼音搜索。使用时需要使用class Database.config(pinyinDict:)接口配置汉字到拼音的映射表。

BuiltinTokenizer.OneOrBinaryBuiltinTokenizer.Verbatim的用法和功能基本一样,上面已经有示例,这里就不再补充。下面通过例子介绍一下拼音搜索的实现方法:

final class PinyinObject: TableCodable {
    var content: String = ""

    enum CodingKeys: String, CodingTableKey {
        typealias Root = PinyinObject
        case content
        static let objectRelationalMapping = TableBinding(CodingKeys.self) {
          	//配置 FTS 版本和 Pinyin 分词器
            BindVirtualTable(withModule: .FTS5, and: BuiltinTokenizer.Pinyin)
        }
    }
}
//配置汉字拼音映射表,支持配置多音字
Database.config(pinyinDict: [
    "" : [ "shan", "dan", "chan" ],
    "" : [ "yu" ],
    "" : [ "qi" ],
    "" : [ "mo", "mu" ],
    "" : [ "ju" ],
    "" : [ "che" ],
])
// 注册拼音分词器
database.add(tokenizer: BuiltinTokenizer.Pinyin)
//创建虚表
try database.create(virtualTable: "pinyinTable", of: PinyinObject.self)

//写入数据
let obj = PinyinObject()
obj.content = "单于骑模具单车"
try database.insert(obj, intoTable: "pinyinTable")

//支持多音字搜索、拼音首字母搜索和拼音前缀搜索
let querys = [
    "\"shan yu qi mu ju dan che\"",
    "\"chan yu qi mo ju shan che\"",
    "\"dan yu qi mo ju chan che\"",
    "\"dan yu qi mu ju ch\"*",
    "\"dan yu qi mo ju d\"*",
    "\"s y q m j d c\"",
    "\"c y q m j s c\"",
    "\"c y q m j\""
]

//支持多音字搜索、拼音首字母搜索和拼音前缀搜索
for query in querys {
    let objs: [PinyinObject] = try database.getObjects(fromTable: "pinyinTable", where: PinyinObject.Properties.content.match(query))
    XCTAssertEqual(objs.count, 1)
    XCTAssertEqual(objs[0].content, obj.content)
}

使用了拼音分词器之后,无法再用原内容来搜索,只能搜索拼音。如果需要支持原文搜索的话,需要再另外建一个FTS表来支持。在两个表的情况下,可以在BindVirtualTable中配置external content table来只保存一份原文,减小空间占用,原理见SQLite External Content Table

分词器配置参数

现有的分词器还可以传入参数来做一些配置,需要添加参数的分词器可以直接在BindVirtualTable配置中添加。WCDB 实现的BuiltinTokenizer.OneOrBinaryBuiltinTokenizer.Verbatim两个分词器有下面三个可配置参数:

  • BuiltinTokenizer.Parameter.NeedSymbol,WCDB的分词器默认不会对符号进行分词,所以搜索符号是搜不到的。如果需要支持搜索符号,需要配置这个参数。
  • BuiltinTokenizer.Parameter.SimplifyChinese配置了之后可以支持用简体汉字来搜索繁体汉字,不过需要使用class Database.config(traditionalChineseDict:)来配置简繁体汉字映射表。
  • BuiltinTokenizer.Parameter.SkipStemming 关闭英文单词的词性还原功能。

这三个配置参数可以叠加配置。

下面以简体汉字搜繁体汉字为例,介绍分词器参数的用法:

final class TraditionalChineseObject: TableCodable {
    var content: String = ""

    enum CodingKeys: String, CodingTableKey {
        typealias Root = PinyinObject
        case content
        static let objectRelationalMapping = TableBinding(CodingKeys.self) {
          	// 设置分词器, 并配置支持简体搜繁体
						// BindVirtualTable可以同时配置多个参数
            BindVirtualTable(withModule: .FTS5, and: BuiltinTokenizer.Verbatim, BuiltinTokenizer.Parameter.SimplifyChinese)
        }
    }
}

//配置简繁体汉字映射表,需要在建索引和搜索前设置
Database.config(traditionalChineseDict: [
    "" : "",
    "" : "",
])
//给数据库注册分词器
database.add(tokenizer: BuiltinTokenizer.Verbatim)
//创建虚表
try database.create(virtualTable: "traditionalChineseTable", of: TraditionalChineseObject.self)

//建索引
let obj = TraditionalChineseObject()
obj.content = "我們是程序員"
XCTAssertNoThrow(try database.insert(obj, intoTable: "traditionalChineseTable"))

//可以使用繁体来搜索
let matchObjects1: [TraditionalChineseObject] = try database.getObjects(fromTable: "traditionalChineseTable", where: TraditionalChineseObject.Properties.content.match("我們是程序員"))
XCTAssertEqual(matchObjects1.count, 1)
XCTAssertEqual(matchObjects1[0].content, obj.content)

//也可以使用简体来搜索
let matchObjects2: [TraditionalChineseObject] = try database.getObjects(fromTable: "traditionalChineseTable", where: TraditionalChineseObject.Properties.content.match("我们是程序员"))
XCTAssertEqual(matchObjects2.count, 1)
XCTAssertEqual(matchObjects2[0].content, obj.content)

自定义分词器

开发者除了可以使用内置的分词器,还可以根据自己的需求自定义分词器。自定义的分词器需要实现 WCDB.Tokenizer这个协议,它的原型如下:

public protocol Tokenizer: AnyObject {
  	// 参数传入的是上一节提到的分词器配置参数
    init(args: [String])
    
    // 每次要分词一个新内容时,都会调用这个接口将内容传入。
    // 其中 flags 参数只在FTS5中会有值,可能是下面几种值中的一个或几个合并值,用来描述分词的场景:
    // FTS5_TOKENIZE_QUERY     0x0001 查询时分词
    // FTS5_TOKENIZE_PREFIX    0x0002 前缀搜索分词
    // FTS5_TOKENIZE_DOCUMENT  0x0004 建索引时分词
    // FTS5_TOKENIZE_AUX       0x0008 搜索辅助函数调用分词
    func load(input: UnsafePointer<Int8>?, length: Int, flags: Int)
      
    
    func nextToken(ppToken: UnsafeMutablePointer<UnsafePointer<Int8>?>,// 保存下个Token的指针
                   pnBytes: UnsafeMutablePointer<Int32>,    //下个Token的字节长度
                   piStart: UnsafeMutablePointer<Int32>,    //下个Token在原文中的字节起始位置
                   piEnd: UnsafeMutablePointer<Int32>,      //下个Token在原文中的字节结束位置
                   pFlags: UnsafeMutablePointer<Int32>?,    //只在 FTS5 中有效,同个文本有多个同义词 Token 时,第二 Token 开始 tflags 要赋值为 FTS5_TOKEN_COLOCATED
                   piPosition: UnsafeMutablePointer<Int32>? //只在 FTS3/4中有效,表示 Token 位置
    ) -> TokenizerErrorCode
}

自定义了分词器之后,还需要使用class Database.register(tokenizer:of:of:)接口将新分词器注册到 WCDB 中才能使用,下面自定义一个以空格为分割符来分词的简单分词器,来说明自定义分词器的方法:

class CustomTokenizer: Tokenizer {

    var hasCheckParameter: Bool = false
    var input: UnsafePointer<Int8>?
    var inputLength: Int = 0
    var lastLocation: Int = 0
    var position: Int = 0

    required init(args: [String]) {
    }

    func load(input: UnsafePointer<Int8>?, length: Int, flags: Int) {
      	// 保存待分词内容,文本不用拷贝
        self.input = input
        self.inputLength = length
      	// 复位其他状态变量
        self.lastLocation = 0
        self.position = 0
    }

    func nextToken(ppToken: UnsafeMutablePointer<UnsafePointer<Int8>?>,
                   pnBytes: UnsafeMutablePointer<Int32>,
                   piStart: UnsafeMutablePointer<Int32>,
                   piEnd: UnsafeMutablePointer<Int32>,
                   pFlags: UnsafeMutablePointer<Int32>?,
                   piPosition: UnsafeMutablePointer<Int32>?) -> WCDB.TokenizerErrorCode {
        guard inputLength > lastLocation, let input = input else {
          	// 分词结束
            return .Done
        }
      	// 记录下个 Token 的字节起止位置
        var start: Int = lastLocation
        var end: Int = lastLocation

        for location in lastLocation..<inputLength {
            if input.advanced(by: location).pointee == 32 {// 匹配空格
                if start == end {
                  	// 起始遇到连续空格,跳过
                    start = location + 1
                    end = start
                } else {
                  	// 找到当前 Token 的结尾
                    break
                }
            } else {
              	// 记录当前位置,继续往下遍历
                end = location + 1
            }
        }
        if start == end {
          	// 没有下个 Token 了,结束分词
            return .Done
        }
      	// 记录当前 Token 的结束位置,作为下个分词的遍历起点
        lastLocation = end
        ppToken.pointee = input.advanced(by: start)
        pnBytes.pointee = Int32(end - start)
        piStart.pointee = Int32(start)
        piEnd.pointee = Int32(end)
        position += 1
        if let piPosition = piPosition {
            piPosition.pointee = Int32(position)
        }
        return.OK
    }
}

// 注册FTS3分词器
Database.register(tokenizer: CustomTokenizer.self, of: "FTS3SimpleTokenizer", of: .FTS3)

// 注册FTS5分词器
Database.register(tokenizer: CustomTokenizer.self, of: "FTS5SimpleTokenizer", of: .FTS5)

分词器的名字是全局唯一的,FTS3/4的分词器不能和 FTS5 的分词器重名,也不能和已有的分词器重名。

搜索辅助函数

未完待续。

Clone this wiki locally