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。压缩包里有一份毛胚版的参考实现。

(此系列的下一篇将写写根据相关度排序的话题,看看有没有人捧场吧~)

代码下载

posted on 2011-02-10 07:33  破宝  阅读(3558)  评论(11编辑  收藏

导航