Sqlite3 FTS5 实现一个自定义分词器

601 阅读10分钟

在实现分词器之前我们需要先知道如何实现一个自定义扩展

可加载扩展包含以下三个要素:

  1. 在源代码文件的顶部 使用“ #include <sqlite3ext.h> 而不是 #include <sqlite3.h> ”。
  2. 将宏“ SQLITE_EXTENSION_INIT1 放在 #include <sqlite3ext.h> ”行之后的一行上。
  3. 添加扩展加载入口方法,如下所示
/* 在此处添加标题注释 */ 
#include <sqlite3ext.h> /* 不要使用 <sqlite3.h>! */ 
SQLITE_EXTENSION_INIT1 

/* 在此处插入扩展代码 */ 

#ifdef _WIN32 
__declspec(dllexport) 
#endif 
/* TODO:扩展入口函数,替换“extension”
extension 需要与共享库项目名称相同 要求小写,不能以lib开头
*/ 
int sqlite3_extension_init( 
  sqlite3 *db, 
  char **pzErrMsg, 
  const sqlite3_api_routines *pApi 
){ 
  int rc = SQLITE_OK; 
  SQLITE_EXTENSION_INIT2(pApi); 
  /* 在此处插入扩展
  ** sqlite3_create_function_v2()、
  ** sqlite3_create_collation_v2()、
  ** sqlite3_create_module_v2() 和/或
  ** sqlite3_vfs_register() 
  ** 的调用,以注册您的扩展添加的新功能。
  */ 
  return rc; 
}
如何实现分词器tokenizer

在向 FTS5 注册新的辅助函数或分词器实现之前,应用程序必须获取指向“fts5_api”结构体的指针。对于注册了 FTS5 扩展的每个数据库连接,都有一个 fts5_api 结构。为了获取指针,应用程序使用单个参数调用 SQL 用户定义函数 fts5()。必须使用 sqlite3_bind_pointer ()接口将该参数设置为指向 fts5_api 对象的指针。以下示例代码:

/* 
** 返回指向数据库连接 db 的 fts5_api 指针。
** 如果发生错误,则返回 NULL 并在数据库
** 句柄中留下错误(可使用 sqlite3_errcode()/errmsg() 访问)。
*/ 
fts5_api *fts5_api_from_db(sqlite3 *db){ 
  fts5_api *pRet = 0; 
  sqlite3_stmt *pStmt = 0; 

  if( SQLITE_OK==sqlite3_prepare(db, "SELECT fts5(?1)", -1, &pStmt, 0) ){ 
    sqlite3_bind_pointer(pStmt, 1, (void*)&pRet, "fts5_api_ptr", NULL); 
    sqlite3_step(pStmt); 
  } 
  sqlite3_finalize(pStmt); 
  return pRet; 
}

要创建自定义分词器,应用程序必须实现三个函数:分词器构造函数(xCreate)、析构函数(xDelete)和执行实际标记化的函数(xTokenize)。每个函数的类型与 fts5_tokenizer 结构的成员变量相同:

typedef struct Fts5Tokenizer Fts5Tokenizer; 
typedef struct fts5_tokenizer fts5_tokenizer; 
struct fts5_tokenizer { 
  int (*xCreate)(void*, const char **azArg, int nArg, Fts5Tokenizer **ppOut); 
  void (*xDelete)(Fts5Tokenizer*); 
  int (*xTokenize)(Fts5Tokenizer*, 
      void *pCtx, 
      int flags,             /* FTS5_TOKENIZE_* 标志的掩码 */ 
      const char *pText, int nText, 
      int (*xToken)( 
        void *pCtx,          /* xTokenize() 的第二个参数的副本 */ 
        int tflags,          /* FTS5_TOKEN_* 标志的掩码 */ 
        const char *pToken, /* 指向包含标记的缓冲区的指针 */ 
        int nToken,          /* 标记的大小(以字节为单位) */ 
        int iStart,          /* 输入文本中标记的字节偏移量 */ 
        int iEnd             /* 输入文本中标记末尾的字节偏移量 */ 
      ) 
  ); 
}; /* 可以作为第三个参数传递给 xTokenize() 的标志 */ 
#define FTS5_TOKENIZE_QUERY 0x0001 
#define FTS5_TOKENIZE_PREFIX 0x0002 
#define FTS5_TOKENIZE_DOCUMENT 0x0004 
#define FTS5_TOKENIZE_AUX 0x0008 /* 可以由分词器实现传回 FTS5 的标志 ** 作为提供的 xToken 回调的第三个参数。 */ 
#define FTS5_TOKEN_COLOCATED 0x0001       /* 与上一个标记相同的位置 */ 

通过调用 fts5_api 对象的 xCreateTokenizer() 方法,将实现注册到 FTS5 模块。如果已经存在同名的 tokenizer,则将其替换。如果将非 NULL xDestroy 参数传递给 xCreateTokenizer(),则在关闭数据库句柄或替换 tokenizer 时,将使用作为唯一参数传递的 pUserData 指针的副本来调用它。

如果成功,xCreateTokenizer() 将返回 SQLITE_OK。否则,它将返回 SQLite 错误代码。在这种情况下,不会调用xDestroy 函数 。

当 FTS5 表使用自定义分词器时,FTS5 核心会调用 xCreate() 一次来创建分词器,然后调用 xTokenize() 零次或多次来标记字符串,然后调用 xDelete() 来释放 xCreate() 分配的任何资源。更具体地说:

  • xCreate:

    此函数用于分配和初始化 tokenizer 实例。实际对文本进行 tokenizer 处理时需要 tokenizer 实例。

    传递给此函数的第一个参数是应用程序在 fts5_tokenizer 对象向 FTS5 注册时提供的 (void*) 指针的副本(xCreateTokenizer() 的第三个参数)。第二个和第三个参数是一个以 nul 结尾的字符串数组,其中包含分词器参数(如果有),这些参数在分词器名称之后指定,作为用于创建 FTS5 表的 CREATE VIRTUAL TABLE 语句的一部分。

    最后一个参数是输出变量。如果成功,应将 (*ppOut) 设置为指向新的分词器句柄并返回 SQLITE_OK。如果发生错误,则应返回 SQLITE_OK 以外的某个值。在这种情况下,fts5 假定 *ppOut 的最终值未定义。

  • xDelete:

    调用此函数来删除先前使用 xCreate() 分配的分词器句柄。Fts5 保证每次成功调用 xCreate() 时都会调用此函数一次。

  • xTokenize:

    此函数预期将对参数 pText 指示的 nText 字节字符串进行标记。pText 可能以空字符结尾,也可能不以空字符结尾。传递给此函数的第一个参数是指向先前调用 xCreate() 返回的 Fts5Tokenizer 对象的指针。

    第二个参数表示 FTS5 请求对所提供文本进行标记的原因。这始终是以下四个值之一:

    • FTS5_TOKENIZE_DOCUMENT - 正在将文档插入 FTS 表或从中删除。正在调用分词器来确定要添加到(或从中删除)FTS 索引的标记集。
    • FTS5_TOKENIZE_QUERY - 正在针对 FTS 索引执行 MATCH 查询。调用分词器来标记查询中指定的裸词或带引号的字符串。
    • (FTS5_TOKENIZE_QUERY | FTS5_TOKENIZE_PREFIX) - 与 FTS5_TOKENIZE_QUERY 相同,不同之处在于裸字或带引号的字符串后面跟着一个“*”字符,这表示分词器返回的最后一个标记将被视为分词前缀。
    • FTS5_TOKENIZE_AUX - 调用分词器来满足辅助函数发出的 fts5_api.xTokenize() 请求。或者在 columnsize=0 数据库上由相同函数发出的 fts5_api.xColumnSize() 请求。 也就是说上面四种情况都会调用分词器方法进行分词

    对于输入字符串中的每个token,必须调用提供的回调 xToken()。它的第一个参数应该是作为 xTokenize() 的第二个参数传递的指针的副本。第三和第四个参数是指向包含标记文本的缓冲区的指针,以及标记的大小(以字节为单位)。第四和第五个参数是输入中标记所衍生文本的第一个字节和紧随其后的第一个字节的字节偏移量。

    传递给 xToken() 回调的第二个参数(“tflags”)通常应设置为 0。例外情况是分词器支持同义词。在这种情况下,请参阅下面的讨论以了解详细信息。

    FTS5 假定每个标记按照它们在输入文本中出现的顺序调用 xToken() 回调。

    如果 xToken() 回调返回除 SQLITE_OK 之外的任何值,则应放弃分词,并且 xTokenize() 方法应立即返回 xToken() 返回值的副本。或者,如果输入缓冲区已耗尽,xTokenize() 应返回 SQLITE_OK。最后,如果 xTokenize() 实现本身发生错误,它可能会放弃分词并返回除 SQLITE_OK 或 SQLITE_DONE 之外的任何错误代码。 以上是官方文档中提到的实现步骤。

下面演示实现一个单个字符分词器的实现

第一步:实现分词器的入口函数

#include <sqlite3ext.h> /* 不要使用 <sqlite3.h>! */ 
SQLITE_EXTENSION_INIT1 


#ifdef _WIN32 
__declspec(dllexport) 
#endif 
/* TODO:扩展入口函数,
*/ 
int sqlite3_single_init( 
  sqlite3 *db, 
  char **pzErrMsg, 
  const sqlite3_api_routines *pApi 
){ 
  int rc = SQLITE_OK; 
  SQLITE_EXTENSION_INIT2(pApi); 
  // 下面是注册分词器的相关代码
 	 fts5_api *fts5api;
  fts5_tokenizer tokenizer = {fts5_single_xCreate, fts5_single_xDelete, 	   	    fts5_single_xTokenize}; // 实现fts5_tokenizer结构体对应的三个函数

  fts5api = fts5_api_from_db(db); // 连接数据库验证是否支持FTS5 fts5_api_from_db是自己定义的函数名 返回的是fts5api指针

  if (fts5api == nullptr)
  {
// 连接数据库失败或者不支持FTS5
    return SQLITE_ERROR;
  }
  // 下面开始注册分词器
  // 第一个参数是连接到数据库的指针
  // 第二个参数是分词器的名称
  // 第三个参数是用户数据指针
  // 第四个参数是fts5_tokenizer 结构体的指针
  // 第五个参数是一个指向函数的指针,这个函数用于在不再需要时销毁 pUserData 指针指向的用户数据。如果 pUserData 是动态分配的内存或包含需要清理的资源,你需要在这里提供一个清理函数。当 FTS5 引擎销毁分词器时,它会调用这个函数。可以传递 NULL,如果不需要清理操作。
  rc = fts5api->xCreateTokenizer(fts5api, "single", (void *)fts5api, &tokenizer, NULL);
  
  return rc; 
}

第二步:实现fts5_tokenizer结构体对应的三个函数

// 声明一个分词器的结构体  根据实现需求声明(不是固定结构)
struct MyTokenizer
{
  std::vector<std::string> words; 
};
// 自己定义的工具方法后面会用到
static std::vector<std::string> split_utf8_string(const std::string &input)
{
  std::vector<std::string> result;
  for (size_t i = 0; i < input.length();)
  {
    unsigned char c = input[i];
    size_t char_len = 1;

    // 判断UTF-8字符的字节长度
    if (c >= 0xF0)
    { // 4字节字符
      char_len = 4;
    }
    else if (c >= 0xE0)
    { // 3字节字符
      char_len = 3;
    }
    else if (c >= 0xC0)
    { // 2字节字符
      char_len = 2;
    }

    // 从原字符串中截取一个完整的字符(字节序列)
    result.push_back(input.substr(i, char_len));
    i += char_len; // 移动到下一个字符
  }
  return result;
}
// 实现xCreate函数
extern "C" int fts5_single_xCreate(void *, const char **azArg, int nArg, Fts5Tokenizer **ppOut)
{

  *ppOut = (Fts5Tokenizer *)new MyTokenizer();

  return SQLITE_OK;
}
// 实现xDelete
extern "C" void fts5_single_xDelete(Fts5Tokenizer *pTokenizer)
{
  MyTokenizer *tokenizer = (MyTokenizer *)pTokenizer;
  delete tokenizer;
}
// 实现分词函数
extern "C" int fts5_single_xTokenize(Fts5Tokenizer *pTokenizer, void *pCtx, int tflags, const char *pText, int nText, int (*xToken)(void *pCtx, int tflags, const char *pToken, int nToken, int iStart, int iEnd))
{
	// 下面是实现是将字符串分割为单个字符 每个字符就是一个token
  MyTokenizer *tokenizer = (MyTokenizer *)pTokenizer;
  tokenizer->words.clear();
  std::string text(pText, nText); // 拿到我们传入的字符串
  // 例如将 周杰伦 分割为 周 杰 伦 三个token存储到 tokenizer->words中
  tokenizer->words = split_utf8_string(text); // split_utf8_string 上面已经声明
	
  size_t start = 0;
  size_t end = 0;

  // 每个token都需要调用回调函数 xToken
  for (const auto &word : tokenizer->words)
  {
    end = start + word.size();
    const char *wptr = word.c_str();
    // 第一个参数传入 fts5_single_xTokenize的第二个参数
    // 第二个参数传tflags 通常是0 同义词除外
    // 第三个参数传 储存每个token的 char* 指针
    // 第四个参数传每个token的长度
    // 第五第六个参数传每个token对应 字符串的开始位置和结束位置 
    xToken(pCtx, tflags, wptr, word.size(), start, end);
    start = end;
  }
  return SQLITE_OK;
}

第三步:实现第一步中调用的 fts5_api_from_db方法

// 该方法官方文档有说明 前面也提到 直接使用
extern "C" fts5_api *fts5_api_from_db(sqlite3 *db)
{
  fts5_api *pRet = 0;
  sqlite3_stmt *pStmt = 0;

  int rc = sqlite3_prepare(db, "SELECT fts5(?1)", -1, &pStmt, 0); 

  if (SQLITE_OK == rc)
  {
    sqlite3_bind_pointer(pStmt, 1, (void *)&pRet, "fts5_api_ptr", NULL);
    sqlite3_step(pStmt);
  }
  sqlite3_finalize(pStmt);
  return pRet;
}

以上三步已经基本实现了一个单个字符的分词器示例

官方文档地址:sqlite.org/fts5.html