导读:本期聚焦于崔健创作的《SQLite能存储BERT向量嵌入吗?一个实战项目的完整实现方案》,敬请观看详情。向量嵌入的存储和检索一直是语义搜索绕不开的话题。提到向量数据库,很多人第一反应是专门的向量引擎,其实利用SQLite配合sqlite-vec扩展,完全可以把BERT生成的768维向量嵌入落地到本地文件里,构建一个轻量级的语义检索系统。这篇文章将从向量嵌入的基本原理讲起,详细演示如何用Python调用BERT模型生成文本向量,如何设计SQLite表结构存储向量数据,以及如何利用余弦相似度实现近邻搜索。文中还会对比暴力检索与索引加速的性能差异,并给出一个完整的端到端实战代码,适合想在本地快速搭建语义搜索能力的开发者参考。

做语义搜索或者文本聚类时,最核心的中间产物就是向量嵌入。BERT这类预训练模型可以把一段文本压缩成一个固定长度的稠密向量,语义相近的文本在向量空间中距离也会更近。拿到了向量之后,下一个问题就是:这些向量存在哪里?如果数据量不大、部署环境简单,直接引入一套独立的向量数据库未免太重了。SQLite作为一个单文件数据库,配合向量检索扩展,完全可以承担起BERT向量嵌入的存储和检索任务,本文就围绕这个思路做一个完整的实战演示。

SQLite能存储BERT向量嵌入吗?一个实战项目的完整实现方案

一、BERT向量嵌入是怎么来的

在使用BERT做向量化之前,需要先理解它输出的张量结构。BERT接收token id序列作为输入,输出两层结果:一层是每个token对应的隐藏状态,另一层是[CLS]位置的池化输出。做句子级别的嵌入时,通常有三种池化策略可选:直接取[CLS]向量、对所有token的隐藏状态求平均(mean pooling),或者使用sentence-transformers这类库自带的优化池化方式。实践证明mean pooling在原始BERT上的效果通常优于直接使用CLS向量,因为CLS位置的训练目标并不是直接对齐语义。

维度是存储设计的关键参数。base规模的BERT模型输出768维向量,large模型是1024维,distilbert也是768维。假如我们有一万条文本,每条文本生成一个768维的float32向量,那么原始向量数据量大约是10000乘以768再乘以4字节,约30MB。这个量级放在内存里毫无压力,放到SQLite单文件里也非常轻松。

生成向量的代码可以借助sentence-transformers库,它封装了池化逻辑和归一化操作。安装命令为pip install sentence-transformers,首次运行会自动下载模型权重。下面是一段生成向量并归一化的示例代码:

from sentence_transformers import SentenceTransformer
import numpy as np

# 加载模型,768维输出
model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')

texts = [
    "SQLite是一个轻量级的嵌入式数据库",
    "BERT是谷歌提出的预训练语言模型",
    "向量嵌入用于衡量文本语义相似度"
]

# encode返回numpy数组,normalize_embeddings=True会做L2归一化
embeddings = model.encode(texts, normalize_embeddings=True)
print(embeddings.shape)  # 输出 (3, 384)

# 归一化后内积等价于余弦相似度
sim = np.dot(embeddings[0], embeddings[1])
print(f"前两条文本的相似度: {sim:.4f}")

这里特意选了一个多语言小模型,维度是384,方便演示。如果你用中文场景较多,也可以换成shibing624/text2vec-base-chinese这类中文模型,维度为768。L2归一化是一个很重要的细节:归一化之后,向量的内积就等于余弦相似度,后续计算可以省去除以模长的步骤。

二、在SQLite中设计向量存储表

向量本质上是二进制大对象,在SQLite中对应BLOB类型。最直接的做法是把numpy数组通过tobytes()方法序列化成字节串存进BLOB字段,读取时再用np.frombuffer()还原。这种方案不依赖任何扩展,兼容性最好,缺点是SQL层面无法对向量做运算,所有相似度计算都要在应用层完成。

另一种方案是使用sqlite-vec扩展,它提供了原生的向量数据类型和虚拟表支持,可以在SQL语句中直接调用vec_distance_cosine这类函数。不过扩展需要额外编译加载,部署多一步操作。下面的建表方案采用第一种通用做法,把两种需求的字段都设计进去:

CREATE TABLE IF NOT EXISTS documents (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    content TEXT NOT NULL,          -- 原始文本
    model_name TEXT NOT NULL,       -- 记录生成向量的模型,避免混用
    dim INTEGER NOT NULL,           -- 向量维度
    embedding BLOB NOT NULL,        -- 序列化后的向量
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_docs_model ON documents(model_name, dim);

写入数据时要注意字节序和精度。numpy默认使用系统本地字节序,跨平台读取时可能出现高低位颠倒的问题,稳妥的做法是统一转为<f4(小端float32)。另外不要用float64存储,精度提升对检索效果几乎没有影响,但存储空间直接翻倍。写入和读取的完整流程如下:

import sqlite3
import numpy as np

def save_embedding(conn, content, vec, model_name):
    # 统一转成小端float32再序列化
    blob = np.asarray(vec, dtype='<f4').tobytes()
    conn.execute(
        "INSERT INTO documents (content, model_name, dim, embedding) VALUES (?, ?, ?, ?)",
        (content, model_name, len(vec), blob)
    )

def load_embeddings(conn, model_name=None):
    if model_name:
        rows = conn.execute(
            "SELECT id, content, embedding FROM documents WHERE model_name = ?",
            (model_name,)
        ).fetchall()
    else:
        rows = conn.execute(
            "SELECT id, content, embedding FROM documents"
        ).fetchall()

    ids, texts, vecs = [], [], []
    for rid, text, blob in rows:
        vec = np.frombuffer(blob, dtype='<f4')
        ids.append(rid)
        texts.append(text)
        vecs.append(vec)
    return ids, texts, np.array(vecs)

还有一个容易踩的坑:np.frombuffer返回的数组是只读的,如果后续需要修改向量(比如再做一次归一化),必须先调用copy(),否则会抛出缓冲区不可写的异常。此外,同一个表里不要混存不同模型、不同维度的向量,查询时一旦混在一起,矩阵运算会直接报形状错误,这也是建表时保留model_namedim字段的原因。

三、实现语义检索:从暴力计算到索引优化

拿到全部向量之后,语义检索的本质就是找 Top-K 近邻。数据量在几万条以内时,最简单有效的方案是把所有向量堆成一个矩阵,一次性做矩阵乘法,然后取相似度最高的K条。numpy底层调用BLAS库,计算速度非常快,一万条384维向量的一次全量检索在普通笔记本上耗时通常只有几毫秒。

def search(query_vec, texts_matrix, texts, top_k=5):
    # 向量已归一化,内积即余弦相似度
    scores = texts_matrix @ query_vec
    top_idx = np.argsort(-scores)[:top_k]
    return [(texts[i], float(scores[i])) for i in top_idx]

# 完整检索流程
query = "轻量级数据库有哪些优势"
qvec = model.encode([query], normalize_embeddings=True)[0]
ids, texts, matrix = load_embeddings(conn, model_name='paraphrase-multilingual-MiniLM-L12-v2')
results = search(qvec, matrix, texts, top_k=3)
for text, score in results:
    print(f"{score:.4f}  {text}")

暴力检索的优点是结果精确、实现简单,缺点是复杂度随数据量线性增长。当数据规模涨到几十万、上百万条时,全量矩阵乘法的延迟就会明显上升,内存占用也成问题。这时可以考虑两条优化路径。

第一条路径是引入sqlite-vec扩展,使用它的虚拟表和 ANN 索引能力。加载方式是在连接数据库后执行conn.enable_load_extension(True),再调用sqlite_vec.load(conn)。它支持把向量存入vec0虚拟表,并用MATCH语法做KNN查询,同时提供chunk_sizepartition key来组织数据分片,检索时会先做粗筛再精算,速度比暴力扫描快一个数量级。

第二条路径是纯应用层的近似检索,比如用hnswlib构建HNSW图索引,把SQLite当作向量数据的持久化存储,内存中维护索引结构。启动服务时从SQLite读出全部向量重建索引,写入时双写同步。这种架构兼顾了SQLite的可靠持久化和HNSW的高速检索,是一个很常见的工程折中方案。两种路径的简单对比如下:

方案数据量级精确度部署复杂度
numpy暴力检索十万级以内精确结果最低,无额外依赖
sqlite-vec扩展百万级精确或近似中等,需加载扩展
SQLite加HNSW索引千万级近似结果较高,需维护双写

四、端到端实战:构建一个本地语义搜索引擎

最后把前面的模块串起来,做一个可运行的小型语义搜索引擎。它的功能是:批量导入一批文本,向量化后写入SQLite;接收用户查询,返回语义最相近的若干条结果。为了让工程结构清晰,把数据库操作、向量化、检索三部分封装成类:

import sqlite3
import numpy as np
from sentence_transformers import SentenceTransformer

class SemanticSearchEngine:
    def __init__(self, db_path, model_name='paraphrase-multilingual-MiniLM-L12-v2'):
        self.conn = sqlite3.connect(db_path)
        self.model = SentenceTransformer(model_name)
        self.model_name = model_name
        self._init_db()
        self._cache = None  # 向量缓存,避免每次检索都查库

    def _init_db(self):
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS documents (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                content TEXT NOT NULL,
                model_name TEXT NOT NULL,
                dim INTEGER NOT NULL,
                embedding BLOB NOT NULL
            )""")

    def add_documents(self, texts):
        vecs = self.model.encode(texts, normalize_embeddings=True)
        for text, vec in zip(texts, vecs):
            blob = np.asarray(vec, dtype='<f4').tobytes()
            self.conn.execute(
                "INSERT INTO documents (content, model_name, dim, embedding) VALUES (?, ?, ?, ?)",
                (text, self.model_name, len(vec), blob))
        self.conn.commit()
        self._cache = None  # 数据变了,缓存失效

    def _load_cache(self):
        if self._cache is None:
            rows = self.conn.execute(
                "SELECT content, embedding FROM documents WHERE model_name = ?",
                (self.model_name,)).fetchall()
            texts = [r[0] for r in rows]
            matrix = np.array([np.frombuffer(r[1], dtype='<f4') for r in rows])
            self._cache = (texts, matrix)
        return self._cache

    def search(self, query, top_k=5):
        texts, matrix = self._load_cache()
        if not texts:
            return []
        qvec = self.model.encode([query], normalize_embeddings=True)[0]
        scores = matrix @ qvec
        top_idx = np.argsort(-scores)[:top_k]
        return [(texts[i], float(scores[i])) for i in top_idx]

if __name__ == '__main__':
    engine = SemanticSearchEngine('semantic.db')
    engine.add_documents([
        "SQLite无需独立服务进程,整个数据库就是一个文件",
        "BERT通过双向Transformer编码器理解上下文语义",
        "余弦相似度衡量两个向量方向的接近程度",
        "今天是周六,天气不错适合出去走走"
    ])
    for text, score in engine.search("向量相似度怎么计算"):
        print(f"{score:.4f} | {text}")

运行后可以看到,查询“向量相似度怎么计算”时,得分最高的正是“余弦相似度衡量两个向量方向的接近程度”这条,而与天气相关的文本得分很低,说明语义匹配确实生效了。这套代码可以直接扩展到文档问答、FAQ自动应答、个人笔记检索等场景。

从工程角度看,这个方案的最大价值在于零部署成本:一个db文件加一个模型权重,拷贝到任何机器上就能跑,特别适合桌面工具、边缘设备或者原型验证阶段。当数据规模或者并发需求增长到SQLite的边界时,再平滑迁移到专业向量数据库也不迟,因为向量生成的部分完全不用改动,只需要替换存储和检索层。这种渐进式的架构演进思路,比一开始就搭建重型基础设施要务实得多。

SQLiteBERT向量嵌入向量检索修改时间:2026-09-08 14:27:41

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260908/52814.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。