欢迎光临
我们一直在努力

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

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

封面信息图

在调用大语言模型构建问答系统或命令行助手时,精准匹配(Exact Match)缓存的命中率往往低得令人沮丧。

用户输入“如何重置密码”、“怎样修改我的密码”以及“忘记密码怎么找回”,在字面上完全不同,基于字符串哈希(如 MD5/SHA256)的传统 Redis 缓存会全部判定为未命中,从而发起三次昂贵且耗时的大模型请求。

为了解决这个问题,很多人第一反应是去部署 Milvus、Qdrant 或者 Pinecone 等专用向量数据库。但对于单机工具、轻量服务端或桌面端应用来说,为了几十兆的缓存数据引入一整套分布式向量系统,无论是部署成本还是内存开销都属于严重过度设计。

借助本地轻量级的 SQLite,将向量存储为二进制 BLOB,配合应用层或 SQLite 扩展进行余弦相似度计算,就能在 10 毫秒内实现一个高内聚、零外部运维依赖的本地语义缓存系统。

核心设计思路

语义缓存的闭环链路非常直接:

  • 输入向量化:收到用户查询 Q 后,首先调用轻量级嵌入模型(如 text-embedding-3-small 或本地 ONNX 向量模型)生成低维浮点向量(例如 512 或 1536 维)。
  • 向量检索与相似度比对:从 SQLite 中检索历史记录,计算当前向量与历史所有记录的余弦相似度(Cosine Similarity)。
  • 阈值决策:
    • 若最高相似度大于设定阈值(如 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 与基础数学计算的结合,足以满足绝大多数日常应用的语义缓存需求。

    赞(0)
    未经允许不得转载:171主机测评 » 基于本地 SQLite 的嵌入向量语义缓存实现
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址