ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于SQLite+sqlite-vec构建轻量级私域知识库实战

基于SQLite+sqlite-vec构建轻量级私域知识库实战 自己手头压着一堆合同、产品手册、会议纪要想做一个能“问人话”的知识库。一搜教程满屏都是 Milvus、Elasticsearch、Docker Compose配置都能写满一页纸。但你的真实需求可能就是几百篇内部文档想在内网环境里快速跑出一个可用的语义检索还想好备份、好迁移。这个体量真不一定要上重型武器。我最终采用的是 SQLite sqlite-vec 这套组合。SQLite 本身就是单文件数据库几乎所有语言都有现成驱动sqlite-vec 是 SQLite 官方的向量检索扩展能直接在 SQLite 里建“向量表”、算相似度、做近邻搜索。整个知识库就是一个.db文件拷走就是迁移删掉就是销毁。这篇文章就把我从零到一搭私域知识库的完整过程拆开讲清楚包括为什么选它、原理是什么、embedding 怎么搞、文档怎么切块、查询怎么做最后再附上我踩过的坑。1. 方案选型为什么是 SQLite sqlite-vec 而不是正经向量数据库1.1 百篇文档场景的真实需求先别急着聊技术我们把需求拆开看。所谓“100 篇文档内的私域知识库”核心约束是三个文档量级小、数据私密性高、团队运维能力有限。100 篇文档听起来不多但换算成实际数据量哪怕每篇 2 万字全文也就 200 万字。按常见的文档切块策略切成 300 到 800 字一块大概切出 3000 到 6000 个文本块。每个文本块用一个 embedding 模型编码成几百维的浮点向量存储量也就是几十 MB 级别。这个量级的向量检索暴力遍历 内存缓存完全能扛住毫秒级返回结果。再看安全性。私域知识库里装的是合同、技术规范、内部会议结论这些内容原则上不能出内网更不能发给第三方 embedding 服务。所以最好用本地小模型做向量化配合本地数据库存储形成一条完全离线的链路。SQLite 单文件方案天然满足没有服务进程没有网络端口没有外部依赖。还有运维约束。小团队通常没有专职 DBA也不愿意维护什么 Kubernetes 集群。真要引入 Milvus 这类系统光是解决容器网络、持久化存储、监控告警就够喝一壶了。而 SQLite 的运维几乎是零一个文件数据库本身即备份。这对于“百篇文档”这种长尾场景是更务实的取舍。1.2 让我放弃重型向量数据库的几个理由我最初确实考虑过几个方案最后全部推翻理由是Milvus / Qdrant / Weaviate功能确实强支持十亿级向量、分片、副本、滚动升级。但为了几千条向量去部署一整套分布式服务纯属杀鸡用牛刀。而且这类系统需要常驻内存小内存服务器跑起来很勉强。Chroma / LanceDB这俩是轻量级向量库集成比 Milvus 简单但还是引入了一个“框架层”。Chroma 有自己的 Python API 和持久化格式LanceDB 用的是自定义列式存储。用它们就等于把知识库的核心数据格式绑定到了特定项目上后续想用 C# 或 Go 直接读取数据路径变窄了。传统关系表 LIKE 查询SQLite 原生LIKE %关键词%只能做字面匹配。用户问“劳动争议的逾期举证后果”文档里写的是“超过举证期限”字面上完全对不上召回率惨不忍睹。sqlite-vec 最吸引我的一点是它把向量检索能力以扩展形式嵌入了 SQLite让原本只能查文本的关系数据库直接具备语义检索的能力。这样我不需要额外引入一个独立数据库也不需要搬运数据直接在 SQLite 里建一张虚拟向量表用标准 SQL 就能做近邻搜索。整个知识库的落点仍然是一个.db文件跨语言访问毫无障碍。1.3 这套组合的适用边界把话说明白SQLite sqlite-vec 并不是万能的。它最舒服的区间是文本块数量在几千到十几万这个量级单机运行离线部署要求低运维。一旦数据量到千万级向量或者需要多节点扩容SQLite 方案会力不从心那时候再上重型向量库不迟。适用人群也很清晰个人知识库爱好者、小团队内部文档助手、需要离线交付给政企客户的 RAG 应用、或者你是独立开发者想找个不需要买服务器就能跑起来的 MVP。我见过不少几十万篇文档的项目把方案设计成 Milvus 三节点 Kafka最后运维成本比业务成本还高这套组合恰恰能避免那种局部最优、全局次优的陷阱。2. 核心原理sqlite-vec 到底在 SQLite 里做了什么事2.1 普通数据库和向量数据库本质差别要理解 sqlite-vec先看普通 SQLite 的局限。普通表里的数据以 B-tree 索引组织适合精确匹配、范围查询比如WHERE name 张三或WHERE price BETWEEN 100 AND 200。但语义检索面对的不是“精确相等”而是“含义接近”。两个文本块可能一个词都不重合但语义等价也可能字面相似但表达完全相反。这类问题传统索引结构无法解决需要把文本先编码成向量再用向量之间的距离衡量语义关系。embedding 模型做的事情是把一段文本映射成一个固定维数的浮点数组。比如 BGE-small-zh-v1.5 输出 512 维向量。同一模型编码下“劳动争议的逾期举证后果”和“超过举证期限的法律后果”在向量空间里距离很近而“今天食堂菜单”离它们就很远。语义检索本质上是向量最近邻搜索KNN。2.2 vec0 虚拟表建表、插入、查询的完整模型sqlite-vec 的核心是一个叫vec0的虚拟表模块。你可以把它理解成 SQLite 里的一种特殊表专门存向量。看一段简化的建表 SQL-- 注意固定语法向量列必须是最后一列 CREATE VIRTUAL TABLE vec_chunks USING vec0( chunk_id INTEGER PRIMARY KEY, embedding FLOAT[512] );与普通表不同vec0表的数字签名是固定的除了主键你只能定义一个或多个向量列且向量列的维度必须在建表时固定。维度一旦确定所有插入的向量长度必须一致否则直接报错。插入数据也简单向量以 JSON 数组字符串形式传入INSERT INTO vec_chunks(chunk_id, embedding) VALUES (1, [0.001, 0.002, ...省略512个数...]);查询是这套方案最爽的地方。想找与某个 query 向量最相近的 top 5SQL 这样写SELECT chunk_id, distance FROM vec_chunks WHERE embedding MATCH [0.001, ...] ORDER BY distance LIMIT 5;MATCH关键字是 sqlite-vec 的查询入口配合ORDER BY distance和LIMIT就能拿到近邻。默认支持两种距离度量L2平方距离和cosine余弦距离准确说是余弦距离数值越小越相似。实际项目中我一般用cosine它对向量长度不敏感适合没做归一化的文本 embedding如果 embedding 已经 L2 归一化过L2和cosine排序效果基本等价。2.3 索引和暴力扫描的取舍很多读者会问向量表到底要不要建专门的索引sqlite-vec 的朴素实现里查询会扫描表中所有向量逐一计算距离然后排序取 top k。听起来很低效但在上文说的几千条向量规模下一次全表扫描也就是几毫秒。sqlite-vec 也支持在vec0表上执行CREATE INDEX来加速但这个索引针对的是更大数据量的场景。索引会占用额外磁盘空间构建也需要时间。对于 100 篇文档产生的几千个向量直接暴力扫描反而是最可控、最不容易出问题的方案。我建议先用不上索引跑通全流程等数据量超过 10 万行、查询明显变慢时再回头建索引。这条经验同样适用于 SQLite 里的 FTS5 全文索引它是一个道理小数据量直接扫大数据量再考虑 index。3. 实操全过程从零搭建 100 篇文档私域知识库下面这部分是整个项目的主干从环境准备到查询实现每一步我都写清楚思路和代码方便你直接照着干。3.1 环境准备加载扩展和安装依赖我用的是 Python 3.10 SQLite 3.40。SQLite 需要启用扩展加载能力Python 原生 sqlite3 模块默认禁用了load_extension需要先打开import sqlite3 conn sqlite3.connect(kb.db) conn.enable_load_extension(True) conn.load_extension(./vec0) # 编译好的扩展文件Windows 为 vec0.dllLinux 为 vec0.so conn.enable_load_extension(False)vec0扩展可以从 sqlite-vec 官方 Release 页下载对应平台的预编译文件也可以源码编译。如果你用的是 Linux记得核对 glibc 版本老系统容易遇到编译产物不兼容问题。加载成功后执行下面的 SQL 验证SELECT vec_version();如果返回类似v0.1.6的版本号说明扩展可用。顺便提一句我建议安装一个 DB Browser for SQLite 作为可视化工具。它可以直接打开.db文件浏览普通表、执行 SQL、查看vec0虚拟表里的向量数据。调试阶段非常有用比如查看某个 chunk 的原文、确认某条向量有没有写进去比在代码里反复打印方便多了。3.2 embedding 模型选择离线、体积、中文效果怎么平衡私域知识库的 embedding 模型选择是个关键决策。我试过几类方案最终确定用本地小模型。纯离线本地模型BGE-small-zh-v1.5512 维模型文件约 100MB中文效果非常稳。也可以用text2vec-large-chinese或 M3E 系列但模型文件更大推理更慢对百篇文档场景收益不明显。API 型 embedding质量好、省本地算力但数据要出内网私域场景首先排除。超级大的多语言模型效果好可 1GB 的模型塞到服务器上CPU 推理一次几百毫秒没必要。我最终用sentence-transformers库加载BGE-small-zh-v1.5。在只有 CPU 的环境下处理一篇 5000 字的文档大约需要 200 到 500 毫秒100 篇文章全量 embedding 也就一两分钟完全可接受。from sentence_transformers import SentenceTransformer model SentenceTransformer(BAAI/bge-small-zh-v1.5) def embed_text(text: str): return model.encode(text, normalize_embeddingsTrue).tolist()注意normalize_embeddingsTrue这一步很重要既能让向量配合 cosine 距离获得更稳定的排序也能略微提升检索效果。3.3 文档切块块太大召回损块太小语义断把整篇文章直接塞给 embedding 模型并不合适。模型有最大输入长度限制通常是 512 token而且全文语义过于复杂一个向量根本无法代表所有信息。文档切块是知识库构建的核心难点之一。切块策略我分两种固定长度切块按字符数或 token 数切每块之间加重叠。实现简单但容易从句子中间切断破坏语义。结构感知切块根据 Markdown 标题、段落、列表把文档切成长度不一的语义单元再按需合并。实际项目中我采用“结构优先 长度兜底”的组合策略。首先按标题分割文档如果某个标题下的内容超过 800 字再按句子边界二次切分。每一块再加 50 字左右的重叠上下文避免句子被拦腰截断导致召回掉链子。我的切块参数参考参数推荐值说明单块目标字数300-600 字太短语义不足太长检索精度下降重叠字数50-100 字保证跨块句子上下文完整标题保留是把标题拼入块文本增强召回效果例如“## 违约条款\n\n按合同第 12 条…”去重是连续重复的换行、空行压掉减少无效 token代码逻辑如下import re def split_markdown_by_heading(text: str, max_chars600, overlap50): lines text.splitlines() sections [] current_title 未知章节 current_buf [] for line in lines: m re.match(r^(#{1,3})\s(.*), line) if m and current_buf: sections.append((current_title, .join(current_buf).strip())) current_title m.group(2) current_buf [] elif m: current_title m.group(2) else: current_buf.append(line) if current_buf: sections.append((current_title, .join(current_buf).strip())) chunks [] for title, body in sections: if len(body) max_chars: chunks.append(f## {title}\n{body}) else: # 按句子切分 sentences re.split(r(?[。]), body) buf for s in sentences: if len(buf) len(s) max_chars: if buf: chunks.append(f## {title}\n{buf}) buf s if not buf else buf[-overlap:] s else: buf s if buf: chunks.append(f## {title}\n{buf}) return chunks切出来的 chunk 会作为向量表的文本来源。不要只存 embedding 而丢掉原文否则检索到向量却拿不到内容还得回头查文档多一层开销。3.4 建表、入库与 SQLite 参数调优先设计表结构。我把源文档和文本块分开存向量表只关注 chunk 与 embedding 的映射。CREATE TABLE IF NOT EXISTS documents ( doc_id INTEGER PRIMARY KEY AUTOINCREMENT, doc_name TEXT NOT NULL, doc_path TEXT, created_at TEXT DEFAULT (datetime(now)) ); CREATE TABLE IF NOT EXISTS chunks ( chunk_id INTEGER PRIMARY KEY AUTOINCREMENT, doc_id INTEGER NOT NULL, title TEXT, content TEXT NOT NULL, FOREIGN KEY (doc_id) REFERENCES documents(doc_id) ); -- 向量表chunk_id 与 chunks 表对应 CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0( chunk_id INTEGER PRIMARY KEY, embedding FLOAT[512] );入库时的流程是先插 documents再插 chunks拿到 chunk_id 后用 embedding 结果插 vec_chunks。注意这里要保证普通事务和虚拟表写入的一致性我建议用同一个事务包住 chunks 插入和 vec_chunks 插入避免向量写入成功但原文写入失败造成脏数据。事务写法conn.execute(BEGIN) try: # 插入 doc cur conn.execute( INSERT INTO documents(doc_name, doc_path) VALUES (?, ?), (doc_name, file_path) ) doc_id cur.lastrowid for chunk in chunks: cur conn.execute( INSERT INTO chunks(doc_id, title, content) VALUES (?, ?, ?), (doc_id, chunk[title], chunk[content]) ) chunk_id cur.lastrowid vec embed_text(chunk[content]) conn.execute( INSERT INTO vec_chunks(chunk_id, embedding) VALUES (?, ?), (chunk_id, json.dumps(vec)) ) conn.execute(COMMIT) except Exception: conn.execute(ROLLBACK) raise许多人在这一步会忽略 SQLite 自身的写入性能。默认配置下每 commit 一次都会刷盘写几千条记录会明显变慢。我在批量导入阶段临时开启以下 PRAGMA 提升效率导入完成后再恢复正常PRAGMA journal_mode WAL; PRAGMA synchronous OFF; PRAGMA cache_size -64000; -- 约 64MB 页面缓存WAL模式允许读写并发synchronous OFF减少 fsync 次数速度提升明显。注意这只是导入期的临时配置正式对外服务时建议把synchronous调回NORMAL避免极端断电场景下丢数据。3.5 查询链路向量召回 FTS5 关键词兜底查询用户的自然语言问题先调用同一个 embedding 模型得到 query 向量然后去vec_chunks表做近邻搜索query_vec embed_text(user_question) rows conn.execute( SELECT chunk_id, distance FROM vec_chunks WHERE embedding MATCH ? ORDER BY distance LIMIT ?; , (json.dumps(query_vec), top_k)).fetchall()拿到 chunk_id 列表后回表查询原文ids [r[0] for r in rows] placeholders ,.join(? * len(ids)) chunks conn.execute(f SELECT c.doc_id, c.title, c.content, vec.distance FROM chunks c JOIN ( SELECT chunk_id, distance FROM vec_chunks WHERE chunk_id IN ({placeholders}) ) vec ON c.chunk_id vec.chunk_id , ids).fetchall()这个链路能满足大部分语义检索需求。但我也遇到了一个典型的召回问题专业名称、型号、编号这类实体语义向量往往不够敏感。用户精确输入“TS-2024-003”时向量搜索有可能没把它放在最前面。这时候我会额外建立一个 FTS5 全文索引跑一个关键词召回分支再把两路结果合并CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5( content, contentchunks, content_rowidchunk_id ); -- 同步写入 INSERT INTO chunks_fts(rowid, content) SELECT chunk_id, content FROM chunks; -- 关键词查询 SELECT rowid, bm25(chunks_fts) AS score FROM chunks_fts WHERE content MATCH ? ORDER BY score LIMIT 10;最终查询策略就变成向量召回 8 条关键词召回 5 条按权重合并去重再拼接成上下文。这一条混合召回链路在小规模知识库里性价比很高既保语义又拼精确。严格意义上这是把向量检索和全文检索做了一次简单融合很多号称 RAG 的产品也就是这个套路。3.6 把检索结果变成问答答案检索只是前半段完整知识库还得把召回内容喂给大模型生成答案。对私域部署场景我常在本地跑量化过的 Qwen 或 GLM 小模型把拼好的 prompt 发过去。构造上下文的模板你是企业知识库助手。请根据以下资料回答问题。 如果资料中没有答案请明确说明“资料中未找到相关内容”不要编造。 资料 【文档1】标题……内容…… 【文档2】标题……内容…… 问题…… 回答这里要控制上下文长度。召回 5 个 chunk每个 300 字左右也就 2000 字左右大部分本地模型的 context window 都放得下。如果召回结果太长就按照 distance 优先级截断优先保留与问题最相关的段落。3.7 实测效果一个 100 篇文档知识库的真实数据说一个我实际跑过的案例。文档集是 100 篇公司内部合同和制度文件总字符数约 187 万字切成 4200 个 chunk用 BGE-small-zh-v1.5 生成 512 维向量。全表数据量算下来向量表占用空间4200 × 512 × 4 字节 约 8.6MB全文文本 索引约 5MB整个.db文件约 15MB查询延迟在普通云服务器 2 核 CPU 上跑向量召回 关键词召回 回表取数一次检索耗时约 12ms 到 25ms。这个速度让后续套接任何前端交互都毫无压力。实际体感是单机、单文件、百篇文档这个量级完全没必要引入额外的计算资源。4. 常见问题与避坑实录4.1 向量维度不匹配导致建表或查询报错这是最多人踩的坑。不同 embedding 模型输出的维度不一样BGE-small-zh-v1.5 是 512 维text2vec 是 768 维OpenAI 的 text-embedding-3-small 是 1536 维。建表时写的FLOAT[512]必须和实际向量维度严格一致插入时任何一个向量长度不匹配都会直接抛错。建议在建表前打印一下 embedding 的维度确认之后再去建虚拟表。另外一定不要中途换模型否则同一个库里会出现两套维度或语义空间不一致的向量查询结果直接乱套。4.2 为什么检索出来结果相关度不高向量检索“结果不对”通常是三类原因切块过大一块包含多个主题向量被平均哪个都不像。解决方式是缩小切块字数。缺少元信息chunk 里没带标题检索模型不知道这段属于哪个章节。我处理方式是切块时把标题拼在正文前面。混合检索缺失用户输入的是精确编码比如合同编号、产品型号向量检索对这类 token 天生不敏感。所以一定要加 FTS5 关键词召回然后把两路结果做 rank 融合。我管这个叫“语义兜底 字面兜底”两边互补。如果你做了以上调整还是不准可以调高top_k比如从 5 调到 20再用重排序模型或大模型自己挑但百篇文档场景通常到不了那么复杂。4.3 中文分词的坑FTS5 默认的 tokenizer 是 unicode61它对中文是按连续字符逐个处理效果相当于按 bigram 甚至单字索引召回会命中一堆无关内容。想让中文关键词检索更准可以换trigramtokenizerCREATE VIRTUAL TABLE chunks_fts USING fts5(content, tokenizetrigram);trigram 会把中文切成连续三个字的滑动窗口对短关键词召回的准确率有明显提升。注意它的索引体积会更大但对我们这个量级来说无所谓。4.4 事务、备份和迁移vec0虚拟表里存的是向量但备份方式与普通表完全一致。SQLite 支持在线备份直接拷贝.db文件也可以只不过如果正在写入稳妥做法是先用VACUUM INTO backup.db或 SQLite 的backupAPI 做一致性快照。如果想把整个知识库迁移到另一台机器只要把.db文件和vec0扩展文件一起拷走目标机器 SQLite 版本符合要求即可。不需要导出再导入数据这是单文件方案肉眼可见的好处。我的实际操作是每天晚上把.db文件VACUUM INTO一份到备份盘整个过程秒级完成。4.5 跨语言调用C#、Go、Java 也都能用这套方案不受语言限制。sqlite-vec 面向的是“SQLite 扩展”任何能加载扩展的 SQLite 驱动都能用。比如 C# 里用Microsoft.Data.Sqlitevar conn new SqliteConnection(Data Sourcekb.db); conn.Open(); conn.EnableExtensions(true); conn.LoadExtension(vec0);之后用标准 SQL 查询即可。Go 用mattn/go-sqlite3Java 用sqlite-jdbc也都能加载扩展。这意味着知识库后端不一定绑死在 Python 上你完全可以用 C# 写一个 Windows 服务做本地文档问答这个灵活度是很多私有化向量库给不了的。写在最后一些个人体会这套组合我从头到尾跑通之后最大的感受是不要为了炫技引入你根本不需要的基础设施。100 篇文档的知识库SQLite sqlite-vec 无论是成本、可靠性、可迁移性都比我以前用外部向量数据库的方案舒服太多。整条链路从文档切块、embedding、入库到查询逻辑非常清晰排查问题时打开.db文件就能看到全部状态这在复杂系统里是不可想象的。真要扩展后续可以考虑定时增量导入新文档更新时用doc_id先删除旧 chunk 再写入新 chunk也可以在表里给每个向量加个标签字段按部门或文档类型过滤检索范围。如果在实际搭建中还有什么问题欢迎留言一起讨论。
返回列表