
本来想拿个文档量还凑合的知识库场景练手结果发现网上聊私域知识库的文章十有八九都在教你搭 Milvus、Weaviate再不济也得上个 Elasticsearch。可问题是我手头就是 100 篇技术文档、产品说明和会议纪要撑死几十万字让我为一个个人知识库去维护一套分布式向量数据库实在有点杀鸡用牛刀。后来我换了个思路把 SQLite 和 sqlite-vec 组合在一起做成纯本地、单文件、零服务依赖的私域知识库。跑通之后效果意外地好检索一条 query 平均也就几十毫秒整个库文件不到 200MB扔在移动硬盘里随插随用。这篇文章就完整复盘一下我的做法包括为什么这么选型、表结构怎么设计、切块和向量化怎么处理、混合检索怎么做以及我实际踩过的几个坑。1. 为什么是 SQLite100 篇文档规模下的正确技术判断1.1 需求边界这其实是一道减法题做知识库第一件事不是选数据库而是算清楚你到底有多少数据。我这边的情况是 100 篇文档大多数是 PDF 和 Markdown去重后大概 250 万字。如果按每 200 到 500 字一个切块来算嵌入库里的向量大约在两万条以下。这个量级意味着什么问题意味着单机内存就能完全放下全部向量数据。两万条 768 维的 float 向量换算下来大概是 2 万乘以 768 乘以 4 字节约 60MB。这个体积SQLite 一个文件就装下了连内存索引的开销都省得计较。很多人听到向量检索第一反应就是专用向量数据库但专用数据库的价值主要体现在分布式扩展、海量规模、多租户隔离这些能力上。这些能力在这个规模下一个都用不上反而要付出部署运维的代价。SQLite 根本就不需要启动任何服务程序里直接连接文件读写一条龙。1.2 混用重型方案的隐性成本我不反对用重型方案但要知道它的隐性成本。我曾经试过用 Docker 起一个向量数据库容器先不说镜像拉取和配置参数的折腾光是数据导入就要自己写一套 client SDK 的代码。数据库在容器里跑数据落盘在挂载卷里备份要单独处理迁移要导来导去。这些工作量对于一台长期运行的生产服务器来说当然不算什么但对于一个个人知识库项目尤其是偶尔才打开查一下的场景完全是负担。即便你用云服务费用和网络依赖也摆在那里。SQLite 的思路完全不同数据库就是一个文件备份就是复制文件迁移就是拷走文件版本管理甚至可以直接丢进 Git。这种简单性在 100 篇文档这种规模下是压倒性的优势。1.3 sqlite-vec 是什么、能做什么sqlite-vec 是 SQLite 的一个扩展它给 SQLite 增加了向量类型和向量相似度检索能力。你可以把它理解成一个插件加载之后 SQLite 就认识向量了可以建虚拟表、执行近邻查询。它支持 float 和 int8 两种向量类型距离计算实现了 L2 距离、余弦距离、内积等常用的几种。sqlite-vec 的思路和 SQLite 本身一脉相承无服务、单文件、嵌入式。它不需要额外进程也不需要配置独立存储数据还是放在 SQLite 文件里只是表和查询的语法多了一套向量操作。和 SQLite 自带的 FTS5 全文搜索模块配合使用效果非常好。FTS5 负责精确关键词匹配sqlite-vec 负责语义相似度召回两者互不冲突还能在同一个 SQLite 文件里解决这对我来说是最舒服的架构。提示sqlite-vec 目前是社区开源项目迭代还是比较快的。用之前建议留意一下版本更新尤其是跨大版本升级时的兼容性变化。2. 环境搭建与 sqlite-vec 插件的装载细节2.1 获取 sqlite-vec 扩展编译与预编译两条路线安装 sqlite-vec 的路径有两条。第一条是去项目的 GitHub Release 页面下载对应操作系统的预编译文件Windows 下是 vec0.dllLinux 和 macOS 下是 .so 或 .dylib。下载后放到一个固定目录使用时手动加载。第二条路线是从源码编译。如果预编译文件在你的平台上不可用或者你想开新的构建选项就得走这条路。编译需要 CMake 和一个 C 编译器步骤常规配置、构建、安装。我建议优先用预编译包省时省力。我当时在 macOS 上直接用预编译文件加载很顺利。如果你用宝塔面板这类 Linux 服务器面板管理环境只要面板能装 SQLite 扩展动态库路径配置好也能正常用不需要在服务器上跑编译工具链。2.2 在 Python 中加载扩展的几种姿势我实际使用是通过 Python 连接 SQLite。Python 标准库的 sqlite3 模块如果支持扩展加载需要先开启 enable_load_extension。这一步容易漏不开启的话 load_extension 会报错。import sqlite3 conn sqlite3.connect(knowledge.db) conn.enable_load_extension(True) conn.load_extension(vec0) # macOS 下为 .dylib会自动适配Windows 下改为 vec0.dll print(conn.execute(SELECT vec_version()).fetchone())需要注意的一点是不同环境下扩展文件名后缀不一样加载时最好把完整路径也写上避免系统找不到动态库。我遇到过一种情况扩展文件在但路径不在动态库搜索范围内怎么加载都报错后来改成绝对路径就好了这个问题很隐蔽。如果你想在命令行里直接体验SQLite 客户端也可以加载扩展机制.load这也意味着你可以用 Db Browser for SQLite 这类可视化工具来查看和调试向量表。这类工具一般支持图形界面加载扩展用来快速验证数据写入结果很方便。2.3 建表设计向量表、切片表、文档表三层模型知识库的表结构我设计了三个层级文档表保存原始文件的元信息切片表保存切块后的文本内容向量表保存切块对应的 embedding。为什么把向量单独放一张虚拟表因为 sqlite-vec 的虚拟表和普通表在操作方式上有差别分开之后管理更清晰。先看文档表CREATE TABLE IF NOT EXISTS documents ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, source_file TEXT, created_at TEXT DEFAULT (datetime(now)) );切片表CREATE TABLE IF NOT EXISTS chunks ( id INTEGER PRIMARY KEY AUTOINCREMENT, doc_id INTEGER NOT NULL, chunk_index INTEGER NOT NULL, content TEXT NOT NULL, FOREIGN KEY (doc_id) REFERENCES documents(id) );向量表CREATE VIRTUAL TABLE vec_chunks USING vec0( embedding float[768] );注意这里有个设计差异向量表本身不存文本内容只存向量。你还需要一个映射关系把向量行和切片行关联起来。我通常在向量表里额外存一个 chunk_id 字段不放到第 768 维之外而是作为扩展元数据。刚才的建表语句可以这样改CREATE VIRTUAL TABLE vec_chunks USING vec0( chunk_id INTEGER PRIMARY KEY, embedding float[768] );这样的好处是查询向量距离时能直接拿到 chunk_id然后 JOIN 回 chunks 表获得文本内容。这里还有个容易忽略的细节向量维度必须和模型输出的维度完全一致。如果你用 384 维的模型建表写成 float[768]插入向量时会直接报错。这种错误通常在写入阶段就暴露不会留到查询阶段。3. 文档切块与 Embedding决定检索质量的两件事3.1 切块策略为什么固定长度不是好方案很多人做知识库第一步就是按固定字符数暴力切块300 字一刀切。但实测下来这种做法的检索质量很一般原因是会拦腰砍断语义完整的段落。例如一段讲了两个技术点的长文被切成两半后每一半都语义残缺检索时容易命中一半却丢了另一半。我试过几种切块方式效果从差到好排列固定字符切块、按段落切块、结合标题层级切块、按语义边界切块。按段落切块是比较推荐的方案我的做法是先按 Markdown 和文档结构切到二级或三级标题然后每个标题下的内容再按段落拆。每个切片尽量保持 200 到 500 字之间。段落太长就二次切分太短就把连续两个自然段合并。为什么要有长度上下限太短语义信息不足向量表示的区分度不够太长则一个切片包含多个主题query 匹配时向量会被平均掉。这个结论是我反复对比了不同长度下的检索命中效果后得到的确实存在一个甜区200 到 500 字是比较稳的区间。切块的代码逻辑可以用正则加判断来做不必引入重量级框架import re def split_text(text: str, max_len: int 500, min_len: int 200): paragraphs re.split(r\n\s*\n, text) chunks [] buf for para in paragraphs: if len(buf) len(para) max_len: buf \n para else: if buf: chunks.append(buf.strip()) buf para if buf: chunks.append(buf.strip()) # 二次切分超过 max_len 的长块 result [] for c in chunks: while len(c) max_len: result.append(c[:max_len]) c c[max_len:] result.append(c) return [c for c in result if len(c) min_len]上面这个 logic 实际上是尽量按段落聚合不让单个块过长如果你的文档段落结构特别乱可以再用一些启发式规则例如一个块内不跨两个一级标题。3.2 模型选型本地模型与 API 模型的取舍向量化的核心问题是选 embedding 模型。这一层的选择会直接决定检索质量的上限后面检索算法再优化都弥补不了模型的语义理解短板。API 模型的优势是使用简单、效果通常不错但缺点也很明显文档要传到外部服务、有调用费用和频率限制、内部文档隐私存在外泄风险。私域知识库这个场景我原则上优先考虑本地模型数据不出本机。我用的模型是国内开发者的开源中文模型支持中英文混合场景输出 768 维向量在中文语义匹配上的表现在同类小模型里属于第一梯队。当然你也可以用其他开源模型关键是注意两点一是维度要和建表时一致二是模型本身支持中文否则中文 query 的召回效果会大打折扣。加载模型时我用 Hugging Face 的 Transformers 库或者直接用更轻量的模型调用方式。如果你在境内网络环境下下载模型比较慢可以用模型平台提供的加速下载方式但切记只是下载模型不要涉及任何其他操作。模型文件一旦就位推理完全在本机运行。还需要注意一点向量化之后要对向量做归一化吗如果距离度量用余弦距离理论上归一化之后用内积等价。sqlite-vec 的 cosine 距离在内部也做了类似处理但我为了通用性保存前手动做了一次 L2 归一化这样不管查询端用什么距离函数都不会因为向量尺度差异导致排序问题。3.3 向量化入库的完整代码向量化入库的完整流程可以这样组织from transformers import AutoTokenizer, AutoModel import torch model_name BAAI/bge-small-zh-v1.5 tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModel.from_pretrained(model_name) model.eval() def embed_text(text: str): inputs tokenizer(text, return_tensorspt, truncationTrue, max_length512) with torch.no_grad(): output model(**inputs) # bge 系列推荐使用 [CLS] 向量或 pooler_output vec output.last_hidden_state[:, 0, :].squeeze().numpy().astype(float32) # L2 归一化 norm (vec ** 2).sum() ** 0.5 return vec / norm然后批次写入import sqlite3 conn sqlite3.connect(knowledge.db) conn.enable_load_extension(True) conn.load_extension(vec0) for doc in docs: cur conn.execute(INSERT INTO documents(title, source_file) VALUES (?, ?), (doc[title], doc[file])) doc_id cur.lastrowid chunks split_text(doc[content]) for idx, chunk in enumerate(chunks): ccur conn.execute(INSERT INTO chunks(doc_id, chunk_index, content) VALUES (?, ?, ?), (doc_id, idx, chunk)) chunk_id ccur.lastrowid vec embed_text(chunk) conn.execute(INSERT INTO vec_chunks(chunk_id, embedding) VALUES (?, ?), (chunk_id, vec.tobytes())) conn.commit()写入向量时用 vec.tobytes() 转成 bytes这是 sqlite-vec 的 API 要求。如果你直接把 numpy 数组传进去大概率会报类型错误。提示向量写入前记得做归一化并且保持全流程同一套模型和归一化逻辑。入库和查询分两次脚本执行时归一化逻辑不一致会导致检索质量显著下降。还有一个实操细节数据库连接可以设置 WAL 模式这样大批量写入时读取不阻塞性能也好一些。conn.execute(PRAGMA journal_modeWAL) conn.execute(PRAGMA synchronousNORMAL)实测边写入边查询的情况下WAL 模式确实明显更顺滑。4. 混合检索关键词与向量的融合以及分数归一化4.1 两路召回的原理如果只靠向量检索会出现一种情况query 里有非常明确的人名、型号、产品代号比如HTTPS 证书过期时间向量检索可能把到底应该选择 HTTP 还是 HTTPS这种语义相近但完全不相关的段落排到前面。原因在于向量空间里语义相似和字面匹配是两回事短代码、编号、特殊名词的区分度在向量空间里并不好。这就是为什么需要关键词检索。SQLite 自带的 FTS5 全文搜索在字面匹配上非常成熟支持 BM25 排序对中文可以用分词器或 trigram 分词。我把原始切片也写入 FTS5 表和向量表并行存在。两路召回的结果再融合我用的方法是 RRFReciprocal Rank Fusion加权排序。RRF 的思路很简单每个文档在召回列表里的位置取倒数分数相加排在越前面贡献越大数据集的绝对分数差异不会对融合结果产生过大的干扰。4.2 RRF 分数融合公式与实现RRF 的公式是每个召回集合中每个文档累积 1/(k rank)k 一般取 60。这个公式的好处是不需要归一化不同检索器的原始分数天然适用于关键词和向量这种量纲不一致的融合。具体实现如下def rrf_fusion(ranked_lists, k60): scores {} for ranked in ranked_lists: for rank, doc_id in enumerate(ranked, start1): scores[doc_id] scores.get(doc_id, 0) 1.0 / (k rank) return sorted(scores.items(), keylambda x: x[1], reverseTrue)向量召回我取前 20 条FTS5 召回也取前 20 条融合后取 top 10。这两个集合的尺寸不用太大不然融合时间会明显变长收益却不高。实际查询时向量检索和 FTS5 检索并行执行然后做 RRF 融合。整个过程在 Python 里实现非常简单单条 query 总耗时不超过 100ms对个人知识库来说完全够用。4.3 过滤与分页让检索结果可用检索结果还有一个常见问题同一个文档的多个切片都排在前列结果页上连续好几条都来自同一篇文章体验不好。我加了一个按 doc_id 分组的逻辑每个文档最多保留 2 条切片结果然后把剩下的名额让给其他文档。实现方式是在 RRF 融合后再扫一遍结果列表用计数器控制每个 doc_id 的上限def dedup_by_doc(ranked_results, chunks_meta, max_per_doc2): counts {} out [] for chunk_id, score in ranked_results: doc_id chunks_meta[chunk_id][doc_id] counts[doc_id] counts.get(doc_id, 0) 1 if counts[doc_id] max_per_doc: out.append((chunk_id, score, doc_id)) return out这样做的好处是搜索结果覆盖面更广一篇文档只能占据两三条用户能快速扫到更多相关来源。另外切片内容展示时最好带上前后文信息。我倾向于把命中的切片前后各扩展 100 到 200 字做成上下文摘要形式方便用户判断这条结果是否真的有用。上下文扩展用 SQLite 的 substr 函数或 Python 字符串切片都能实现关键是把原始切片在文档中的起止位置记录下来。5. 实测效果与我在反复踩坑中总结的注意点5.1 一个真实文档集上的测试数据我用自己的 100 篇技术文档做了评测文档类型包括 API 文档、故障排查手册、会议纪要和产品需求文档。测试 query 大概准备了 30 个分为字面类如数据库连接池 max 配置和意图类如服务启动失败应该查什么混合检索的命中率显著高于单独使用向量检索。具体数字上字面类 query 的 top5 命中率 FTS5 单路就接近八成意图类 query 则是向量检索明显占优。混合检索在两组 query 上的表现都优于单路RRF 融合后的 top1 命中率比单路向量检索提升了大概 15% 左右。性能表现方面2 万条向量、768 维的库单次向量近邻查询在本地 SSD 上耗时约 20 到 40msFTS5 查询基本是毫秒级。知识库文件总大小在 180MB 左右其中向量数据占大头。这个体量对 SQLite 来说毫无压力。5.2 资源占用与性能参考内存占用上SQLite 本身非常克制查询时主要开销来自加载向量计算。sqlite-vec 做近邻搜索时如果没有合适的索引可能要做全表扫描但 2 万条向量的规模下全表扫描也就几十毫秒的事完全可接受。如果你的文档量涨到 10000 篇以上全表扫描可能开始吃力。sqlite-vec 提供了分区表能力来按某一列过滤后再搜索例如按文档分类字段过滤候选集能明显加速。另一个方案是拆库按业务域拆成多个 SQLite 文件查询时并行走多库最后再融合效果也很直接。这里要给个建议如果向量规模到了几十万条SQLite 方案可能就不是最优解了届时再考虑专用向量数据库也不晚。100 篇文档这种规模SQLite 就是最省心的选择。5.3 我在实操中遇到过的坑和对应解法第一个坑是扩展版本不匹配。sqlite-vec 某个版本引入的向量表格式变化导致我用旧版写入的向量文件在新版扩展下查询报错后来我只能重新导入数据。所以如果你的知识库里已经积累了不少向量数据升级扩展前一定先备份文件并且在测试环境验证兼容性尽量不要直接在生产库上升级。第二个坑是 Python 的 sqlite3 模块默认不支持 load_extension。有些 Python 发行版编译时没开这个特性enable_load_extension 调用后依然报错。这时候有两个选择换用 sqlite3 的更新版本或者用 APSW 这类第三方库连接 SQLite。我记得 APSW 对扩展加载的支持更完备SQL 语法能力也更接近底层 sqlite3遇到这个问题时是一个靠谱的备选。第三个坑是 embedding 模型的上下文长度上限。我的模型最大支持 512 token长段落直接 truncation 会导致向量信息丢失。解决方法是切块时控制长度让单块文本 token 数大致在 200 到 400 之间就不会触发截断。中文一个 token 大概对应一到两个汉字200 到 500 字的切块基本安全。第四个坑是 C 接口的 float 数据字节序问题。sqlite-vec 在写入向量时要求的数据格式是裸 float 字节跨平台时字节序有差异。好在常见平台上都是小端我本机测试没有遇到问题但如果你要把数据库文件从服务器拷到 ARM 板子用建议先写一个读写验证的脚本确认字节序一致。5.4 后续可以在此基础上扩展的方向这套方案跑通之后可以继续扩展的空间很大。我最想做的两个方向是一个是对 query 做同义词扩充比如把故障扩展成异常报错crash再分别走向量检索能提高召回另一个是接入 rerank 模型对 RRF 融合后的候选列表做精排进一步提升答案质量。UI 层也可以自己做比如用浏览器本地页面加一个 Python 脚本作为后端通过端口访问检索接口。如果你想更轻可以直接把 SQLite 文件挂到只读网关下检索接口用少量代码封装即可。整个过程仍然是无服务架构所有数据都在一个文件里适合个人知识库这种长期积累、随时查询的场景。依赖单一文件这个特点还带来一个好处你可以把知识库文件放在网盘同步目录里在副电脑上下载一份副本直接查询完全不受部署的约束。真要说缺点的话那就是多端并发写入不太行个人知识库场景入库频率极低根本撞不上这个问题。100 篇文档的私域知识库SQLite 加 sqlite-vec 的组合给了我一个够简单、够稳、够快的基础设施。如果你的数据规模也在这个量级真心建议试试这个方案没必要为一辆玩具车配一台重型卡车级别的发动机。