资讯详情

资讯详情

本地知识库搭建实战:本地embedding+向量库+定时同步

1. 为什么我要折腾本地知识库先说结论我搭这套东西的起因特别朴素——笔记软件换了四五个从印象笔记到Notion再到Obsidian每次迁移都像搬家最要命的是搜索。我攒了大概六年的技术笔记、会议纪要、读书摘录加起来两千多篇用关键词搜经常搜出一堆不相关的东西想找上次那个数据库连接池超时的排查思路搜连接池能出来八十条翻到第十条就忘了自己要找什么。后来我试了某云笔记的AI问答功能确实好用但有两个问题让我最终放弃第一我的笔记里有不少公司内部的技术方案和客户信息传到别人服务器上心里总不踏实第二按月订阅的费用算下来一年也不少而且断网就用不了。于是我就想能不能在自己电脑上搞一套——把笔记全部本地向量化用本地embedding模型再配一个每天自动同步的机制这样既安全又不用花钱。这个项目前后折腾了大概三周踩的坑比我预想的多得多。从embedding模型选型、向量库搭建、文档切分策略到自动同步的定时任务、增量更新逻辑、文件编码问题每一个环节都有坑。这篇文章就是把这整个过程完整记录下来包括我最终采用的方案、每一步的具体操作、参数怎么定、遇到问题怎么排查。如果你也有大量本地文档想做成可检索的知识库或者单纯想了解一下本地embedding这套东西怎么落地这篇应该能帮你省下不少时间。整套方案的核心思路是本地embedding模型负责把文本转成向量本地向量数据库负责存储和检索定时任务负责每天自动扫描新增和修改的文件并增量更新索引。不依赖任何外部API断网也能用数据不出本机。适合有隐私顾虑、文档量大、又愿意花点时间折腾的技术人员。2. 整体方案设计与选型考量2.1 为什么是本地embedding 向量库 定时同步这个组合在动手之前我调研了几种方案这里把思路捋一下方便你判断自己的场景适不适合。第一种是直接用云端大模型的embedding API比如OpenAI的text-embedding-3-small。优点是省事效果也好但缺点很明显要联网、要花钱、数据要传出去。我粗略算过我两千多篇笔记大概折合三百万token用云端API初次索引大概几块钱但每次新增都要重新调用长期下来是持续成本而且隐私问题绕不开。第二种是用本地大模型跑embedding比如通过Ollama调用nomic-embed-text或者bge-m3。这是我现在用的方案。本地跑的好处是零成本、零隐私风险、断网可用。代价是需要一点硬件——我用的是一台2021款的笔记本16G内存没有独立显卡纯CPU跑embedding模型速度能接受。初次索引三百万token大概跑了四十多分钟之后每天增量更新就几秒钟的事。第三种是混合方案本地embedding加云端LLM做问答。这个我暂时没做因为我的核心需求是检索而不是问答——我要的是快速找到相关笔记然后自己看而不是让AI替我总结。如果你需要问答功能可以在检索层之上再接一个本地LLM那是另一个话题了。向量库的选择上我对比了Chroma、Qdrant、LanceDB和FAISS。FAISS性能最好但需要自己管理元数据Chroma上手最简单但持久化有点坑Qdrant功能全但要跑服务LanceDB是嵌入式的最省心。最终我选了Chroma原因是它和Python生态结合最顺API简单而且支持持久化到本地目录适合个人项目。如果你文档量特别大比如几十万篇可以考虑Qdrant或者Milvus但个人知识库这个量级Chroma完全够用。2.2 文档切分策略这是最容易被低估的环节很多人搭知识库只关注embedding模型和向量库忽略了文档切分结果检索效果很差。我一开始也是这样直接把整篇笔记扔进去embedding结果一篇三千字的笔记变成一个向量检索的时候要么全中要么全不中精度极差。后来我改成了按语义切分具体策略是这样的先按Markdown的标题层级切一级标题切大块二级标题切中块如果某个块还是超过500字再按段落切段落之间用空行分隔。每个块控制在200到500字之间太短了语义不完整太长了检索精度下降。块与块之间保留50字左右的重叠避免刚好切在关键句中间导致语义断裂。这里有个细节切分的时候要保留上下文信息。比如一篇笔记叫MySQL慢查询排查切出来的某个块只讲了开启慢查询日志如果单独embedding这个块检索怎么开启慢查询日志能中但检索MySQL性能问题怎么排查就可能中不了。我的做法是在每个块的文本前面拼上文档标题和所属章节标题变成MySQL慢查询排查 日志配置开启慢查询日志的方法是...这样embedding的时候上下文就带上了。2.3 自动同步的触发机制设计自动同步这块我试了三种方案。第一种是用系统的cron或者计划任务每天固定时间跑一次全量扫描。优点是简单可靠缺点是如果电脑那个时间没开机就错过了。第二种是用文件系统监听比如watchdog库文件一改就触发更新。优点是实时缺点是频繁触发、容易漏事件而且我经常批量改文件会触发几百次更新。最终我用的是混合方案每天凌晨两点跑一次全量扫描同时保留一个手动触发的命令。全量扫描的逻辑是遍历笔记目录下所有.md文件对比文件的修改时间和向量库里记录的修改时间只处理新增和修改过的文件删除的文件从向量库里移除。这样即使某天没开机第二天开机后手动跑一次或者等第二天凌晨也能补上。这里的关键是增量更新。如果每天全量重新embedding三百万token要跑四十多分钟不现实。增量更新只处理变化的文件通常一天也就改几篇笔记几秒钟就跑完了。实现上我用一个JSON文件记录每个文件的路径、修改时间、内容哈希和对应的向量ID列表扫描时对比修改时间和哈希不一致就重新处理。3. 核心细节解析与实操要点3.1 本地embedding模型怎么选、怎么跑模型选型我试了四个all-MiniLM-L6-v2、bge-small-zh-v1.5、bge-base-zh-v1.5和nomic-embed-text。前两个是英文为主的中文效果一般bge系列是智源出的中文模型效果明显好一截nomic-embed-text是英文为主但多语言也还行。最终我选了bge-base-zh-v1.5理由是中文笔记为主这个模型在中文语义相似度上表现稳定模型大小适中约400MBCPU推理速度可接受输出768维向量存储和检索开销都不大。如果你笔记以英文为主可以用all-MiniLM-L6-v2速度快很多如果追求更好效果且有GPU可以上bge-large-zh-v1.5。跑模型的方式我用的是sentence-transformers库这是最省事的方案。安装很简单pip install sentence-transformers加载和推理的代码大概长这样from sentence_transformers import SentenceTransformer model SentenceTransformer(BAAI/bge-base-zh-v1.5) def embed_texts(texts): # bge系列模型建议在检索query前加指令前缀 embeddings model.encode(texts, normalize_embeddingsTrue) return embeddings这里有个坑bge系列模型在检索时query和document的embedding方式略有不同。官方建议query前面加为这个句子生成表示以用于检索相关文章这个前缀document不加。我实测下来加了前缀检索精度确实有提升大概能提高几个百分点。另外normalize_embeddingsTrue一定要开这样向量是单位向量余弦相似度直接点积就行省事。CPU推理速度方面我的笔记本上bge-base-zh-v1.5大概每秒能处理20到30个短文本块初次索引三百万token约六千个块跑了四十多分钟。如果你嫌慢可以换bge-small-zh-v1.5速度快一倍效果差一点点。3.2 向量库的搭建与持久化配置Chroma的安装和初始化pip install chromadb初始化持久化客户端import chromadb client chromadb.PersistentClient(path./chroma_db) collection client.get_or_create_collection( namemy_notes, metadata{hnsw:space: cosine} )这里有几个关键点。第一hnsw:space设成cosine因为我们用归一化向量cosine距离最合适。第二collection的name一旦定了就别改改了等于新建一个空库。第三持久化路径要选好别放在会被清理的临时目录里。插入数据的时候每个块需要提供id、embedding、document原始文本和metadata。metadata里我存了文件路径、章节标题、修改时间这些方便检索后回溯。id我用的是文件路径加块序号的哈希保证唯一且可复现。检索的时候results collection.query( query_embeddings[query_embedding], n_results10, include[documents, metadatas, distances] )n_results设10是我试出来的设5有时候漏掉相关结果设20又太多噪音。你可以根据自己的文档密度调整。3.3 文档切分的具体实现切分这块我写了一个函数逻辑是递归的先按一级标题切每个一级标题下的内容如果超过500字再按二级标题切还超就按段落切段落还超就按句子切。代码大概长这样import re def split_markdown(text, max_len500, overlap50): # 先按标题切 sections re.split(r\n(?#{1,3} ), text) chunks [] for sec in sections: if len(sec) max_len: chunks.append(sec) else: # 按段落切 paras sec.split(\n\n) current for p in paras: if len(current) len(p) max_len: current p \n\n else: if current: chunks.append(current.strip()) current p \n\n if current: chunks.append(current.strip()) # 加overlap final [] for i, c in enumerate(chunks): if i 0: c chunks[i-1][-overlap:] c final.append(c) return final这个实现比较粗糙实际用的时候我加了标题上下文拼接。另外overlap的处理要注意别把标题切碎我后来改成只在段落级别加overlap标题级别不加。3.4 增量同步的状态管理状态文件我用JSON存结构大概是这样{ /notes/mysql/slow-query.md: { mtime: 1700000000, hash: a1b2c3..., chunk_ids: [id1, id2, id3] } }扫描逻辑遍历笔记目录下所有.md文件对每个文件算mtime和内容哈希和状态文件对比。如果文件是新的或者mtime变了且哈希也变了就重新切分、embedding、更新向量库同时删掉旧的chunk_ids对应的向量。如果文件被删了从向量库删掉对应向量从状态文件移除。这里有个坑mtime有时候不可靠比如从网盘同步下来的文件mtime可能是同步时间而不是修改时间。所以我加了哈希校验mtime变了但哈希没变就跳过避免无谓的重新embedding。4. 实操过程与核心环节实现4.1 环境准备与依赖安装我的环境是Ubuntu 22.04Python 3.10。如果你用Windows或者macOS大部分步骤一样只是路径和定时任务的写法不同。先建虚拟环境别污染系统Pythonpython -m venv venv source venv/bin/activate然后装依赖pip install sentence-transformers chromadb markdown-it-pysentence-transformers会连带装torch如果你不需要GPU装CPU版torch能省不少空间pip install torch --index-url https://download.pytorch.org/whl/cpu这一步我踩过一个坑torch版本和sentence-transformers版本不匹配会导致加载模型时报错。我的经验是先装sentence-transformers让它自己拉torch如果拉的是GPU版且你没有GPU再手动换成CPU版。4.2 初次全量索引的完整流程初次索引我写了一个脚本流程是遍历笔记目录、读取文件、切分、批量embedding、批量插入向量库、更新状态文件。批量embedding的时候要注意内存。我一开始把六千个块一次性encode结果内存爆了。后来改成每批256个块跑完一批插一批内存稳定在2G左右。BATCH_SIZE 256 for i in range(0, len(chunks), BATCH_SIZE): batch chunks[i:iBATCH_SIZE] embeddings model.encode(batch, normalize_embeddingsTrue) collection.add( ids[...], embeddingsembeddings.tolist(), documentsbatch, metadatas[...] )初次索引跑了四十多分钟中间我盯着进度条看了半天。建议跑之前先拿几十篇笔记试一下确认流程通了再全量跑不然跑一半发现切分有问题就白跑了。4.3 每日自动同步的定时任务配置Linux下用cron编辑crontabcrontab -e加一行0 2 * * * /home/user/kb/venv/bin/python /home/user/kb/sync.py /home/user/kb/sync.log 21意思是每天凌晨两点跑sync.py日志追加到sync.log。注意要用虚拟环境里的python绝对路径不然cron环境找不到依赖。Windows下用任务计划程序macOS下用launchd逻辑类似。我建议日志一定要留出问题的时候能查。sync.log我设了自动轮转超过10M就切分避免日志无限增长。4.4 检索接口的封装与使用检索我封装了一个命令行工具输入query输出最相关的10个块每个块显示来源文件、章节标题和相似度分数。def search(query, top_k10): query_emb model.encode( 为这个句子生成表示以用于检索相关文章 query, normalize_embeddingsTrue ) results collection.query( query_embeddings[query_emb.tolist()], n_resultstop_k, include[documents, metadatas, distances] ) for doc, meta, dist in zip( results[documents][0], results[metadatas][0], results[distances][0] ): print(f[{1-dist:.3f}] {meta[file]} {meta[section]}) print(doc[:200]) print(---)相似度分数我用1-dist显示因为cosine距离越小越相似转成相似度更直观。实测下来相似度0.7以上的基本相关0.5到0.7的可能相关0.5以下的基本不相关。5. 常见问题与排查技巧实录5.1 检索结果不相关怎么办这是最常见的问题。我遇到过的原因有四个切分太粗、embedding模型不适合中文、query没加前缀、向量库距离度量设错了。排查顺序先看切分后的块大小如果很多块超过800字说明切太粗调小max_len再看模型如果用的是英文模型跑中文换bge系列然后检查query有没有加bge的前缀最后确认collection的hnsw:space是cosine。我印象最深的一次是检索如何优化Python循环性能结果返回的全是Python安装教程。查了半天发现是切分的时候把Python这个标题下的所有内容切成了一个块导致这个块和任何带Python的query都相似。改成按二级标题切之后问题就解决了。5.2 同步任务没跑或者跑失败cron任务不跑的原因通常是环境变量问题。cron的环境和登录shell不一样PATH可能不包含你需要的路径。解决办法是在脚本里用绝对路径或者在crontab里显式设置PATH。跑失败的话先看日志。我遇到过几种文件编码不是UTF-8导致读取报错解决办法是读取时指定encodingutf-8遇到错误用errorsignore磁盘满了导致写入失败模型文件被误删导致加载失败。5.3 内存占用过高embedding模型加载后常驻内存大概1G左右Chroma的HNSW索引也会占内存。如果文档量特别大内存可能吃紧。我的做法是同步脚本跑完就退出不常驻检索的时候再加载。这样平时内存占用很低只有跑同步和检索的时候才占。如果文档量超过十万块建议换Qdrant或者用Chroma的磁盘模式减少内存占用。5.4 常见问题速查表问题现象可能原因排查方法解决方案检索结果不相关切分太粗检查块大小调小max_len到300-500检索结果不相关模型不适合确认模型语言换bge-base-zh-v1.5检索结果不相关query没加前缀检查query处理加bge检索前缀同步任务不跑cron环境问题看cron日志用绝对路径同步任务报错文件编码看错误日志指定utf-8编码内存占用高模型常驻看进程内存脚本跑完退出索引速度慢批量太大看内存峰值调小batch_size向量库损坏异常退出看chroma日志从状态文件重建5.5 几个我踩过的坑和对应的经验第一个坑是文件路径含中文或空格。Chroma的id如果直接用文件路径含中文或空格可能出问题。我的做法是对路径做哈希用哈希值作为id的一部分metadata里存原始路径。第二个坑是Markdown里的代码块被切碎。代码块里的内容如果被切分到不同块检索的时候语义就断了。我的做法是在切分前先把代码块替换成占位符切完再还原保证代码块完整。第三个坑是重复内容导致检索结果冗余。我有些笔记是复制的内容重复检索的时候返回好几条一样的。解决办法是在插入前做去重内容哈希相同的块只保留一个。第四个坑是模型更新后向量不一致。我中途换过一次模型从bge-small换成bge-base结果新旧向量混在一起检索效果很差。换模型必须全量重建索引不能增量。6. 一些实操心得和后续扩展方向这套东西跑了一个多月每天凌晨自动同步我用得挺顺手。检索速度方面六千个块的库一次query大概几十毫秒完全无感。准确率方面我主观感觉比之前用关键词搜好很多尤其是那种我记得写过但想不起关键词的场景用自然语言描述一下就能找到。如果你也想搭一套我的建议是先从几十篇笔记开始把流程跑通确认检索效果满意了再全量导入。别一上来就几千篇出了问题排查起来很痛苦。后续我打算加两个东西一是把检索结果接一个本地LLM做总结这样问我关于数据库优化都写了什么能直接给个汇总二是做一个简单的Web界面现在命令行用着还是不太方便。不过这两个都是锦上添花核心的embedding加同步已经够用了。最后分享一个小技巧如果你笔记里有大量表格切分的时候要特别小心表格被切碎基本就废了。我的做法是检测到表格就整块保留不切分哪怕超过500字也不切。表格的语义完整性比块大小重要得多。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →