基于本地 SQLite 的嵌入向量语义缓存实现

在调用大语言模型构建问答系统或命令行助手时,精准匹配(Exact Match)缓存的命中率往往低得令人沮丧。
用户输入“如何重置密码”、“怎样修改我的密码”以及“忘记密码怎么找回”,在字面上完全不同,基于字符串哈希(如 MD5/SHA256)的传统 Redis 缓存会全部判定为未命中,从而发起三次昂贵且耗时的大模型请求。
为了解决这个问题,很多人第一反应是去部署 Milvus、Qdrant 或者 Pinecone 等专用向量数据库。但对于单机工具、轻量服务端或桌面端应用来说,为了几十兆的缓存数据引入一整套分布式向量系统,无论是部署成本还是内存开销都属于严重过度设计。
借助本地轻量级的 SQLite,将向量存储为二进制 BLOB,配合应用层或 SQLite 扩展进行余弦相似度计算,就能在 10 毫秒内实现一个高内聚、零外部运维依赖的本地语义缓存系统。
核心设计思路
语义缓存的闭环链路非常直接:
- 若最高相似度大于设定阈值(如 0.92),判定为语义命中,直接从本地数据库取出对应的 response 返回,耗时由数秒降至 5~10 毫秒,且无后续 Token 成本;
- 若未命中,正常调用大模型生成回复,然后将 (query, embedding, response, timestamp) 异步写入 SQLite。
基于 TypeScript 与 SQLite 的轻量实现
在单机中小型场景下(缓存条目在数万条以内),现代 CPU 遍历计算几万个向量的余弦相似度仅需几毫秒,无需安装任何 C++ 扩展,直接使用 Float32Array 和二进制 Buffer 即可达成极高吞吐。
import Database from "better-sqlite3";
export interface CacheEntry {
id: number;
query: string;
response: string;
similarity: number;
}
export class SemanticCache {
private db: Database.Database;
constructor(dbPath = "semantic_cache.db") {
this.db = new Database(dbPath);
this.init();
}
private init(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS semantic_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
query TEXT NOT NULL,
embedding BLOB NOT NULL,
response TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_records_created ON semantic_records(created_at);
`);
}
// 计算两个向量的余弦相似度
private cosineSimilarity(a: Float32Array, b: Float32Array): number {
let dot = 0.0;
let normA = 0.0;
let normB = 0.0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
if (normA === 0 || normB === 0) return 0;
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
// 查询最佳匹配
public query(targetEmbedding: number[], threshold = 0.92): CacheEntry | null {
const targetArr = new Float32Array(targetEmbedding);
const stmt = this.db.prepare("SELECT id, query, embedding, response FROM semantic_records");
const rows = stmt.all() as Array<{
id: number;
query: string;
embedding: Buffer;
response: string;
}>;
let bestMatch: CacheEntry | null = null;
let maxSim = -1;
for (const row of rows) {
// 从 Buffer 中零拷贝构建 Float32Array
const candidateArr = new Float32Array(
row.embedding.buffer,
row.embedding.byteOffset,
row.embedding.byteLength / 4
);
const sim = this.cosineSimilarity(targetArr, candidateArr);
if (sim > maxSim) {
maxSim = sim;
if (sim >= threshold) {
bestMatch = {
id: row.id,
query: row.query,
response: row.response,
similarity: sim,
};
}
}
}
return bestMatch;
}
// 写入新缓存
public set(query: string, embedding: number[], response: string): void {
const floatArr = new Float32Array(embedding);
const buffer = Buffer.from(floatArr.buffer, floatArr.byteOffset, floatArr.byteLength);
const stmt = this.db.prepare(`
INSERT INTO semantic_records (query, embedding, response, created_at)
VALUES (?, ?, ?, ?)
`);
stmt.run(query, buffer, response, Date.now());
}
// LRU 淘汰:保留最近 N 条数据
public prune(maxEntries = 10000): void {
this.db.prepare(`
DELETE FROM semantic_records
WHERE id NOT IN (
SELECT id FROM semantic_records ORDER BY created_at DESC LIMIT ?
)
`).run(maxEntries);
}
}
生产调优与踩坑经验
在实际落地该方案时,有几个务实细节值得注意:
二进制存储格式的选择:千万不要把浮点向量以 JSON 字符串(如 [0.012, -0.34, …])存入数据库。1536 维的浮点数组转成 JSON 字符串大约占用 15KB,而存为 Float32Array 的二进制 Buffer 仅占用 1536 * 4 = 6144 字节(约 6KB),体积减少了 60% 以上,而且从 SQLite 读取时无需做昂贵的 JSON.parse,读取速度提升一个数量级。
相似度阈值的设定:
- 阈值设为 0.85 时,可能会出现误命中(例如“如何注销账号”与“如何注册账号”相似度可能在 0.86 左右);
- 阈值建议设定在 0.92 ~ 0.95 之间。在这个区间内,虽然牺牲了一小部分边缘命中的机会,但能够确保返回内容的语义一致性,绝对不发生指鹿为马的情况。
数据规模与扩展上限:对于个人 CLI 工具或中小型企业内部知识库,缓存条目通常在 1,000 ~ 20,000 条之间。在这种规模下,纯内存余弦遍历耗时在 3 毫秒以内。如果缓存规模突破 10 万条,可以通过加载官方的 sqlite-vec 扩展插件,利用 SIMD 指令和向量索引继续保持毫秒级性能,而无需对架构做翻天覆地的重构。
用最简单的技术解决真实问题,比引入昂贵复杂的架构更有生命力。SQLite 与基础数学计算的结合,足以满足绝大多数日常应用的语义缓存需求。
