要在 SQLite 里做向量相似度搜索,首先得承认一个事实:SQLite 原生并没有向量数据类型和向量索引。但好在它的 BLOB 字段可以存储二进制浮点数组,配合自定义函数,我们完全能算余弦相似度。问题是当数据达到几万、几十万行时,逐条计算的开销会变得很难接受。本文会用一种轻量的近似方案解决这个矛盾:先用随机投影把高维向量映射成哈希桶,查询时只对桶内候选做精确比对,从而把整体的扫描量降下来。

一、把向量存进 BLOB,而不是 JSON
向量通常是一组 float32 或 float64 数值。JSON 文本虽然可读性好,但存储体积大、解析速度慢,每次比较都要重新切字符串。更实际的做法是使用 struct.pack 将数组转成紧凑的二进制数据,写入 BLOB 字段。由于浮点数组长度固定,读取时可以按维度精确解析,不会产生歧义。
建表时可以同时缓存向量的模长,这样计算余弦相似度时不必每次都对同一个向量重复开方。还需要给分桶字段建立索引,这是后面近似搜索能够快速过滤候选集的基础。
CREATE TABLE vectors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vector BLOB NOT NULL,
bucket TEXT,
norm REAL
);
CREATE INDEX idx_vectors_bucket ON vectors(bucket);
写入数据时,用 Python 将列表编码成小端字节序的 float32 数组。小端字节序在主流平台上兼容性较好,但读写两端必须保持一致的约定。
import sqlite3
import struct
def encode_vector(values):
return struct.pack('<%df' % len(values), *values)
conn = sqlite3.connect('vectors.db')
cursor = conn.cursor()
vector = [0.12, -0.45, 0.89, 0.03, -0.77, 0.31, 0.56, -0.29]
cursor.execute(
'INSERT INTO vectors (vector, norm) VALUES (?, ?)',
(encode_vector(vector), sum(v * v for v in vector) ** 0.5)
)
conn.commit()
相比 JSON 文本,BLOB 方案几乎没有冗余。一个 1000 维的 float32 向量只占 4KB 左右,而 JSON 可能因为分隔符和格式化额外增加数倍体积。它的缺点是需要自行管理字节序和维度信息,团队内部要约定好向量编码规范。当然也可以使用 sqlite-vec 这类扩展,但本方案完全不依赖外部库,更适合嵌入式或受限环境。
二、用自定义函数计算余弦相似度
SQLite 原生没有向量点积函数,但可以通过 sqlite3.create_function 注册一个 Python 函数,把两条向量的 BLOB 内容解析出来再计算。余弦相似度等于两个向量的点积除以各自模长的乘积,值域在 -1 到 1 之间,越接近 1 表示方向越一致。
下面这个实现会先按 4 字节一组解出 float32 数组,再计算点积和模长。需要注意,如果某条向量的模长为零,需要返回 0,避免除零错误。
import math
import struct
def unpack_vector(blob):
count = len(blob) // 4
return struct.unpack('<%df' % count, blob)
def cosine_similarity(a, b):
va = unpack_vector(a)
vb = unpack_vector(b)
dot = sum(x * y for x, y in zip(va, vb))
na = math.sqrt(sum(x * x for x in va))
nb = math.sqrt(sum(x * x for x in vb))
if na == 0 or nb == 0:
return 0.0
return dot / (na * nb)
conn.create_function('cosine_similarity', 2, cosine_similarity)
注册完成后,可以直接在 SQL 中调用这个函数。目标向量需要编码成 BLOB 后通过占位符传入,查询结果按相似度降序排列并限制条数。
SELECT id, cosine_similarity(vector, ?) AS score FROM vectors ORDER BY score DESC LIMIT 10;
Python 自定义函数在数据量较小时非常灵活,调试也方便。但每一行都会触发一次 Python 与 SQLite 之间的数据拷贝,频繁调用会带来额外开销。如果追求更高性能,可以改用 C 扩展注册函数,或者把点积拆解成多个 SQL 表达式提前过滤一部分候选。对于十万级数据,暴力扫描的单次查询通常需要几百毫秒,虽然能用,但不够理想,这正是引入近似分桶的原因。
三、随机投影分桶:近似搜索的关键
随机投影是一种经典的局部敏感哈希思路。先生成若干随机超平面,每个向量与这些超平面的法向量做点积,结果为正就记为 1,否则记为 0。这样每个向量都能得到一段固定长度的二进制签名。签名相同或仅差少量位的向量,在高维空间中有更大概率是近邻。把签名作为桶号存进表里并建立索引,查询时先算查询向量的签名,再只对桶内候选做精确余弦计算,扫描量会大幅下降。
生成投影矩阵的代码可以用 NumPy 完成。矩阵需要保存下来复用,否则每次重启程序后生成的新随机平面会导致已有桶号全部失效。
import numpy as np
DIM = 8
BITS = 16
rng = np.random.default_rng(42)
projection = rng.standard_normal((BITS, DIM))
def sign_vector(blob):
arr = np.frombuffer(blob, dtype='<f4')
dots = projection @ arr
bits = (dots > 0).astype(int)
return ''.join(str(b) for b in bits)
得到签名函数后,需要把已有数据的桶号一次性补上。对于新写入的数据,同样在写入时计算桶号,保持索引实时可用。
for row in conn.execute('SELECT id, vector FROM vectors'):
vid, blob = row
bucket = sign_vector(blob)
conn.execute('UPDATE vectors SET bucket = ? WHERE id = ?', (bucket, vid))
conn.commit()
查询时如果只取签名完全一致的桶,速度快但容易漏掉真实近邻,因为哈希边界附近的向量可能因为一位差异被分到不同桶。改进方法是同时查询当前桶以及海明距离为 1 的邻居桶。这样候选集会扩大一些,但召回率明显提升。
def neighbor_buckets(signature, flip=1):
buckets = {signature}
for i in range(len(signature)):
changed = list(signature)
changed[i] = '1' if changed[i] == '0' else '0'
buckets.add(''.join(changed))
return buckets
query_bucket = sign_vector(encoded_query)
buckets = neighbor_buckets(query_bucket, flip=1)
placeholders = ','.join('?' for _ in buckets)
sql = f'''
SELECT id, cosine_similarity(vector, ?) AS score
FROM vectors
WHERE bucket IN ({placeholders})
ORDER BY score DESC
LIMIT 10
'''
rows = conn.execute(sql, [encoded_query] + list(buckets)).fetchall()
桶号长度和翻转位数需要在速度和召回率之间做权衡。BITS 越长,分桶越细,候选集越小,但漏召回风险越高;flip 越大,召回越充分,但扫描量也随之增加。16 位签名可以把数据分布到 65536 个桶中,如果总行数只有几万,很多桶会处于空置状态,查询时自然只落到很小一部分数据上。
四、近似方案的效果与适用边界
暴力扫描的时间复杂度是 O(N×d),其中 N 是总行数,d 是向量维度。随机投影分桶后,如果平均每个桶内有 m 条数据,扩展候选后扫描量约等于 m 乘以扩展桶数。当 N 较大而桶分布均匀时,m 通常远小于 N,因此查询耗时可以从几百毫秒降到几毫秒。但这是以牺牲部分精度为代价的近似搜索,不能保证返回全局最相似的 Top K。
这种方案适合允许损失少量相似项的场景,例如本地知识库的草稿检索、嵌入式设备上的语义匹配、小型推荐系统的初筛。对于金融风控、医疗检索等对召回完整性要求极高的场景,不应直接使用纯随机投影的近似结果。如果数据量达到百万级,或者需要更稳定的召回,建议引入 sqlite-vec、sqlite-vss 等专门扩展,或者单独部署向量数据库。
落地时还需要注意几点:投影矩阵必须持久化保存,所有写入和更新都要同步计算桶号;向量维度太低时随机投影的区分度不强,通常几十维以上才有实用价值;如果更新频繁,需要重算桶号,成本较高,SQLite 更适合读多写少的场景。只要这些边界控制清楚,用 SQLite 做轻量级向量相似度搜索是完全可行的。

