当前位置:首页 > 编程开发 > 正文

200 行做本地语义搜索:sqlite-vec 替掉向量数据库

约 6548 字 · 预计阅读 17 分钟
一句话总结:几千到几十万条文档的语义搜索,不需要 Milvus 也不需要 Qdrant。SQLite 加一个扩展就能做 KNN 检索,再配上内置的 FTS5 做混合搜索,整个系统就是一个数据库文件。这篇给完整可跑的代码和规模边界。

先说结论性的判断:向量数据库的核心卖点是规模,而绝大多数项目的规模根本用不上它。你的知识库如果是几千份文档、几十万条切片,装一套需要 Docker、需要运维、需要单独备份的服务,纯属给自己找麻烦。

sqlite-vec 是个 SQLite 扩展(MIT / Apache-2.0 双协议),把 KNN 检索做成了 SQL 语句。数据在文件里,备份就是复制一个 .db 文件。

环境

pip install sqlite-vec sentence-transformers

两个依赖:sqlite-vec 自带预编译扩展,sentence-transformers 负责生成向量。中文场景建议用 BAAI/bge-small-zh-v1.5(512 维,约 100MB),英文用 all-MiniLM-L6-v2(384 维)。

如果你的机器 CPU 弱,也可以改用 Ollama 跑 bge-m3 通过 HTTP 拿向量,省掉 torch 那一坨依赖。

建库:三个表分工

结构很简单,一张原表存文本,一张虚拟表存向量,一张 FTS5 表做关键词。

import sqlite3, sqlite_vec, struct

DIM = 512   # 必须和嵌入模型输出维度一致,建表后不能改

db = sqlite3.connect("kb.db")
db.enable_load_extension(True)
sqlite_vec.load(db)
db.enable_load_extension(False)

db.executescript("""
CREATE TABLE IF NOT EXISTS docs (
    id      INTEGER PRIMARY KEY,
    path    TEXT NOT NULL,
    content TEXT NOT NULL
);

CREATE VIRTUAL TABLE IF NOT EXISTS vec_docs USING vec0(
    embedding float[512] distance_metric=cosine
);

CREATE VIRTUAL TABLE IF NOT EXISTS fts_docs USING fts5(
    content, content='docs', content_rowid='id'
);
""")

三个容易出错的地方:

  • 维度写死在建表语句里(float[512]),换模型必须重建表
  • distance_metric=cosine 要显式写,默认是 L2。文本嵌入用余弦更准
  • FTS5 的 content='docs' 是外部内容表模式,索引不重复存正文,省空间

写入:把向量打包成 BLOB

sqlite_vec.serialize_float32() 把 Python list 转成二进制,比传 JSON 字符串快得多。

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("BAAI/bge-small-zh-v1.5")

def add_docs(items):
    """items: [(path, content), ...]"""
    texts = [c for _, c in items]
    vecs = model.encode(texts, normalize_embeddings=True)

    with db:   # 事务,批量写快很多
        for (path, content), vec in zip(items, vecs):
            cur = db.execute(
                "INSERT INTO docs(path, content) VALUES (?, ?)", (path, content))
            rid = cur.lastrowid
            db.execute(
                "INSERT INTO vec_docs(rowid, embedding) VALUES (?, ?)",
                (rid, sqlite_vec.serialize_float32(vec.tolist())))
            db.execute(
                "INSERT INTO fts_docs(rowid, content) VALUES (?, ?)", (rid, content))

normalize_embeddings=True 这个参数别漏。bge 系列的向量归一化之后,余弦距离和点积等价,检索结果更稳。

如果不想用 serialize_float32,标准库也能做:

def pack(vec):
    return struct.pack(f"{len(vec)}f", *vec)   # 等价写法

查询:一条 SQL 就是 KNN

def search_semantic(query, k=10):
    qv = model.encode([query], normalize_embeddings=True)[0]
    rows = db.execute("""
        SELECT d.id, d.path, d.content, v.distance
        FROM vec_docs v
        JOIN docs d ON d.id = v.rowid
        WHERE v.embedding MATCH ? AND k = ?
        ORDER BY v.distance
    """, (sqlite_vec.serialize_float32(qv.tolist()), k)).fetchall()
    return rows

注意 k = ? 是 vec0 的专用参数,不能只用 LIMIT。两者都写更保险——k 控制检索深度,LIMIT 控制返回条数。

没有建索引这一步。vec0 默认做的是暴力扫描,几千到几十万条的量级下延迟在毫秒级,够用。

加关键词:FTS5 + RRF 融合

纯语义检索有个通病:精确词搜不到。错误码、产品型号、人名缩写这类东西,向量表达不出来。SQLite 的 FTS5 正好补这块。

def search_keyword(query, k=10):
    rows = db.execute("""
        SELECT d.id, d.path, d.content, f.rank
        FROM fts_docs f
        JOIN docs d ON d.id = f.rowid
        WHERE fts_docs MATCH ?
        ORDER BY f.rank
        LIMIT ?
    """, (query, k)).fetchall()
    return rows

def rrf(rank_lists, k=60):
    """倒数排名融合,不用校准两路分数量纲"""
    score = {}
    for rows in rank_lists:
        for rank, row in enumerate(rows):
            rid = row[0]
            score[rid] = score.get(rid, 0) + 1 / (k + rank + 1)
    return sorted(score.items(), key=lambda kv: kv[1], reverse=True)

def search_hybrid(query, k=10):
    fused = rrf([search_semantic(query, k), search_keyword(query, k)])
    ids = [rid for rid, _ in fused[:k]]
    if not ids:
        return []
    ph = ",".join("?" * len(ids))
    return db.execute(f"SELECT id, path, content FROM docs WHERE id IN ({ph})", ids).fetchall()

RRF 的好处是不用管两路分数的量纲差异——一边是余弦距离(0 到 2),一边是 BM25 排名分(可能是负数),直接加权相加会打架。按排名倒数求和就没这个问题。

这套融合在上一篇 RAG 文里讲过原理,这里换成了 SQLite 实现,代码量少了三分之二。

图 1:原文表、向量表、FTS5 表各管一件事,融合层只做排名合并

规模边界:什么时候该换

这是我认为最该说清楚的一段,因为「SQLite 能不能扛」这个问题没有统一答案。

数据量 检索方式 实际感受
1 万条以内暴力扫描毫秒级,无感
10 万条暴力扫描几十毫秒,可接受
100 万条需要 ANN开始吃力,考虑分片或换库
千万条以上分布式别硬撑

判断标准不是「多少条」,是「查询延迟能不能接受」。单机场景下 10 万条以内我建议直接用,超过就实测一次再决定。

外接的另一个信号是并发。SQLite 的写锁是库级的,多进程同时写入会互相等。只读查询并发的场景没事,写入频繁的应用要考虑 WAL 模式(PRAGMA journal_mode=WAL)。

坑清单

坑一:维度写错。 建表时 float[512],模型输出 384 维,插入直接报错。换模型必须删表重建,老数据要重新编码。

坑二:enable_load_extension 被禁用。 少数 Python 构建(比如 macOS 系统自带的)编译时关掉了扩展加载,enable_load_extension(True) 会抛异常。换用官方 python.org 或 conda 的解释器。

坑三:忘了归一化。 不归一化又用余弦距离,结果会偏向长文本(向量模长大)。bge 和大多数中文模型都建议 normalize_embeddings=True。

坑四:FTS5 中文分词。 SQLite 内置分词器对中文按字切,效果一般。要好的中文检索得挂 jieba 之类的自定义分词器,或者接受「按字匹配」——对专有名词其实还行。

坑五:把大段文本一条条塞进 vec0。 切片长度建议 300 到 500 字。太长了单条向量表达不准,太短了检索出来的片段没有上下文。

边界

这套方案适合「单机、可离线、数据量中小、以读为主」的场景:个人知识库、内部文档搜索、离线设备的检索、桌面应用内嵌搜索。

不适合:多租户 SaaS、需要水平扩展的服务、千万级以上的向量。这些真该用专门的库。

另外一个容易被忽略的优势值得提一句:sqlite-vec 能编成 WASM,同一套 SQL 在浏览器里也能跑。做纯前端的本地搜索时,这个特性很好用。

FAQ

和 FAISS 比怎么样?

FAISS 是纯索引库,只做向量检索,没有 SQL、没有事务、没有持久化。sqlite-vec 是「数据库里的向量能力」,胜在集成度高,代价是极限性能不如专用索引。

需要单独装 SQLite 吗?

不需要。Python 标准库自带 sqlite3,sqlite-vec 装的是扩展。

能不能直接存 OpenAI 的嵌入?

可以。text-embedding-3-small 是 1536 维,建表写 float[1536]。只是每次查询都要调 API,本地场景不如自己跑小模型。

多少条数据之后必须换?

没有硬阈值。我的经验是查询延迟超过 200ms 就该考虑换,而 10 万条以内通常到不了这个数。

支持增量更新吗?

支持,直接 insert。删除要同时删三张表(或者用触发器),漏删会导致检索出空内容。

结语

这套东西的价值不在技术含量,在于把基础设施成本降到零。一个 .db 文件,不占端口,不写配置文件,备份就是复制。

如果你正在为几千份文档纠结要不要上向量数据库,先花两小时把这 200 行跑通。很多项目跑起来之后会发现,根本不需要换。

参考资料

目录导航
  • 环境
  • 建库:三个表分工
  • 写入:把向量打包成 BLOB
  • 查询:一条 SQL 就是 KNN
  • 加关键词:FTS5 + RRF 融合
  • 规模边界:什么时候该换
  • 坑清单
  • 边界
  • FAQ
  • 结语
    • 参考资料
  • 相关文章:

  • RAG 第二季:混合检索 + 重排,把「搜不到」和「搜得不准」一起治2026-10-27 14:17:53
  • RAG 实战:几百行代码搭一个自己的知识库问答(Python + SQLite,不碰框架)2026-10-27 14:17:53
  • 发表评论

    ◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。