ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

本地知识库搭建实战:Ollama+SQLite实现语义检索

2026/10/4 23:05:58 拓冰建站 浏览量
本地知识库搭建实战:Ollama+SQLite实现语义检索 1. 为什么我要折腾本地知识库这件事先说结论我搭这套东西的起因特别朴素——笔记太多找不到了。三年下来 Obsidian 库里攒了四千多篇笔记标签体系换了三套文件夹结构重构过五次结果每次想找当时记的那个 SQLite 修改字段类型的坑还是得靠全文搜索硬翻。全文搜索这东西关键词对不上就是找不到我明明记得写过ALTER TABLE 不支持直接改类型但搜改类型就是出不来因为当时写的是字段类型变更。这就是关键词检索的天花板它只认字面不认语义。而 embedding 干的事就是把每段文字变成一串高维向量让改类型和字段类型变更在向量空间里靠得很近。再配合本地跑的小模型做问答就能实现我用大白话问它按语义找的效果。整套方案我最后落地的组合是Ollama 跑本地 embedding 模型 SQLite 存向量 Python 做同步脚本 Obsidian 作为笔记源和查询入口。选这四个不是拍脑袋后面会一个个讲清楚为什么。这套东西适合谁适合笔记量在千篇以上、对数据隐私有要求、愿意花一个周末折腾一次、之后每天自动跑的人。如果你笔记不到两百篇说实话 Obsidian 自带的搜索够用了别折腾。我踩的坑主要集中在三个地方Ollama 模型下载慢到怀疑人生、SQLite 存向量后查询性能断崖式下跌、以及每日自动同步时增量更新的判定逻辑写错了导致重复索引。这三个坑后面都会给出具体的排查过程和最终解法。2. 整体架构设计与技术选型逻辑2.1 为什么是本地 embedding 而不是调云端 API最直接的原因是隐私。我的笔记里有大量工作记录、项目复盘、甚至一些个人财务规划的草稿这些东西我不放心传到任何第三方服务器上哪怕对方承诺不训练、不存储。本地 embedding 意味着从文本到向量的整个过程都发生在自己机器上数据不出门。第二个原因是成本。云端 embedding API 按 token 计费我四千多篇笔记粗算下来大概三百万 token 左右首次全量索引就要花掉一笔钱之后每天增量虽然少但日积月累也是持续支出。本地跑一次电费几分钱边际成本几乎为零。第三个原因是可控性。本地模型我想换就换想调参数就调参数不用担心哪天 API 涨价或者下线。这一点在我后来从nomic-embed-text换到bge-m3的时候体现得特别明显——改一行配置的事十分钟搞定。当然本地方案也有代价首次要下载模型这个坑后面细说、需要一定的内存和算力、embedding 质量比顶级云端模型略逊一筹。但对我这种个人知识库场景质量差距在实际使用中几乎感知不到。2.2 Ollama 在整套方案里的角色定位Ollama 本质上是一个本地模型运行时它把模型的下载、加载、推理封装成了几条命令。我选它而不是直接上sentence-transformers或者llama.cpp核心原因是运维成本低。ollama pull一条命令搞定模型下载ollama serve起服务然后通过 HTTP 接口调用Python 侧只需要一个requests就能对接不用管 CUDA 版本、不用管 PyTorch 和显卡驱动的兼容性。这里有个关键决策点embedding 模型和生成模型要不要用同一个我的答案是分开。embedding 用专门的 embedding 模型比如bge-m3或nomic-embed-text生成问答用另一个对话模型比如qwen2.5:7b。原因很简单embedding 模型追求的是把语义压缩到向量里的保真度对话模型追求的是生成流畅度两者优化目标不同混用会两头不讨好。而且 embedding 模型通常很小几百 MB常驻内存没压力对话模型按需加载就行。2.3 SQLite 存向量的可行性边界很多人一听用 SQLite 存向量第一反应是这能行吗。我的实测结论是十万条以内的向量SQLite 完全够用但必须用对方法。SQLite 本身没有原生向量类型我的做法是把 embedding 结果序列化成 BLOB 存进去查询时全量加载到内存做余弦相似度计算。这里的关键数字是一条 768 维的 float32 向量占 768 × 4 3072 字节十万条就是约 300 MB。这个体量全量加载到内存做暴力检索在现代机器上单次查询大概 200 到 500 毫秒完全可接受。超过十万条怎么办两个方向一是上sqlite-vec这类扩展做近似最近邻搜索二是换专业的向量库。但我的笔记量短期内到不了十万条一篇笔记切三到五个 chunk四千篇也就一万多条所以 SQLite 方案在可见的未来都够用。选它的最大好处是零额外依赖——不用起 Docker、不用装服务、一个.db文件拷走就是完整备份。2.4 数据流全景整套系统的数据流是这样的Obsidian 库里的 Markdown 文件是唯一数据源Python 脚本每天定时扫描库目录对比 SQLite 里记录的文件修改时间和内容哈希找出新增和变更的文件切分成 chunk调用 Ollama 的 embedding 接口拿到向量写入 SQLite。查询时用户的问题同样走 embedding然后在 SQLite 里做相似度检索取回最相关的几个 chunk 拼成上下文再交给对话模型生成回答。这个设计里有个容易被忽略的点Obsidian 库是只读的。脚本只读不写绝不修改你的笔记文件。这一点很重要因为一旦脚本有写权限某次 bug 就可能把你的笔记改乱。我见过有人写的同步脚本把 frontmatter 覆盖掉的惨案所以从一开始就把权限卡死。3. 环境搭建与核心组件安装实操3.1 Ollama 安装与模型拉取的正确姿势Ollama 的安装本身没什么难度官网下载对应平台的安装包一路下一步就行。真正的坑在模型拉取这一步。我第一次拉bge-m3的时候进度条卡在 3% 整整二十分钟最后超时失败。原因大家都懂默认源在国内访问不稳定。我的解法是配置镜像源。Ollama 支持通过环境变量指定模型仓库地址在启动服务前设置好就行。具体操作是在系统环境变量里加一条指向国内镜像的配置然后重启 Ollama 服务。配置完之后再拉模型速度从几十 KB/s 直接飙到几 MB/sbge-m3大概两分钟就下完了。还有一个坑是模型存储路径。Ollama 默认把模型存在系统盘的用户目录下bge-m3加对话模型加起来好几个 G系统盘紧张的话很快就红了。解决办法同样是环境变量指定一个数据盘上的目录作为模型存储路径。这个变量必须在第一次拉模型之前就设好否则模型已经下到默认位置了改路径还得手动迁移。提示环境变量改完一定要完全退出 Ollama 再重启托盘图标右键退出不算得去任务管理器确认进程真的没了。我有一次改了变量没生效排查了半小时才发现是旧进程还在跑。3.2 Python 环境与依赖管理Python 这边我用的是 3.11依赖就四个requests调 Ollama 接口、watchdog做文件监听可选、markdown-it-py解析 Markdown、numpy做向量计算。装依赖我强烈建议用虚拟环境别往全局环境里怼。python -m venv venv建环境激活之后pip install装包干净利落。这里有个新手常踩的坑numpy在某些 Python 版本上装不上报编译错误。原因是那个版本没有预编译的 wheel 包pip 就去源码编译然后缺编译器就挂了。解法很简单要么升级 Python 到有 wheel 的版本要么指定装老一点的 numpy 版本。我现在的做法是直接锁版本requirements.txt里写死numpy1.26.4避免每次装出不同结果。3.3 Obsidian 库的目录约定Obsidian 库就是一个普通文件夹里面全是.md文件。但有几个目录我建议排除在索引之外.obsidian配置目录、.trash回收站、以及任何附件目录图片、PDF 这些。原因一是这些文件不是文本二是配置目录里的 JSON 文件被索引进去纯属噪音。我的做法是在脚本里维护一个排除列表扫描时跳过这些目录。另外 Obsidian 有个特性要注意它的文件修改时间在同步或者某些插件操作后可能会变但内容其实没变。所以我不能只靠 mtime 判断变更必须加上内容哈希做二次校验。这个细节后面在增量同步那节会详细讲。3.4 SQLite 管理工具的选择调试阶段强烈建议装一个 SQLite 图形化管理工具。我用的是 DB Browser for SQLite开源跨平台能直接打开.db文件看表结构、跑 SQL、导出数据。没有这个工具你调试的时候只能靠命令行sqlite3敲 SQL效率低很多。建库的时候有个细节开启 WAL 模式。默认的 journal 模式在写入时会锁库如果你的查询和写入并发比如同步脚本在跑的时候你正好在查询就会报 database is locked。WAL 模式下读写可以并发体验好很多。开启方式就是建库后执行一句PRAGMA journal_modeWAL;一次设置永久生效。4. 核心实现细节与踩坑实录4.1 文本切分策略chunk 大小怎么定切分是整套系统里最影响效果的一环切得不好检索出来的上下文要么太碎没信息量要么太长噪音多。我的策略是按语义边界切控制 token 数。具体做法先按 Markdown 的标题层级切大块每个二级标题下的内容作为一个候选块如果这个块超过 500 个 token再按段落切段落还超就按句子切。最终每个 chunk 控制在 200 到 500 token 之间相邻 chunk 之间保留 50 token 的重叠避免正好在边界处的信息被切断。为什么是 200 到 500这是实测出来的。小于 200 token 的 chunk 检索出来经常只有半句话模型没法回答大于 500 token 的 chunk 里往往混了好几个主题相似度被稀释检索精度下降。500 这个上限也跟 embedding 模型的最大输入长度有关bge-m3支持 8192 token但实际用下来超过 512 之后语义压缩质量就开始下降。注意切分的时候一定要保留标题信息。我的做法是在每个 chunk 前面拼上它所属的标题路径比如项目复盘 数据库选型 SQLite 踩坑。这样即使 chunk 本身没提到SQLite检索数据库选型的时候也能命中。4.2 调用 Ollama embedding 接口Ollama 的 embedding 接口是/api/embeddingsPOST 请求body 里传模型名和文本。返回的是一个 float 数组。这里有个性能坑逐条调用极慢。我一开始写的是循环里一条一条调一万个 chunk 跑了一个多小时。后来改成批量一次传一批文本Ollama 新版支持/api/embed批量接口速度提升了将近十倍。另一个坑是超时设置。默认的 requests 超时是无限等待如果 Ollama 服务卡住脚本就永远挂在那。我设了 30 秒超时超时就重试重试三次还失败就跳过这条并记日志。这样即使个别文本有问题也不会阻塞整个同步流程。还有个细节embedding 结果要归一化。虽然余弦相似度对向量长度不敏感但归一化之后可以用点积代替余弦计算快一点点。而且归一化后存成 float32 的 BLOB读取时直接np.frombuffer就能还原很方便。4.3 SQLite 表结构设计表结构我改过三版最终定下来是这样CREATE TABLE chunks ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_path TEXT NOT NULL, file_hash TEXT NOT NULL, chunk_index INTEGER NOT NULL, heading_path TEXT, content TEXT NOT NULL, embedding BLOB, updated_at INTEGER ); CREATE INDEX idx_file_path ON chunks(file_path); CREATE INDEX idx_file_hash ON chunks(file_hash);关键字段说明file_hash是整个文件的哈希用来判断文件是否变更chunk_index是 chunk 在文件内的序号方便按顺序还原上下文embedding存的是归一化后的 float32 二进制updated_at是 Unix 时间戳。为什么不用单独的文件表加外键因为我的查询模式很简单就是按文件路径找所有 chunk和全量加载向量做检索两张表反而增加 join 开销。单表冗余存file_hash虽然有点浪费空间但换来的是查询逻辑极简。4.4 增量同步的判定逻辑这是踩坑最狠的地方。我第一版逻辑是mtime 变了就重新索引结果发现 Obsidian 每次打开库某些文件的 mtime 都会变导致每天全量重跑。第二版改成内容哈希变了才重新索引但哈希计算要读整个文件四千个文件每天读一遍也要几十秒。最终方案是两级判定先比 mtimemtime 没变直接跳过快mtime 变了再算哈希哈希没变就只更新 mtime 记录不重新 embedding省算力哈希也变了才真正重新索引。这样日常增量同步基本就是扫一遍目录比时间戳几秒钟搞定。删除文件的处理也要注意如果某个文件在库里被删了对应的 chunk 也要删掉。我的做法是同步开始时先查出数据库里所有file_path跟磁盘上实际存在的文件做差集差集里的路径对应的 chunk 全部删除。5. 常见问题排查与性能优化5.1 检索结果不相关的排查思路检索不准通常有三个原因按排查优先级排现象可能原因排查方法解决结果完全不相关embedding 模型没加载对检查 Ollama 返回的向量维度确认模型名和维度匹配结果沾边但不精准chunk 切分太碎或太大抽样看几个 chunk 的内容调整切分参数该命中的没命中标题信息没拼进 chunk检查 chunk 开头有没有标题路径补上标题前缀我遇到过一次特别诡异的情况检索数据库迁移死活出不来相关笔记后来发现那篇笔记的标题是从 MySQL 换到 SQLite 的完整记录正文里一次都没出现迁移两个字。这就是纯语义检索的价值——它靠的是向量距离不是字面匹配。但前提是 embedding 模型质量够好bge-m3在这类场景下表现明显优于早期的nomic-embed-text。5.2 查询性能优化实录十万条数据 SQLite 查询要多久我的实测数据全量加载一万条 768 维向量到内存耗时约 80 毫秒做一次余弦相似度计算numpy 矩阵运算耗时约 30 毫秒排序取 top 10耗时约 5 毫秒。总计单次查询 120 毫秒左右体感就是秒出。优化点有几个一是向量用 numpy 的frombuffer批量还原别在 Python 循环里一条条转那样慢十倍二是相似度计算用矩阵乘法np.dot(query_vec, matrix.T)一次算完所有别写 for 循环三是结果缓存相同问题短时间内重复问直接返回缓存我加了个简单的 LRU 缓存命中率还挺高。如果数据量真的涨到十万条以上第一个瓶颈是内存加载那 80 毫秒会变成 800 毫秒。这时候可以考虑分片加载或者上sqlite-vec扩展。但说实话个人知识库到这个量级更该考虑的是怎么精简笔记而不是优化检索。5.3 Ollama 服务稳定性问题Ollama 跑久了偶尔会内存泄漏表现为响应越来越慢最后卡死。我的解法是写个健康检查同步脚本开始前先 ping 一下/api/tags如果超过 5 秒没响应就重启 Ollama 服务。Windows 下用taskkill加start重启Linux 下用systemctl restart。另一个问题是模型加载慢。第一次调用某个模型时Ollama 要把它加载到内存可能要十几秒。我的做法是在同步脚本开头先发一个空请求预热一下 embedding 模型这样后续批量处理时就不用等加载了。对话模型同理查询前预热。5.4 每日自动同步的定时任务配置Windows 下用任务计划程序Linux 下用 cron。我两个平台都配过核心就一条定时执行 Python 脚本脚本自己处理日志和异常。Windows 任务计划的关键设置勾选不管用户是否登录都要运行、勾选如果任务失败按以下频率重新启动、设置停止现有实例避免上次没跑完又启动新的。Linux cron 就一行0 3 * * * /path/to/venv/bin/python /path/to/sync.py /path/to/sync.log 21每天凌晨三点跑。日志一定要留而且要按天切分。我出过一次同步静默失败因为异常被吞了日志里啥都没有排查了半天。后来改成所有异常都写日志并且脚本最后输出一行统计处理了几个文件、新增几个 chunk、耗时多少这样看日志一眼就知道有没有正常跑。6. 我实际用下来的几点体会这套东西跑了大半年最大的感受是检索体验的提升是质变的。以前找笔记靠记忆关键词现在直接问我上次记的那个 SQLite 改字段类型的坑是怎么解决的它能精准定位到那篇笔记的相关段落。这种用自然语言找自己写过的东西的体验用过就回不去了。第二个体会是别追求一步到位。我一开始想搞得很复杂又是向量库又是重排序模型结果卡在环境配置上两天没进展。后来砍到最小可用版本——Ollama 加 SQLite 加一个 Python 脚本半天就跑通了。先跑起来再慢慢优化这个顺序很重要。第三个体会是关于模型选择的embedding 模型的质量比对话模型重要得多。对话模型差一点顶多回答啰嗦或者不够流畅embedding 模型差一点检索出来的东西完全不相关整个系统就废了。所以预算和精力优先投在 embedding 模型上bge-m3目前是我用过性价比最高的选择。最后分享一个我最近加的小功能查询结果里带上笔记的文件路径和标题点一下就能在 Obsidian 里打开对应笔记。这个功能实现起来很简单就是在返回结果里多带两个字段但用起来特别顺手——检索到相关内容后直接跳过去看全文比在对话框里读片段舒服多了。