☰
放弃云笔记,用Ollama+SQLite自建本地语义搜索知识库
2026/10/15 11:26:49 网站建设 项目流程

1. 为什么我要放弃云笔记,自己搭一套知识库

三年前我把所有笔记都放在某云笔记里,直到有一次搜索一个关键词,返回的结果跟我实际写的内容八竿子打不着——它只匹配了标题里的字,正文里明明写过三段相关的内容,一条都没搜出来。那一刻我意识到,云笔记的搜索本质上是字符串匹配,它不理解"向量数据库"和"embedding 存储"之间的语义关联。你搜"怎么让笔记自动关联",它只会找包含这几个字的段落,而不是找真正讲这件事的内容。

这就是我决定自己搭一套本地知识库的起点。核心诉求很明确:语义搜索加上每日自动同步。语义搜索靠的是 embedding 模型把每段文字转成向量,搜索时比较向量相似度,而不是比字面。自动同步则是让新写的笔记在当天就被索引进去,不用手动触发。

整套方案的技术栈是这样的:Obsidian作为笔记编辑器(本地 Markdown 文件,数据完全归自己),Ollama跑本地 embedding 模型(不用联网、不用 API key、不花钱),SQLite存向量和元数据(单文件、零配置、够用),Python写索引和同步脚本。这四个东西组合起来,覆盖了从写作、向量化、存储到检索的完整链路。

适合谁来参考?如果你满足下面任意一条,这篇内容对你就直接有用:笔记量超过 500 篇、经常"记得写过但搜不到"、对数据隐私有要求不想把笔记传到别人服务器、或者单纯想搞明白 embedding 和向量检索到底怎么回事。如果你笔记不到 100 篇,说实话 Obsidian 自带的搜索够用了,不用折腾。

我踩的坑主要集中在三个地方:Ollama 模型下载慢到怀疑人生、SQLite 存向量时的类型选择和查询性能、以及每日自动同步的触发机制设计。下面按搭建顺序一个个说。

2. 环境搭建:Ollama、SQLite、Python 的安装细节与国内加速

2.1 Ollama 安装:模型存储路径一定要提前改

Ollama 的安装本身不复杂,Windows 下直接下安装包双击,Linux 下一行脚本。但有个坑我踩得很深:默认模型存储路径在系统盘。embedding 模型虽然不大(几百 MB),但如果你后面还想跑生成模型做摘要,动辄几个 GB,C 盘很快就红了。

Linux 下修改模型路径靠环境变量。如果你用 systemd 管理 Ollama 服务,编辑服务文件:

sudo systemctl edit ollama.service

在打开的编辑器里加入:

[Service] Environment="OLLAMA_MODELS=/data/ollama/models"

然后重载并重启:

sudo systemctl daemon-reload sudo systemctl restart ollama

Windows 下则是设置系统环境变量OLLAMA_MODELS,指向比如D:\ollama\models,然后重启 Ollama 服务。注意是重启服务,不是重启电脑,很多人改完环境变量发现没生效,就是因为 Ollama 后台进程还在用旧路径。

提示:改路径之前如果已经下载过模型,记得把旧路径下的blobs和manifests两个目录整体搬过去,否则 Ollama 会认为模型不存在,重新下载。

2.2 模型下载慢的应对:分批拉取与镜像源思路

ollama pull下载慢是普遍现象,尤其是晚上高峰期。我的经验是:别一次性拉大模型,先拉小的 embedding 模型验证链路。embedding 模型通常只有几百 MB,比动辄 4GB 以上的生成模型好拉得多。

常用的 embedding 模型有nomic-embed-text、mxbai-embed-large、bge-m3这几个。我实测下来nomic-embed-text在中文笔记场景下表现中规中矩,体积小、速度快,适合先跑通。如果你对中文语义要求高,bge-m3更合适,但体积大一些。

下载的时候可以观察进度,如果卡住不动,Ctrl+C 中断再重新 pull,Ollama 支持断点续传,不会从头开始。另外把下载安排在凌晨或者网络空闲时段,速度会明显不一样。有些地区有可用的镜像源,配置方式是在环境变量里指定OLLAMA_HOST指向镜像地址,具体地址会变动,需要自己查当前可用的。

拉完之后验证一下:

ollama list ollama run nomic-embed-text "测试一下"

第二条命令会输出一个向量数组,看到一长串浮点数就说明模型正常工作了。

2.3 SQLite 与 Python 环境:别小看版本问题

SQLite 在 Linux 下一般系统自带,sqlite3 --version能查到就行。如果没有:

# Debian/Ubuntu 系 sudo apt install sqlite3 libsqlite3-dev # RHEL/Rocky 系 sudo dnf install sqlite sqlite-devel

Python 这边,我强烈建议用3.10 以上,因为后面用到的类型注解和部分库对版本有要求。安装 Python 本身不展开,官网下载或者包管理器都行。关键是装依赖:

pip install requests numpy

requests用来调 Ollama 的 HTTP 接口,numpy用来做向量运算。这里有个坑:numpy 在部分老版本 Python 上编译会失败,如果你pip install numpy报错,先升级 pip 再试,或者直接用pip install --only-binary :all: numpy强制装预编译包。

注意:SQLite 默认不支持向量类型,我们存向量只能存成 BLOB(二进制)或者 JSON 文本。这个选择直接影响后面的查询性能,第 3 节详细说。

3. 向量存储的选型:为什么我用 SQLite 存 BLOB 而不是 JSON

3.1 向量到底存成什么格式

embedding 模型输出的是一串浮点数,比如 768 维或 1024 维。存进 SQLite 有几种选择:JSON 字符串、逗号分隔文本、BLOB 二进制。我一开始图省事用了 JSON,结果十万条数据之后查询慢得离谱。

原因很简单:JSON 存的是文本,每个浮点数要占好几个字符,768 维向量存成 JSON 大概 8KB 到 10KB,而存成 float32 的 BLOB 只有 768 × 4 = 3072 字节,体积差了三倍。更关键的是读取时,JSON 要解析字符串再转 float,BLOB 直接struct.unpack或者 numpy 的frombuffer就能还原,速度快一个数量级。

存 BLOB 的写法:

import struct import numpy as np def vector_to_blob(vec): return struct.pack(f'{len(vec)}f', *vec) def blob_to_vector(blob): return np.frombuffer(blob, dtype=np.float32)

建表语句:

CREATE TABLE notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT UNIQUE NOT NULL, title TEXT, content TEXT, mtime REAL, embedding BLOB, updated_at REAL );

path加 UNIQUE 约束是为了后面做增量同步时能靠路径判断是否已索引。mtime存文件修改时间,用来判断笔记有没有变过。

3.2 十万条数据查询要多久

这是热词里很多人关心的。我实测了一下:十万条 768 维向量,如果做全量暴力检索(每条都算一次余弦相似度),Python 单线程大概要 3 到 5 秒。这个速度对于个人知识库来说,其实勉强能用,但体验不好。

优化方向有两个。第一是降维,用 PCA 把 768 维降到 128 维,精度损失不大,速度提升好几倍。第二是分块检索,先用关键词粗筛出一批候选,再在候选里做向量比较。我采用的是第二种,因为笔记场景下用户搜索往往带有关键词,粗筛命中率很高。

粗筛用 SQLite 的LIKE或者 FTS5 全文索引:

SELECT id, embedding FROM notes WHERE content LIKE '%关键词%';

然后在 Python 里对这批结果算余弦相似度排序。这样十万条数据里如果粗筛出 500 条,向量比较只需要几十毫秒。

提示:如果你的笔记量真的到了几十万条,SQLite 就不太够了,该上专门的向量库。但个人知识库到这个量级的极少,SQLite 完全撑得住。

3.3 余弦相似度的计算细节

余弦相似度衡量的是两个向量的方向接近程度,值在 -1 到 1 之间,越接近 1 越相似。公式是点积除以模长乘积。用 numpy 写:

def cosine_similarity(a, b): return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

批量计算时,把所有候选向量堆成一个矩阵,一次矩阵运算搞定,比循环快得多:

def batch_similarity(query_vec, matrix): query_norm = query_vec / np.linalg.norm(query_vec) matrix_norm = matrix / np.linalg.norm(matrix, axis=1, keepdims=True) return matrix_norm @ query_norm

这里有个容易忽略的点:embedding 模型输出的向量不一定归一化。有些模型输出已经归一化了,有些没有。如果你不确定,就统一在存储前做一次归一化,这样查询时只需要算点积,省掉除法。我一开始没归一化,导致相似度分数看起来很奇怪,排查了半天才发现是模长不一致。

4. 索引脚本:从 Markdown 文件到向量入库的完整流程

4.1 扫描笔记目录与增量判断

Obsidian 的库就是一个文件夹,里面全是.md文件。索引脚本第一步是遍历这个文件夹,拿到所有 Markdown 文件的路径和修改时间。

import os import time def scan_notes(vault_path): notes = [] for root, dirs, files in os.walk(vault_path): # 跳过 Obsidian 的配置目录 dirs[:] = [d for d in dirs if not d.startswith('.')] for f in files: if f.endswith('.md'): full = os.path.join(root, f) notes.append({ 'path': full, 'mtime': os.path.getmtime(full) }) return notes

增量判断的逻辑是:拿文件的mtime跟数据库里存的mtime比,如果文件更新了就重新索引,否则跳过。这样每天同步时只处理改过的笔记,几秒钟就跑完。

def needs_reindex(conn, path, mtime): cur = conn.execute('SELECT mtime FROM notes WHERE path = ?', (path,)) row = cur.fetchone() if row is None: return True return mtime > row[0]

4.2 文本分块:整篇索引还是分段索引

这是设计上的一个关键决策。如果把整篇笔记转成一个向量,那搜索时只能定位到"哪篇笔记相关",定位不到具体段落。如果按段落分块,每块一个向量,搜索就能精确到段落。

我选的是按段落分块,但做了合并:连续短段落合并到 500 字左右再切。原因是 embedding 模型对过短的文本语义捕捉不准,一句话的向量跟另一句话的向量可能莫名其妙地相似。

def chunk_text(text, max_len=500): paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()] chunks = [] buf = '' for p in paragraphs: if len(buf) + len(p) < max_len: buf += p + '\n' else: if buf: chunks.append(buf.strip()) buf = p + '\n' if buf: chunks.append(buf.strip()) return chunks

这样一篇 3000 字的笔记大概切成 6 到 8 块,每块单独存一行,用note_id关联回原笔记。搜索时返回的是块,但展示时可以聚合到笔记级别。

4.3 调用 Ollama 生成 embedding

Ollama 的 embedding 接口是/api/embeddings,POST 请求,body 里带模型名和文本:

import requests def get_embedding(text, model='nomic-embed-text'): resp = requests.post( 'http://localhost:11434/api/embeddings', json={'model': model, 'prompt': text}, timeout=60 ) resp.raise_for_status() return resp.json()['embedding']

这里有个性能问题:逐条请求太慢。一篇笔记 8 个块,1000 篇笔记就是 8000 次 HTTP 请求,每次几十毫秒,加起来好几分钟。优化方式是批量请求,但 Ollama 的 embeddings 接口一次只接受一个 prompt。我的做法是用多线程并发,开 4 到 8 个线程同时请求,速度提升明显。

from concurrent.futures import ThreadPoolExecutor def batch_embed(texts, workers=4): with ThreadPoolExecutor(max_workers=workers) as ex: return list(ex.map(get_embedding, texts))

注意:并发数别开太高,Ollama 本地推理吃 CPU/GPU,开太多反而互相抢资源。4 到 8 个线程是比较稳的区间。

4.4 入库与事务处理

入库时用事务包起来,避免中途出错导致数据半截。SQLite 的 Python 接口默认是自动提交的,要显式开事务:

def index_note(conn, path, title, chunks, embeddings): with conn: # 自动 commit/rollback conn.execute('DELETE FROM notes WHERE path = ?', (path,)) for chunk, emb in zip(chunks, embeddings): conn.execute( 'INSERT INTO notes (path, title, content, mtime, embedding, updated_at) VALUES (?,?,?,?,?,?)', (path, title, chunk, os.path.getmtime(path), vector_to_blob(emb), time.time()) )

先删后插是为了处理笔记内容变短的情况,否则旧块会残留。with conn这个上下文管理器在 Python 的 sqlite3 里会自动处理事务,异常时回滚,正常时提交。

5. 每日自动同步:定时任务与文件监听的取舍

5.1 为什么不用文件监听

最直觉的方案是用watchdog监听文件变化,一改就索引。我试过,问题不少:Obsidian 保存文件时可能触发多次事件(编辑器先写临时文件再重命名),导致同一篇笔记被索引好几遍;而且你写笔记时频繁触发索引,Ollama 推理占资源,打字都卡。

所以最终选了定时任务,每天固定时间跑一次全量扫描加增量索引。个人知识库不需要秒级同步,每天一次完全够用。

5.2 Linux 下的 cron 配置

Linux 下用 cron 最简单。编辑当前用户的 crontab:

crontab -e

加入一行,每天凌晨 3 点跑:

0 3 * * * /usr/bin/python3 /home/user/kb/index.py >> /home/user/kb/index.log 2>&1

>>把输出追加到日志,2>&1把错误也重定向进去,方便排查。这里有个坑:cron 的环境变量跟你登录 shell 的不一样,python3的路径、OLLAMA_HOST这些可能找不到。稳妥做法是在脚本里写绝对路径,或者用 shell 脚本包一层,先source环境变量再执行。

5.3 Windows 下的计划任务

Windows 用"任务计划程序"创建基本任务,触发器选每天,操作选"启动程序",程序填python.exe的完整路径,参数填脚本路径。同样要注意工作目录和环境变量,在"起始于"里填脚本所在目录。

如果不想用图形界面,也可以用schtasks命令行:

schtasks /create /tn "KBIndex" /tr "C:\Python311\python.exe C:\kb\index.py" /sc daily /st 03:00

5.4 同步失败的排查思路

自动任务最怕的是"悄悄失败"。我的做法是脚本里加日志和异常捕获,每次运行记录处理了多少篇、失败了多少篇。如果连续几天日志没更新,说明任务根本没跑起来。

常见失败原因:Ollama 服务没启动(重启后忘了自启)、路径写错、权限不足。Ollama 在 Linux 下可以设成开机自启:

sudo systemctl enable ollama

这样重启机器后服务自动起来,定时任务就不会因为连不上 Ollama 而失败。

6. 搜索端实现:从查询向量到结果排序

6.1 查询流程拆解

搜索的完整流程是:用户输入查询词 → 转成 embedding → 跟库里所有向量算相似度 → 排序取前 N 条 → 返回对应笔记段落。

def search(conn, query, top_k=10): q_vec = np.array(get_embedding(query), dtype=np.float32) q_vec = q_vec / np.linalg.norm(q_vec) cur = conn.execute('SELECT id, path, title, content, embedding FROM notes') rows = cur.fetchall() results = [] for row in rows: vec = blob_to_vector(row[4]) vec = vec / np.linalg.norm(vec) score = float(np.dot(q_vec, vec)) results.append((score, row[1], row[2], row[3])) results.sort(reverse=True) return results[:top_k]

这是全量暴力检索版本,十万条数据下大概几秒。前面说的粗筛优化就是在这里加一层WHERE content LIKE。

6.2 相似度阈值:多少分才算相关

余弦相似度没有绝对标准,不同模型输出的分布不一样。我实测nomic-embed-text下,真正相关的段落相似度一般在 0.6 以上,0.5 到 0.6 是勉强相关,0.5 以下基本是噪声。所以我在返回结果时加了个阈值过滤,低于 0.5 的直接不展示,避免用户看到一堆不相关的内容。

这个阈值需要根据自己的模型和笔记内容调。建议先不加阈值跑一批查询,观察相关结果的分数分布,再定一个合适的线。

6.3 结果展示:聚合到笔记级别

因为索引是按段落分块的,同一篇笔记可能有好几个块都命中。展示时如果直接列块,用户会看到同一篇笔记重复出现。我的做法是按path聚合,取每篇笔记得分最高的那个块作为代表,展示笔记标题加命中段落摘要。

def aggregate_by_note(results): best = {} for score, path, title, content in results: if path not in best or score > best[path][0]: best[path] = (score, title, content) return sorted(best.values(), reverse=True)

这样搜索结果更干净,用户一眼能看到哪几篇笔记相关,点进去再看具体段落。

7. 踩坑复盘:那些让我熬夜的问题

7.1 Ollama 模型加载失败与显存不足

有次我换了个更大的 embedding 模型,结果 Ollama 报错说加载失败。排查发现是显存不够——模型虽然不大,但 Ollama 默认会预留一部分显存做上下文。解决办法是设置OLLAMA_NUM_PARALLEL=1减少并发,或者干脆用 CPU 跑 embedding(速度慢点但稳定)。

CPU 模式设置环境变量OLLAMA_GPU_OVERHEAD或者启动时加参数,具体看版本。embedding 任务对延迟不敏感,CPU 跑完全可接受。

7.2 SQLite 数据库锁与并发写

定时任务在写库的时候,如果你同时在跑搜索脚本,可能遇到database is locked错误。SQLite 默认的锁粒度是整库,一个写操作会阻塞所有读。

解决办法是开启 WAL 模式:

PRAGMA journal_mode=WAL;

WAL 模式下读写可以并发,写不阻塞读。这个设置是持久化的,设一次就行。另外把busy_timeout设长一点,遇到锁时自动等待而不是直接报错:

PRAGMA busy_timeout=5000;

7.3 笔记里的特殊字符导致 JSON 解析失败

有些笔记里有奇怪的 Unicode 字符或者超长单行,调 Ollama 接口时 JSON 序列化会出问题。Python 的requests库默认用json=参数会做转义,一般没事,但如果文本里有控制字符,还是可能失败。稳妥做法是先清洗:

import re def clean_text(text): return re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', text)

把控制字符去掉再送进模型。

7.4 增量索引的边界情况

增量索引靠mtime判断,但有个边界:如果你用git管理笔记库,git checkout切换分支时文件mtime会变,导致大量笔记被重新索引。这个其实不算 bug,重新索引一遍也没坏处,只是费点时间。如果在意,可以改用文件内容的哈希值来判断,但计算哈希本身也要读文件,省不了多少。

另一个情况是笔记被删除。增量扫描只处理存在的文件,被删的笔记在数据库里会残留。我的做法是每次全量扫描时,把数据库里存在但磁盘上不存在的记录清理掉:

def cleanup_deleted(conn, existing_paths): cur = conn.execute('SELECT DISTINCT path FROM notes') db_paths = {row[0] for row in cur.fetchall()} for p in db_paths - existing_paths: conn.execute('DELETE FROM notes WHERE path = ?', (p,))

8. 性能调优与后续扩展方向

8.1 索引速度的瓶颈在哪

整个索引流程里,最慢的是调 Ollama 生成 embedding。本地模型推理速度取决于硬件,CPU 上 768 维模型大概每块几十到几百毫秒。1000 篇笔记、每篇 8 块,就是 8000 次推理,CPU 上可能要跑半小时以上。

优化手段:一是用 GPU 加速,Ollama 会自动检测;二是减少分块数量,把max_len调大,块少了推理次数就少;三是只索引改过的笔记,增量同步下每天可能只有几篇,几秒钟搞定。

8.2 查询速度的进一步优化

如果笔记量继续增长,全量暴力检索会越来越慢。除了前面说的关键词粗筛,还可以考虑向量量化:把 float32 降成 int8,体积缩小四倍,相似度计算用整数运算,速度快很多,精度损失在可接受范围内。

另一个方向是分层索引,先用聚类把向量分成若干簇,查询时只比较最近的几个簇。这个实现复杂一些,个人知识库一般用不上。

8.3 跟 Obsidian 的联动

目前搜索是独立的 Python 脚本,跟 Obsidian 是分离的。如果想在 Obsidian 里直接搜,可以写个 Obsidian 插件调本地 HTTP 服务,或者用 Obsidian 的 URI 协议从外部唤起。我目前的做法是搜索脚本输出结果后,用obsidian://open?vault=xxx&file=yyy这样的链接直接跳转到对应笔记,体验已经够顺了。

8.4 数据备份不能忘

SQLite 是单文件,备份就是复制文件。但要注意 WAL 模式下还有-wal和-shm两个附属文件,备份时要一起复制,或者先执行PRAGMA wal_checkpoint(TRUNCATE)把 WAL 合并回主库再复制。我设了个每周自动备份,把数据库文件复制到另一个目录,加上日期后缀。

cp /home/user/kb/knowledge.db /home/user/kb/backup/knowledge_$(date +%Y%m%d).db

笔记源文件本身在 Obsidian 库里,那个用 git 或者网盘同步都行,跟数据库是两套独立的备份。

整套东西跑下来,从最初的想法到稳定运行大概花了我两个周末。中间踩的坑基本都写在上面了,如果你照着搭,应该能省下不少时间。最后说一句:别追求一步到位,先把最小链路跑通——一篇笔记能索引、能搜出来——再逐步加自动同步、性能优化这些。我一开始就想把所有功能做全,结果卡在 Ollama 下载那步整整一天,后来退回去先跑通单篇索引,后面就顺了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询