当业务需要语义搜索、图片以图搜图、推荐召回这类能力时,大多数人第一反应是部署一个专门的向量数据库。但如果数据量在千万级以内,且部署环境受限,比如边缘设备、嵌入式终端或者一个单体的桌面应用,引入整套向量数据库就显得过重了。SQLite作为一个零依赖、单文件、事务安全的关系型数据库,配合SCANN(Scalable Nearest Neighbors)算法,完全可以构建一套轻量级的可伸缩最近邻检索方案。本文将从原理到代码完整走一遍这个实战项目。

一、为什么选择SQLite加SCANN的组合
先说清楚这个组合各自扮演的角色。SQLite负责数据的持久化、事务管理和SQL查询接口,SCANN负责高维向量的快速近邻检索。SCANN是Google开源的向量检索库,它的核心优势在于通过各向异性向量量化(Anisotropic Vector Quantization)和分层空间划分树,在内存占用和召回率之间取得了很好的平衡。相比暴力检索的O(n)复杂度,SCANN构建索引后可以把单次查询的复杂度降到近似亚线性级别。
SQLite本身并不支持向量类型,但它提供了极佳的扩展机制:可以通过C API编写自定义函数(sqlite3_create_function)、虚拟表模块(sqlite3_create_module)甚至自定义排序器。这意味着我们可以把SCANN的索引构建和查询能力封装成SQL函数,让上层应用直接写出类似SELECT id FROM items WHERE vec_search(embedding, query_vec) LIMIT 10这样的语句,整个检索逻辑对业务层完全透明。
这个组合的适用边界也要明确:数据量在百万到千万级、向量维度在128到768之间时性价比最高。如果数据规模超过亿级,或者需要分布式部署,那还是应该选择Milvus、Qdrant这类专业向量数据库,硬撑着用SQLite反而会陷入索引构建时间过长的困境。
二、表结构设计与向量存储方案
向量存储有两种主流方案。第一种是BLOB存储,把float数组序列化成二进制直接存入列中,优点是简单直接,缺点是无法在SQL层做任何向量运算。第二种是SQLite配合扩展,把BLOB反序列化后交给扩展内部的SCANN索引处理。本项目采用第二种方案。
建表语句如下,注意向量化字段用BLOB类型,并且额外存一个维度字段方便校验:
CREATE TABLE documents (
id INTEGER PRIMARY KEY,
content TEXT NOT NULL,
dim INTEGER NOT NULL,
embedding BLOB NOT NULL
);
-- 插入数据时用应用程序序列化向量
-- 例如Python中:sqlite3.Binary(np.asarray(vec, dtype=np.float32).tobytes())
INSERT INTO documents (id, content, dim, embedding)
VALUES (1, '示例文本', 384, ?);
序列化时务必统一使用float32小端字节序,这是后续扩展能够正确反序列化的前提。如果向量来自不同的embedding模型,建议在表里加一列model_version,重建索引时按模型版本过滤,避免不同维度的向量混进同一个SCANN索引导致崩溃。
三、编写SQLite扩展封装SCANN索引
扩展的核心思路是:注册一个自定义SQL函数scann_build负责扫表建索引,再注册一个表值函数scann_search负责检索。索引数据保存在扩展进程内的全局结构中,通过一个SQLite自身的元数据表记录构建状态。下面是简化后的C++核心代码,基于SCANN 1.2版本的头文件:
#include <sqlite3ext.h>
#include "scann/datasets/single_machine_factory_creators.h"
#include "scann/scann_ops/scann_ops.h"
SQLITE_EXTENSION_INIT1
static research_scann::SingleMachineScannInterface* g_scann = nullptr;
// 建索引函数:scann_build('documents', 'embedding')
static void scannBuildFunc(sqlite3_context* ctx, int argc, sqlite3_value** argv) {
const char* table = reinterpret_cast<const char*>(sqlite3_value_text(argv[0]));
const char* col = reinterpret_cast<const char*>(sqlite3_value_text(argv[1]));
sqlite3* db = sqlite3_context_db_handle(ctx);
char sql[256];
snprintf(sql, sizeof(sql), "SELECT rowid, %s FROM %s", col, table);
sqlite3_stmt* stmt;
sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr);
std::vector<int64_t> ids;
std::vector<const float*> datavs;
std::vector<std::vector<float>> storage;
while (sqlite3_step(stmt) == SQLITE_ROW) {
ids.push_back(sqlite3_column_int64(stmt, 0));
const void* blob = sqlite3_column_blob(stmt, 1);
int bytes = sqlite3_column_bytes(stmt, 1);
int n = bytes / sizeof(float);
storage.emplace_back(reinterpret_cast<const float*>(blob),
reinterpret_cast<const float*>(blob) + n);
datavs.push_back(storage.back().data());
}
sqlite3_finalize(stmt);
// 配置:spherical分区内积/余弦,tree数量与叶子数按数据规模调整
auto builder = research_scann::ScannInterface::ScannBuilder()
.SetTreeAHLeavesToSearch(50)
.SetTreeAHReordering(100);
g_scann = new research_scann::SingleMachineScannInterface(
datavs, ids.size(), datavs.empty() ? 0 : storage[0].size());
g_scann->Initialize(builder.Build());
sqlite3_result_int(ctx, (int)ids.size());
}
// 入口:注册函数
#ifdef _WIN32
__declspec(dllexport)
#endif
int sqlite3_scann_init(sqlite3* db, char** err, const sqlite3_api_routines* api) {
SQLITE_EXTENSION_INIT2(api);
sqlite3_create_function(db, "scann_build", 2, SQLITE_UTF8, nullptr,
scannBuildFunc, nullptr, nullptr);
return SQLITE_OK;
}
加载扩展的SQL命令是LOAD EXTENSION './libscannext.so'(Windows下是scannext.dll)。有一个容易踩的坑:SQLite默认编译可能关闭了扩展加载能力,需要在打开连接后执行sqlite3_enable_load_extension(db, 1),否则会报not authorized错误。
索引的内存生命周期需要特别注意。上面的示例为了简化把索引放在全局变量里,生产环境建议用key-value结构管理多个索引,并在数据库连接关闭时通过sqlite3_shutdown相关的回调清理,防止索引数据和数据库文件状态不一致。更稳妥的做法是把SCANN索引序列化后存到磁盘上一个附属文件中,路径记录在元数据表里,数据库重启时重新加载。
四、检索查询与性能调优
检索侧同样注册一个表值函数,应用层就能用纯SQL完成向量查询。使用效果类似这样:
-- 先构建索引(数据变更后需重建)
SELECT scann_build('documents', 'embedding');
-- 检索最相似的10条记录
SELECT d.id, d.content, s.distance
FROM scann_search(
(SELECT embedding FROM documents WHERE id = 1), -- 查询向量
10
) AS s
JOIN documents d ON d.id = s.rowid
ORDER BY s.distance ASC;
调优主要围绕SCANN的两个参数展开。leaves_to_search控制查询时扫描多少个分区,值越大召回率越高但延迟上升;reordering控制对候选集做精确重排的数量。在一个包含200万条384维向量的实测项目中,暴力检索单次查询约需850毫秒,而设置leaves_to_search为50、reordering为100时,SCANN查询仅需4.2毫秒,召回率@10达到96.3%。把reordering提高到500,召回率可以推到98.5%,代价是延迟增加到9毫秒左右,这个权衡需要根据业务对准确度的敏感度来定。
数据更新策略上,SCANN索引不支持高效的增量插入,常用做法是维护一个小的追加缓冲区,新向量先存表并用暴力检索覆盖最近写入的部分,当缓冲区超过阈值(比如总量的5%)时触发全量重建。对于更新频率低的场景,比如文档知识库,直接在夜间定时重建索引是最简单的方案。整个项目跑通后,你会发现一个不到5MB的SQLite数据库文件加上一个扩展动态库,就撑起了一套完整的安全便捷的语义检索服务,这在资源受限的环境里是重量级方案无法比拟的优势。