本文来自网易云社区
作者:苏甦
在Android开发中,遇到搜索的需求,可以考虑FTS的方案。
SQLite是支持FTS的,具体的文档在 https://www.sqlite.org/fts3.html 上可以找到,这里简要的总结下。
参考这个变动 https://android.googlesource.com/platform/external/sqlite/+/c82acac4e67711e8d9289b572d334298aeb5d806,绝大多数Android版本支持到FTS4。
参考这个变动 https://android.googlesource.com/platform/external/sqlite/+/a586535b4c1fa23e280ff9a188671113f900b48b%5E%21/#F0,绝大多数Android版本不支持自定义分词器的注入。
结论就是,如果不需要自定义分词器,那么Android的FTS支持完全够用,如果需要自定义分词器,那就得找其他的办法了。
如果不需要自定义分词,那么可以使用SQLite自带的内建分词器。
需要自定义分词,但同时又使用Android SQLite,有没有一种方案可以满足呢?我们先使用外部分词器产生token,再把token重新拼装,而同时使用SQLite的内建simple分词器,那么问题在于simple分词器如何处理我们拼装过的内容。simple的算法,简单说,就是在ASCII范围内设定哪些字符是分隔符,然后找到连续的非分隔符做为一个token,跳过所有的分隔符和非ASCII。这样导致一个问题,所有非ASCII的字符全部被丢弃掉。假设我们分词后产生中文的token,那么再次拼装后,中文会被丢弃。这样,如果需要自定义分词的话,使用Android SQLite是做不到的。
自行编译Android SQLite,详细的编译过程不描述,这里注意在源码基础上,加上-DSQLITE_ENABLE_FTS3_TOKENIZER这个宏定义。
可以选择移植Android SQLite的封装代码来封装新编译的的SQLite库。也可以选择直接调用SQLite的C API来实现业务逻辑。具体看模块的定位,如果是出于扩展Android SQLite的目的,那采用移植比较好,这样在使用上,就如同采用SQLCipher一样,只需要更换掉导入的包名就可以。如果只是实现单一的搜索的功能,那只就直接使用SQLite的C API就可以。具体的在JNI封装章节会提到。
这里先假定实现了一个SQLite的分词器,也就是实现了sqlite3_tokenizer_module,具体内容在后续的实现分词器章节讲述。编译Android SQLite中,打开了SQLITE_ENABLE_FTS3_TOKENIZER这个开关,目的就是启用SQLite的fts3_tokenizer函数。具体参考SQLite文档。
SELECT fts3_tokenizer("my_tokenizer", "my_sqlite3_tokenizer_module_address")。
调用是针对某个SQLite连接的,也就是sqlite3*。每次打开一个连接,需要做分词器的注入。
存在数据库的数据,并不是所有的数据都需要做索引的,也包含其他的数据。有如下的考虑点
public final class IndexRecord {
public String content;
public String sid;
public int iid;
}
创建表和索引
// 创建索引表,使用自定义分词器,仅包含有一个content列,content进入索引,同时表还包含一个隐藏列docid
CREATE VIRTUAL TABLE IF NOT EXISTS FtsIndex USING fts4(content, tokenize=my_tokenizer);
// 创建数据表,使用docid做为主键,docid与索引表中的docid对应,包含status列,用来标记状态,包含另外两个数据列
CREATE TABLE IF NOT EXISTS FtsMeta(docid INTEGER PRIMARY KEY, status INT DEFAULT 0, sid TEXT, iid INT);
// sid列索引
CREATE INDEX IF NOT EXISTS FtsMeta_sid ON FtsMeta(sid);
// iid列索引
CREATE INDEX IF NOT EXISTS FtsMeta_iid ON FtsMeta(iid);
// 插入索引内容
INSERT INTO FtsIndex(content) values(?)
// 数据库变动
changes()
// FtsIndex docid
last_insert_rowid()
// 插入数据表
INSERT INTO FtsMeta(docid, sid, iid) values(?, ?, ?)
// 根据iid删除
DELETE FROM FtsIndex WHERE docid in (SELECT docid FROM FtsMeta WHERE iid=?)
// 根据iid删除
DELETE FROM FtsMeta WHERE iid=?
删除一批记录,标记FtsMeta表status列。
// 根据sid标记删除
UPDATE FtsMeta SET status=1 WHERE sid=?
由于SQLite中int的存储方式使用varint,负数占用最大长度,所以以0做为正常状态,以1做为删除状态。
索引数据的删除的代价很大,所以在批量删除时,选择标记删除的方式。
SELECT sid,iid,content FROM FtsMeta LEFT OUTER JOIN FtsIndex USING(docid) WHERE docid IN (SELECT docid FROM FtsIndex WHERE content MATCH ?) AND status=0
用户端输入的任意查询字符串需要经过处理。在一次FTS查询中,输入给MATCH的参数会通过分词器进行分词。这里需要提到几个查询方式,做简要介绍,详细的参考SQLite文档。
查询字符串处理流程
public final class FTSQuery {
public static final String queryString(String str) {
String[] querys = breakQuery(str);
if (querys == null || querys.length == 0) {
return "";
}
StringBuilder sb = new StringBuilder(32);
for (String query : querys) {
if (!TextUtils.isEmpty(query)) {
sb.append('"');
sb.append(query);
sb.append("*\" ");
}
}
return sb.toString();
}
private static final Pattern QUERY_PATTERN = Pattern.compile("\\s+");
private static final String[] breakQuery(String query) {
if (query != null) {
query = query.trim();
}
return !TextUtils.isEmpty(query) ? QUERY_PATTERN.split(query.replace('*', ' ')) : null;
}
}
可以找到很多分词器的方案,但都需要词库的支持。字母和数字比较好处理,针对中文,可以采用单字切的方式。也就是一个中文字符做为一个token,这样就不需要词库的支持了,优点是怎么样都能匹配到,缺点是SQLite中的记录一个token命中的document数量很多,但是token总数是有限的。这里不讨论了,具体可以SQLite中FTS相关文档中的B-TREE节点部分。
SQLite的字符数据通常采用UTF-8编码。针对中文,或者说非ASCII字符,都使用单字符分词切分,探测也很简单,直接扫描第一个字节有多少个1的位。而针对ASCII字符,我们可以分为三类,一类是分隔符,直接丢弃,比如0x20,一类是单字符token,既做为分隔符,又做为token,比如#,最后一类是连续token字符,通常就是字母和数字,'0'-'9','a-z','A-Z'。
C版本实现
tokenizer_cursor *cursor = (tokenizer_cursor *) pCursor;
tokenizer *tokenizer = (tokenizer *) pCursor->pTokenizer;
unsigned char *input = (unsigned char *) cursor->pInput;
int iStartOffset = -1;
while (cursor->iOffset < cursor->nBytes) {
unsigned char c = input[cursor->iOffset];
if (c < 0x80) {
int config = tokenizer->configs[c];
if (config == 0) { // continuous token
// begin
if (iStartOffset == -1) {
iStartOffset = cursor->iOffset;
}
// next
cursor->iOffset++;
} else if (config > 0) { // single token
// catch it
if (iStartOffset == -1) {
iStartOffset = cursor->iOffset++;
}
// if break continuous token, catch it in next turn
break;
} else { // space
if (iStartOffset != -1) {
// break continuous token, skip it in next turn
break;
} else {
// skip
cursor->iOffset++;
}
}
} else {
if (iStartOffset == -1) {
iStartOffset = cursor->iOffset;
int length = 0;
for (; c & 0x80; c <<= 1, length++) {}
int remain = cursor->nBytes - cursor->iOffset;
cursor->iOffset += length > remain ? remain : length;
}
break;
}
}
JAVA版本实现(在查询结果高亮处理章节会用到)
/*package*/ final class Tokenizer {
public static final int tokenize(String str, int start, int end, int[] token) {
int tok = -1;
while (start < end) {
char c = str.charAt(start);
if (c < 0x80) {
int config = configs[c];
if (config == 0) { // continuous token
// begin
if (tok == -1) {
tok = start;
}
// next
start++;
} else if (config > 0) { // single token
// catch it
if (tok == -1) {
tok = start++;
}
// if break continuous token, catch it in next turn
break;
} else { // space
if (tok != -1) {
// break continuous token, skip it in next turn
break;
} else {
// skip
start++;
}
}
} else {
if (tok == -1) {
tok = start++; // todo what about UCS4 ?
}
break;
}
}
if (tok != -1) {
token[0] = tok;
token[1] = start;
return start;
}
return -1;
}
}
封装部分有如下
网易云免费体验馆,0成本体验20+款云产品!
更多网易研发、产品、运营经验分享请访问网易云社区。