SQLite全文检索(2)
距上一篇有好久了,因为乏人问津所以一直也没写这第二篇。年前看到有人给我发消息问 SQLite 全文检索的事,我想哪怕只有一个人看吧,我也整理整理。这一篇就写写如何扩展 SQLite 使它支持东亚文字的切词。
熟悉 Lucene 的童鞋大概知道,切词是在索引时进行的。对 SQLite 来说,也就是 INSERT UPDATE 时发生切词。SQLite 的做法是,在定义 FTS 虚表时指定切词器:
CREATE VIRTUAL TABLE pages USING fts3(title, body, tokenize=porter);
还记得“porter”吗?当然这里不是哈利波特,其实是指 Martin Porter 设计的切词算法。或许你在 Lucene 里见过,这个切词器主要用于英语词的整形(如复数变单数,去词尾变词根等等)。porter 是 SQLite 内置的切词器,可以直接使用。而我们需要扩展自己的切词器。
SQLite 是一个 C 语言开发的、定位于嵌入型的轻量级数据库,因此它的切词器接口也是以 C 语言的形式给出的。这里仅简单介绍一下:
(1) SQLite 要求你首先创建一个结构:
[StructLayoutAttribute(LayoutKind.Sequential)] internal struct sqlite3_tokenizer_module { public int iVersion; public sqlite3_tokenizer_module_xCreate xCreate; public sqlite3_tokenizer_module_xDestroy xDestroy; public sqlite3_tokenizer_module_xOpen xOpen; public sqlite3_tokenizer_module_xClose xClose; public sqlite3_tokenizer_module_xNext xNext; }
除了 iVersion 是常数之外,其余几个字段都是函数指针,分别是切词器生命周期各阶段的回调函数。其中 xNext 函数是重点,用于返回下一个切好的词。
(2) 然后将上面的这个结构体的内存地址,通过下面的 SQL 语句告诉给 SQLite:
SELECT fts3_tokenizer('demo', <sqlite3_tokenizer_module ptr>);
比如这句注册了名叫 demo 的切词器。注册之后就可以使用这个切词器了:
CREATE VIRTUAL TABLE pages USING fts3(title, body, tokenize=demo);
简单说起来只是这两步,但实现过程对于 C# 程序员来说,还是不太容易的,因为我们并不经常直接和函数指针、内存地址这些东西打交道。
实现过程中比较关键的几点是:
(1) 必须将回调函数,以及上面提到的接口 module 结构体,放到非托管内存领域。因为托管内存是 CLR 管理的,垃圾回收随时会启动,对象也可能被移动位置,回调函数和内存地址随时都会失效(尤其是切词处理时有大量数据进进出出,垃圾回收也会很频繁)。
Tip:可以先用 Marshal.AllocHGlobal 申请一段非托管内存,然后用 Marshal.StructureToPtr 将结构体写入非托管内存。但必须注意:放入非托管内存空间的结构体,一定要在使用完毕后手动释放(Marshal.FreeHGlobal)。
(2) 即便写入了非托管内存,关了程序切词器也就没了,所以每次连接到 SQLite 时,只要操作将要涉及到 FTS 虚表,都必须重新注册切词器。
好了,下面开始上主菜~
你已经看到,这个实现过程中有大量的代码要在非托管内存进行,需要小心翼翼的处理,一不留神就会出问题。因此,有必要做一些封装,将这些实现细节隐藏起来,方便 .NET 开发者扩展新的切词器。
下面这个是我封装后的抽象基类,只贴出接口部分:
public abstract class SQLiteFtsTokenizer { /// <summary> /// 切词器名称。也就是 tokenize=**** 处写的那个名称,请重写此属性。请用英文字母。 /// </summary> public virtual string Name { get { return "custom"; } } /// <summary> /// 注册切词器。参数是 SQLite 连接。 /// </summary> public void RegisterMe(SQLiteConnection connection) { } /// <summary> /// 切词器刚创建时的处理。(可选) /// </summary> /// <param name="tokenizerArgument">The argument for tokenizer.</param> protected virtual void OnCreate(string tokenizerArgument) { } /// <summary> /// 切词器销毁前的处理。(可选) /// </summary> protected virtual void OnDestroy() { } /// <summary> /// 切词器开始工作前的初始化。 /// </summary> protected abstract void PrepareToStart(); /// <summary> /// SQLite 传出的、需要切词的字符串(只读)。 /// </summary> protected string InputString { get { return this.inputString; } } /// <summary> /// 尝试读取下一个 Token。 /// </summary> /// <returns>成功读取 Token 返回 true,读取结束返回 false。</returns> protected abstract bool MoveNext(); /// <summary> /// 读取到的 Token。 /// </summary> protected string Token { get { return this.token; } set { this.token = value; } } /// <summary> /// 读取到的 Token 在 InputString 的位置(从 0 起算)。 /// </summary> protected int TokenIndexOfString { get { return this.tokenIndexOfString; } set { this.tokenIndexOfString = value; } } /// <summary> /// 下一次读取应该开始的位置(从 0 起算)。如果下一次读取正好在此次 Token 的后面,可以返回 -1。(目前我还未发现它的影响) /// </summary> protected int NextIndexOfString { get { return this.nextIndexOfString; } set { this.nextIndexOfString = value; } } /// <summary> /// 开发测试用。返回值是切完的 Token 列表。 /// </summary> public List<string> TestMe(string inputString) { } }
有了这个基类,扩展出我们自己的切词器就比较容易了。我在下载压缩包里放了一个 CJKTokenizer。参考了车东为 Lucene 写的 CJKTokenizer 的做法,采用的是二元切词法,比如“清华大学”将切为“清华/华大/大学”三个 Token。
最后,看一下自定义 Tokenizer 的使用代码示例:
using (SQLiteConnection connection = new SQLiteConnection("Data Source=filename")) { CJKTokenizer tokenizer = new CJKTokenizer(); connection.Open(); tokenizer.RegisterMe(connection); //注册切词器 //建表 SQLiteCommand cmd = new SQLiteCommand(connection); cmd.CommandText = "CREATE VIRTUAL TABLE docs USING fts3(title, content, tokenize=cjk)"; cmd.ExecuteNonQuery(); //插入数据 cmd.CommandText = "INSERT INTO docs (title, content) VALUES (?, ?)"; SQLiteParameter p1 = new SQLiteParameter(); p1.DbType = System.Data.DbType.String; p1.Value = "测试标题"; cmd.Parameters.Add(p1); SQLiteParameter p2 = new SQLiteParameter(); p2.DbType = System.Data.DbType.String; p1.Value = "测试内容"; cmd.Parameters.Add(p2); cmd.ExecuteNonQuery(); //检索 cmd.CommandText = "SELECT docid, title, content FROM docs WHERE docs MATCH '测试'"; SQLiteDataReader dr = cmd.ExecuteReader(); while(dr.Read()) { //... } dr.Close(); connection.Close(); }
其实只多了两行代码:一行 new ,一行注册切词器。
现有的切词器大多针对 Lucene 开发,如果不想改动太多代码,可以采用“适配器模式”,为 Lucene Tokenizer(TokenFilter)套一个 Adapter。压缩包里有一份毛胚版的参考实现。
(此系列的下一篇将写写根据相关度排序的话题,看看有没有人捧场吧~)