本地知识库自建指南:Ollama embedding与SQLite向量检索实践

发布时间:2026/10/5 5:26:55
本地知识库自建指南:Ollama embedding与SQLite向量检索实践 1. 为什么我要折腾一个本地知识库1.1 从信息焦虑到动手自建我的笔记散落在四五个地方Obsidian 里有一批技术笔记Zotero 里躺着几百篇论文的标注浏览器书签夹里塞满了稍后读微信收藏里还有一堆截图和链接。每次想找某个具体知识点都得挨个翻一遍翻不到就只能重新搜。这种状态持续了大半年我终于受不了了。市面上的云笔记和知识管理工具我基本都试过问题集中在两点一是数据不在自己手里二是搜索能力太弱——关键词匹配根本理解不了我想找的是关于向量检索性能优化的内容这种语义需求。所以我决定自己搭一套本地 embedding 向量检索 每日自动同步数据全部落在本地 SQLite 里用 Ollama 跑 embedding 模型用 Python 写同步脚本Obsidian 作为前端展示层。这套方案解决的核心问题是让我的所有笔记和资料能被语义搜索而不是只能靠关键词。比如我搜怎么让模型记住上下文它能找出我写的关于对话历史管理和记忆机制的笔记哪怕里面根本没出现上下文这三个字。适合谁参考有一定 Python 基础、愿意花一个周末折腾、对数据隐私和检索质量有要求的同学。如果你完全没写过代码这篇文章也能让你看懂整体思路但实操部分可能需要先补一下 Python 基础。1.2 整体架构长什么样先上一张我脑子里的架构图文字版数据源层Obsidian 仓库Markdown 文件、Zotero 导出的笔记、手动整理的文本处理层Python 脚本负责读取文件、切分文本、调用 Ollama 生成 embedding存储层SQLite 数据库存文本块、向量、元数据来源、时间、标签检索层Python 脚本接收查询生成查询向量在 SQLite 里做余弦相似度计算返回 Top-K 结果展示层Obsidian 插件或简单的本地 Web 页面展示搜索结果调度层系统定时任务Linux 用 cronWindows 用任务计划程序每天凌晨跑一次同步这个架构的关键取舍是不引入向量数据库。很多人第一反应是上 Chroma、Milvus、Qdrant 这些专业向量库但我实测下来个人知识库的数据量级几千到几万条文本块用 SQLite 存向量、Python 里做暴力检索完全够用。十万条数据以内一次全量检索在普通笔记本上也就几百毫秒。引入向量数据库反而增加了部署复杂度和维护成本对个人项目来说是过度设计。另一个取舍是embedding 模型的选择。我最终选了 Ollama 上的nomic-embed-text而不是 OpenAI 的 embedding API。原因很简单数据不出本地没有调用费用而且中文效果经过实测可以接受。后面会详细讲模型选型的对比过程。2. 环境准备与工具选型2.1 Ollama 安装与模型拉取Ollama 是我这套方案里最核心的依赖负责跑 embedding 模型。安装本身不复杂但国内网络环境下拉取模型是个大坑我踩了好几次。安装步骤Linux 为例# 官方安装脚本 curl -fsSL https://ollama.com/install.sh | sh # 验证安装 ollama --versionWindows 和 macOS 直接去官网下载安装包即可。安装完成后Ollama 默认会把模型存在系统盘的用户目录下。如果你的系统盘空间紧张embedding 模型虽然不大但多拉几个也占地方可以修改模型存储路径# Linux/macOS设置环境变量 export OLLAMA_MODELS/data/ollama/models # 写入 shell 配置文件使其永久生效 echo export OLLAMA_MODELS/data/ollama/models ~/.bashrc source ~/.bashrcWindows 下则在系统环境变量里新增OLLAMA_MODELS指向你想要的目录然后重启 Ollama 服务。拉取 embedding 模型ollama pull nomic-embed-text这里就是第一个大坑ollama 下载太慢了。我试过好几次进度条卡在某个百分比不动等半小时都没反应。后来总结出几个应对方法换时间段凌晨或清晨拉取速度明显好于晚上配置镜像源部分社区维护的镜像可以加速具体地址会变动建议自行搜索最新可用的手动下载模型文件如果实在拉不动可以找离线安装包把模型文件放到OLLAMA_MODELS目录下对应的文件夹里注意手动放置模型文件时目录结构要和 Ollama 预期的保持一致否则服务启动后识别不到。建议先ollama pull一个小模型比如all-minilm看看它落在哪个目录、目录结构是什么样再照着放。2.2 embedding 模型怎么选热词里embedding模型排行是个高频搜索我实际对比过几个能在 Ollama 上跑的模型模型维度中文效果速度体积我的评价nomic-embed-text768良好快~274MB综合最优首选all-minilm384一般极快~46MB英文场景够用中文偏弱mxbai-embed-large1024良好中等~670MB效果略好但资源占用高bge-m31024优秀较慢~1.2GB中文最强但吃资源我的选择逻辑是个人知识库以中文为主但不需要极致效果速度和资源占用更重要。nomic-embed-text在中文语义检索上表现稳定768 维的向量存 SQLite 也不占太多空间。如果你主要处理英文资料all-minilm完全够用速度快到飞起。如果你对中文检索质量要求极高、机器配置也好可以上bge-m3。这里有个经验不要盲目追求排行榜第一的模型。排行榜上的评测集和你的实际数据分布可能差很远而且大模型意味着每次同步都要花更多时间。我建议先用nomic-embed-text跑起来觉得效果不够再换。2.3 Python 环境与依赖Python 版本建议 3.10 以上我用的是 3.11。依赖不多核心就几个pip install requests numpyrequests用来调 Ollama 的 HTTP APInumpy用来做向量运算。如果你不想用 numpy纯 Python 列表也能算余弦相似度但数据量大了会慢很多建议还是装上。提示如果你在 Windows 上遇到pip安装慢的问题可以配置国内镜像源这个网上教程很多不展开。2.4 SQLite 管理工具SQLite 本身是 Python 内置的不需要额外安装。但你需要一个工具来查看和调试数据库内容。我推荐DB Browser for SQLite简称 DB4S开源跨平台界面直观能直接执行 SQL、查看表结构、导出数据。热词里提到的 db browser for sqlite 和 db4s 就是它。下载安装后直接打开你的.db文件就能看到所有表和数据。调试阶段我几乎每天都要用它看一眼向量有没有正确写入、文本块切分是否合理。3. 核心实现从文件到向量3.1 数据库表结构设计先建表。我的设计比较简单两张表一张存文本块和向量一张存文件同步状态。CREATE TABLE IF NOT EXISTS chunks ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_file TEXT NOT NULL, chunk_index INTEGER NOT NULL, content TEXT NOT NULL, embedding BLOB NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS file_sync ( file_path TEXT PRIMARY KEY, last_modified REAL NOT NULL, last_synced TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_source_file ON chunks(source_file);几个设计要点向量存成 BLOB。numpy 数组可以转成 bytes 存进 SQLite 的 BLOB 字段读取时再转回来。这样比存 JSON 字符串省空间读写也快。import numpy as np def vector_to_blob(vec): return np.array(vec, dtypenp.float32).tobytes() def blob_to_vector(blob): return np.frombuffer(blob, dtypenp.float32)file_sync 表用来做增量同步。每次同步前先查这个文件有没有变过对比last_modified没变就跳过避免重复计算 embedding。这个优化在文件多的时候效果非常明显。chunk_index 记录文本块在原文件中的顺序检索到结果后可以按文件聚合展示时更有上下文。3.2 文本切分策略文本切分是整套系统里最容易被忽视、但影响最大的环节。切得太碎语义不完整切得太大检索精度下降。我的策略是按段落切分带重叠def split_text(text, chunk_size500, overlap100): paragraphs text.split(\n\n) chunks [] current for para in paragraphs: if len(current) len(para) chunk_size: current para \n\n else: if current: chunks.append(current.strip()) # 处理超长段落 if len(para) chunk_size: for i in range(0, len(para), chunk_size - overlap): chunks.append(para[i:i chunk_size]) current else: current para \n\n if current: chunks.append(current.strip()) return chunkschunk_size500是我反复调整后的值。中文 500 字大约对应 300-400 个 token正好在 embedding 模型的最佳输入范围内。overlap100是为了避免关键信息刚好被切在边界上导致语义丢失。实操心得Markdown 文件里的代码块、表格、标题这些结构切分时最好单独处理。我一开始没管结果代码块被从中间切开检索出来的片段完全没法看。后来加了个判断遇到 包裹的代码块就整体保留不参与切分。3.3 调用 Ollama 生成 embeddingOllama 提供了 HTTP API默认监听11434端口。生成 embedding 的接口是/api/embeddingsimport requests def get_embedding(text, modelnomic-embed-text): resp requests.post( http://localhost:11434/api/embeddings, json{model: model, prompt: text}, timeout60 ) resp.raise_for_status() return resp.json()[embedding]这里有个性能问题逐条调用 API 很慢。我的知识库有几千个文本块逐条调要跑十几分钟。优化方法是批量处理但 Ollama 的 embeddings 接口一次只接受一个 prompt。我的做法是用多线程并发调用from concurrent.futures import ThreadPoolExecutor def batch_embed(texts, modelnomic-embed-text, workers4): with ThreadPoolExecutor(max_workersworkers) as executor: results list(executor.map(lambda t: get_embedding(t, model), texts)) return resultsworkers4是我测试下来比较稳的并发数。再高容易把 Ollama 服务压垮反而变慢。这个值取决于你的机器配置建议从 2 开始试。注意Ollama 默认只加载一个模型实例并发请求会排队。如果你发现并发没效果可能是 Ollama 的OLLAMA_NUM_PARALLEL环境变量没设置。可以设为 4 试试但会占用更多内存。3.4 增量同步逻辑完整的同步流程是这样的import os import sqlite3 from pathlib import Path def sync_vault(vault_path, db_path): conn sqlite3.connect(db_path) cursor conn.cursor() for md_file in Path(vault_path).rglob(*.md): mtime os.path.getmtime(md_file) cursor.execute( SELECT last_modified FROM file_sync WHERE file_path ?, (str(md_file),) ) row cursor.fetchone() if row and abs(row[0] - mtime) 1: continue # 文件没变跳过 # 文件变了删除旧数据 cursor.execute(DELETE FROM chunks WHERE source_file ?, (str(md_file),)) # 读取、切分、生成 embedding content md_file.read_text(encodingutf-8) chunks split_text(content) embeddings batch_embed(chunks) for idx, (chunk, emb) in enumerate(zip(chunks, embeddings)): cursor.execute( INSERT INTO chunks (source_file, chunk_index, content, embedding) VALUES (?, ?, ?, ?), (str(md_file), idx, chunk, vector_to_blob(emb)) ) cursor.execute( INSERT OR REPLACE INTO file_sync (file_path, last_modified) VALUES (?, ?), (str(md_file), mtime) ) conn.commit() conn.close()这个逻辑的关键是先删后插。文件变了就把这个文件的所有旧 chunk 删掉重新生成避免残留过期数据。虽然有点粗暴但对个人知识库来说完全够用而且逻辑简单不容易出错。4. 检索与展示4.1 余弦相似度检索检索的核心就是算余弦相似度。SQLite 本身不支持向量运算所以我把所有向量读到内存里用 numpy 算def search(query, db_path, top_k10): query_vec np.array(get_embedding(query), dtypenp.float32) conn sqlite3.connect(db_path) cursor conn.cursor() cursor.execute(SELECT id, source_file, content, embedding FROM chunks) rows cursor.fetchall() conn.close() results [] for row in rows: vec blob_to_vector(row[3]) # 余弦相似度 similarity np.dot(query_vec, vec) / (np.linalg.norm(query_vec) * np.linalg.norm(vec)) results.append((similarity, row[0], row[1], row[2])) results.sort(keylambda x: x[0], reverseTrue) return results[:top_k]这个实现是全量暴力检索。十万条数据以内一次检索大概 200-500 毫秒完全可以接受。如果你数据量更大可以考虑用sqlite-vec扩展或者换向量数据库但个人知识库基本到不了那个量级。实操心得numpy 的np.dot对 float32 数组有优化比纯 Python 循环快几十倍。另外如果向量已经归一化过余弦相似度就退化成点积可以省掉除法。Ollama 返回的 embedding 不一定是归一化的但你可以自己归一化后存进去检索时直接点积。4.2 在 Obsidian 里展示结果检索脚本跑通后展示层我试过两种方案方案一Python 脚本输出 Markdown 文件。检索结果写成一个.md文件放到 Obsidian 仓库里用 Obsidian 打开看。简单粗暴但每次都要手动跑脚本。方案二Obsidian 插件调用本地 API。写一个简单的 HTTP 服务包装检索逻辑Obsidian 插件发请求拿结果。这个体验最好但需要写插件门槛高一些。我目前用的是方案一的变体写了个search.py命令行传查询词结果直接打印到终端同时生成一个临时 Markdown 文件。够用不折腾。python search.py 向量检索性能优化输出格式[0.87] /vault/notes/embedding-optimization.md 向量检索的性能瓶颈主要在距离计算... [0.82] /vault/notes/rag-pipeline.md 检索阶段如果用暴力搜索十万条数据...4.3 每日自动同步自动同步用系统定时任务。Linux 下编辑 crontabcrontab -e加入一行每天凌晨 3 点跑同步0 3 * * * /usr/bin/python3 /path/to/sync.py /var/log/kb-sync.log 21Windows 下用任务计划程序创建一个每天触发的任务操作选启动程序程序填python.exe的路径参数填sync.py的路径。注意定时任务里的 Python 路径一定要写绝对路径环境变量可能和你在终端里不一样。我踩过这个坑脚本手动跑没问题定时任务就是跑不起来查了半天发现是python3找不到。5. 踩坑记录与排查技巧5.1 Ollama 相关坑坑一模型下载卡住不动。前面提过换时间段或找离线包。还有一个隐藏问题Ollama 下载是断点续传的如果卡住了可以 CtrlC 中断再重新 pull它会从断点继续不用从头下。坑二Ollama 服务启动失败。常见原因是端口被占用。11434端口如果被别的程序占了Ollama 起不来。用lsof -i :11434Linux/macOS或netstat -ano | findstr 11434Windows查一下把占用进程干掉或者改 Ollama 的监听端口。坑三embedding 结果为空。有时候 API 返回 200 但embedding字段是空的。这通常是因为输入文本太长超过了模型的最大输入长度。nomic-embed-text的最大输入是 8192 token一般不会超但如果你切分逻辑有 bug 传了个超长文本进去就会这样。加个长度检查if len(text) 6000: text text[:6000]坑四Ollama 占用内存越来越高。长时间跑批量 embeddingOllama 的内存占用会涨。我的做法是每处理 500 个 chunk 就重启一次 Ollama 服务或者设置OLLAMA_MAX_LOADED_MODELS1限制同时加载的模型数。5.2 SQLite 相关坑坑一并发写入锁。SQLite 默认是写锁同一时间只能一个连接写。如果你一边跑同步一边跑检索可能会遇到database is locked。解决办法是同步和检索错开时间或者用 WAL 模式conn.execute(PRAGMA journal_modeWAL)WAL 模式下读写可以并发对个人知识库这种读多写少的场景很合适。坑二BLOB 字段读取报错。numpy 的frombuffer要求 bytes 长度是 4 的倍数float32 是 4 字节。如果存的时候不是 float32 或者长度不对读出来就会报错。统一用np.float32存读的时候也指定dtypenp.float32。坑三数据库文件越来越大。删除了 chunk 之后SQLite 文件不会自动缩小。需要手动执行VACUUMVACUUM;我一般每个月跑一次能把文件缩小不少。5.3 检索质量相关坑坑一搜出来的结果不相关。最常见的原因是切分太碎一个 chunk 里没有完整语义。调大chunk_size或者优化切分逻辑。另一个原因是 embedding 模型不适合你的数据换模型试试。坑二相似度分数都很低。如果所有结果的相似度都在 0.3 以下说明查询和文档的语义空间对不齐。检查一下查询和文档是不是用了同一个模型生成 embedding——必须用同一个模型不同模型的向量空间不通用。坑三中文检索效果差。nomic-embed-text的中文能力中等如果效果不满意换bge-m3。另外查询时可以在前面加个指令前缀比如为这个句子生成表示以用于检索相关文章有些模型对指令前缀敏感能提升效果。5.4 常见问题速查表问题现象可能原因解决方法Ollama 下载卡住网络问题换时间段、用离线包、断点续传embedding 返回空输入超长截断文本到 6000 字符以内database is locked并发读写冲突开启 WAL 模式、错开同步和检索时间检索结果不相关切分太碎/模型不匹配调大 chunk_size、确认查询和文档同模型定时任务不执行路径或环境问题用绝对路径、检查日志数据库文件过大删除后未回收空间定期执行 VACUUM同步速度慢逐条调用 API多线程并发、增量同步跳过未变文件6. 后续可以怎么扩展这套系统跑了一个多月基本满足我的需求。后面我打算做几个扩展接入更多数据源。目前只同步了 Obsidian 仓库下一步想把 Zotero 的笔记也导进来。Zotero 可以导出 Markdown放到一个固定目录同步脚本加个路径就行。热词里如何将zotero的笔记导入obsidian是个高频问题其实用 Zotero 的 Better BibTeX 插件导出 Markdown 再放进 Obsidian 仓库是最顺滑的方案。加个简单的 Web 界面。用 Flask 或 FastAPI 包一层浏览器里直接搜比命令行方便。这个不难几十行代码的事。检索结果重排序。先用向量检索召回 Top-50再用一个小的交叉编码器模型重排序精度能提升不少。不过这会增加复杂度和延迟看需求决定要不要上。多模态扩展。图片、PDF 里的文字也可以提取出来做 embedding不过这就复杂了暂时不折腾。我个人在实际操作中的体会是这套方案最大的价值不是技术本身而是它逼着我把散落各处的笔记整理到了一起。以前笔记到处放现在有了统一的同步流程反而养成了随手记、定期整理的习惯。技术是手段知识管理才是目的。如果你也在纠结要不要自建知识库我的建议是先用最简单的方案跑起来哪怕只是 Obsidian 加个全文搜索插件也比什么都不做强。等真的觉得不够用了再逐步加 embedding、加自动同步一步步来别一上来就追求完美架构。