☰
Obsidian+DeepSeek:本地AI知识库的Harness实践
2026/10/23 3:48:56 网站建设 项目流程

本地笔记库跑了几年的人大概都有过这种体验:笔记越攒越多,翻的时候却越来越慢,明明记得写过某个结论,搜索框里敲三次关键词都摸不到。我给自己的 Obsidian 库接上 DeepSeek 之后,最直观的变化不是"能聊天了",而是那些躺在角落里吃灰的旧笔记终于被翻出来了。这篇就把整套Obsidian + DeepSeek的接入过程摊开讲清楚,包括我理解的 Harness 这一层到底该干什么、三档不同成本的接入方案、切片与召回的参数怎么定,以及五个把我卡了半天的报错。适合已经会用 Obsidian 基本操作、想给自己的本地 AI 知识库添一层检索与生成能力的人,也适合完全没碰过 API、但愿意照着敲一遍命令的读者。

先说清楚一件事:标题里的 Harness 不是一个必须安装的软件包,我把它理解为"套具"——把模型这匹野马套住,让它的力气只往你笔记这一个方向使。马具本身不产生产力,但它决定了马往哪跑、跑多远、会不会把车掀翻。很多人接 AI 笔记失败,问题从来不在模型不够聪明,而在这一层套具没做好:上下文乱塞、密钥乱放、结果不落盘、召回的片段和问题根本不沾边。下面按我自己踩过的顺序一层层拆。

1. 把 Harness 理解成一副马具:四层结构决定后面所有选择

1.1 笔记层、索引层、模型层、编排层各自管什么

我在动手之前先把整个链路拆成四层,这个拆法后来帮我省了大量返工。笔记层就是你库里的 Markdown 文件,带 frontmatter、带[[双链]]、带标签,它是唯一的事实来源,任何 AI 生成的内容在写回去之前都只是草稿。索引层负责把笔记变成可检索的结构,包括切片、向量、关键词倒排,它决定了模型能看到什么。模型层是 DeepSeek 那边的推理服务,负责理解和生成,它没有你的记忆,每次调用都是白纸一张。编排层夹在中间,负责组装上下文、拼提示词、管理密钥、处理重试、把结果写回文件——这一层就是 Harness。

分清这四层的好处很直接:出问题的时候能定位。回答答非所问,是索引层的召回不准,不是模型笨;回答一半断了,是编排层的超时或上下文超限;能聊天但找不到具体笔记,是索引层压根没建起来。我见过不少人一遇到效果差就换模型,换来换去没变化,因为瓶颈一直在索引层。

具体到文件组织,我建议单独开一个目录放索引产物,比如_ai_index/,并且在 Obsidian 的设置里把这个目录排除在搜索和双链解析之外。索引文件动辄几十兆,让它参与全文搜索会拖慢整个库的响应,而且它本身没有阅读价值。同理,AI 生成后写回的笔记统一打一个ai_generated: true的 frontmatter 字段,日后想批量清理或者人工复核,一条查询就能筛出来。

1.2 为什么"装个插件填个 Key"通常撑不过两周

刚上手时我也这么干过:装个带聊天面板的插件,把 API Key 粘进去,选个模型,完事。头两天很爽,第三天开始出问题。原因很朴素——插件默认的上下文策略是"把当前笔记或者当前选中的文本发过去",它不知道你库里还有另外十七篇讲过同一个概念。你问一个跨笔记的问题,它只能拿眼前这一段硬答,答不出来就开始编。

第二个撑不住的点是成本失控。插件的默认配置往往把整篇长笔记塞进上下文,一篇八千字的读书笔记每次提问都要重新计费,聊十轮就是八万字上下文的开销。中文一个字大概对应 0.6 到 1 个 token,具体取决于分词器,八千字中文保守估四千到八千 token,多轮对话还会把历史一起带上,账单涨得比想象中快。

第三个点是结果不留痕。聊天框里聊出来的好东西,关掉面板就没了。知识库的价值在于沉淀,而不是一次性问答。所以我在第二周就改了策略:聊天面板只用来做临时提问,真正要留下来的内容必须走"生成 → 预览 → 写入指定目录"这条路径。这一点在后面第 5 节会给出具体的模板和回写方法。

顺带说个容易被忽略的细节:Obsidian 的插件生态里,不同插件对 OpenAI 兼容接口的实现程度差别很大。有的只让你填 Base URL 和 Key,模型名写死在代码里;有的支持自定义模型名但不支持流式输出;有的把请求发到它自己的服务器再转发。选插件时优先看两点——能不能自定义模型名,能不能关掉遥测。这两点决定了你后面调参的自由度。

2. 密钥落位与调用通道:先解决"敢不敢把整库交给模型"

2.1 API Key 的三种存放位置与各自的暴露面

密钥放哪儿这件事,直接决定了你敢不敢把整个库接进去。我试过三种位置,各有各的坑。

第一种是直接填在插件设置里。Obsidian 的插件配置保存在库目录下的.obsidian/plugins/<插件名>/data.json,也就是说密钥就躺在你的笔记库里。如果你用网盘同步、用 Git 备份、或者把库放在共享目录,这个文件会跟着一起走。我第一版就是这么干的,直到某次把库打包发给别人做演示,才想起来里面带着密钥。现在我的做法是在.gitignore里显式排除.obsidian/plugins/*/data.json,并且同步工具里设置该目录不同步。

第二种是放在系统环境变量里,通过本地网关服务读取。这种方式密钥不落在库目录,缺点是移动端用不了——手机上没有你那套环境变量。我的实际组合是:桌面端走本地网关,移动端只读不生成,需要生成时把片段发到桌面端处理。

第三种是写在一个库外的配置文件里,网关启动时读。这种方式最灵活,多库共用一个密钥配置,改一次全生效。缺点是文件权限要自己管好,Unix 系统上chmod 600是基本操作。

存放位置是否会随库同步移动端可用多库复用我的评价
插件 data.json会可用需重复配置应急可以,长期不建议
系统环境变量不会不可用可用桌面端首选
库外配置文件不会视网关而定最佳配合网关最省心

提示:不管放哪儿,Key 一旦在截图、日志、报错信息里出现过,就当它已经泄露,直接去后台作废重发。我见过把完整请求头贴进 issue 求助的,那基本等于公开了。

2.2 直连、本地网关、云端中转的取舍

调用通道的选择比密钥位置更影响体验。直连最省事,插件直接请求服务商的公开接口;本地网关是在本机跑一个小服务,插件指向http://127.0.0.1:端口,由网关去请求真正的接口;云端中转则是把请求发到第三方的转发服务。

直连的优点是链路短、延迟低、没有额外进程要维护。缺点是密钥必然存在客户端,而且你没法在中间做日志、缓存、限流。我一开始用直连,后来因为要做"同一段落不重复请求"的缓存,才改成网关。

本地网关的好处是把所有脏活集中了:密钥只在网关进程的环境变量里;请求和响应可以落日志方便排查;可以做本地缓存,同样的提示词第二次直接命中;可以做限流,防止脚本跑飞把额度打光;还可以在网关层统一处理重试和超时。代价是多一个进程要启动,得处理端口占用和开机自启。我用的是一个不到两百行的 Python 脚本配 FastAPI,够用。

云端中转我不建议用在本地知识库场景。你的笔记里可能有客户信息、个人记录、未发表的想法,经过第三方转发意味着多一个信任面,而且中转服务的可用性你没法控制。省下的那点配置时间,不值得。

网关的实现细节这里给个骨架,核心就三件事——读环境变量、转发请求、写缓存:

import os, hashlib, json, time from fastapi import FastAPI, Request import httpx app = FastAPI() UPSTREAM = "https://api.deepseek.com/chat/completions" KEY = os.environ["DEEPSEEK_API_KEY"] CACHE_DIR = os.path.expanduser("~/.cache/ds_harness") os.makedirs(CACHE_DIR, exist_ok=True) @app.post("/v1/chat/completions") async def chat(req: Request): body = await req.json() raw = json.dumps(body, sort_keys=True, ensure_ascii=False) fp = hashlib.sha256(raw.encode()).hexdigest()[:32] path = os.path.join(CACHE_DIR, fp + ".json") if os.path.exists(path): return json.load(open(path, encoding="utf-8")) async with httpx.AsyncClient(timeout=120) as client: r = await client.post(UPSTREAM, json=body, headers={ "Authorization": f"Bearer {KEY}", "Content-Type": "application/json", }) r.raise_for_status() data = r.json() json.dump(data, open(path, "w", encoding="utf-8"), ensure_ascii=False) return data

这段代码只做两件事:把请求原样转发出去,把响应按请求内容的哈希缓存下来。缓存这一层在实际使用中省了我大概三成的调用量,因为整理笔记时经常会对同一段文字反复问类似的问题。注意temperature不为 0 时同样的请求结果会有差异,缓存会掩盖这种差异,如果你在做创意类生成,可以给缓存加一个短过期时间或者干脆关掉。

3. 三档接入方案:从插件填表到自建索引脚本

3.1 第一档:现成插件的 OpenAI 兼容配置

如果你只想先跑通,不想写代码,这一档半小时能搞定。思路是利用 DeepSeek 提供的 OpenAI 兼容接口,把任何支持自定义 Base URL 的笔记插件指过去。需要填的参数就四个:

参数填写内容说明
Base URL服务商公开的接口根地址注意末尾不要多加/v1,具体以官方文档为准
API Key你的密钥建议先用一个独立的、限额的 Key 测试
模型名对话模型或推理模型推理模型更慢更贵,日常整理用对话模型够
上下文长度按笔记平均长度设别一上来就拉满

填完之后第一件事不是提问,是发一条最简单的请求确认链路通。用命令行验证比在插件里试更干净,因为报错信息完整:

curl -s https://api.deepseek.com/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $DEEPSEEK_API_KEY" \ -d '{ "model": "deepseek-chat", "messages": [{"role": "user", "content": "用一句话说明双链笔记和文件夹分类的区别"}], "temperature": 0.3, "max_tokens": 200 }'

返回里如果能看到choices[0].message.content,说明 Key、地址、模型名三样都对。这一步最容易被跳过,然后花两小时在插件里排查一个其实是 Key 复制多了空格的问题。我吃过这个亏——从网页复制 Key 时带了个换行,插件报的是笼统的"请求失败",用 curl 一跑立刻看到 401。

这一档的天花板很明显:插件不知道你的库结构,只能靠你手动把相关笔记贴进上下文。作为过渡方案够用,但别指望它把知识库盘活。

3.2 第二档:本地网关 + 插件,把密钥和日志集中管

第二档是在第一档基础上加一层本地网关,插件里的 Base URL 改成http://127.0.0.1:8000/v1。改动很小,收益是密钥从插件配置里挪走了,而且你能看到每一条请求的完整输入输出。

日志这块我建议落成按天切分的 JSONL,一行一条,字段至少包含时间、模型、输入 token 数、输出 token 数、耗时、请求哈希。有了这张表,你才能回答"这个月钱花在哪了"这种问题。我第一个月的日志跑出来才发现,七成的调用量集中在三个高频提示词上,直接给这三个加了缓存,账单立刻下来了。

网关还有个隐性好处:可以做输入预处理。比如把 Obsidian 的![[嵌入笔记]]语法展开成实际内容,把==高亮==转成普通文本,把 frontmatter 剥离出来单独传给模型。这些处理放在网关里做一次,所有插件都受益;放在每个插件里做,就是重复劳动。

启动方式我用 systemd 用户服务,或者简单点用nohup挂后台。关键是加个健康检查,插件配置里通常有个"测试连接",指向网关的一个/health端点,返回 200 就说明进程活着。

3.3 第三档:Python 自建索引 + 检索 + 生成回写

想真正把旧笔记盘活,绕不开自建索引。这一档的工作量大概两三个晚上,但效果和前面两档不是一个量级——模型能看到的是"从你全库里检索出来的最相关片段",而不是"你手动贴进去的那一段"。

整体流程是四步:遍历 Markdown 文件、按标题树切块、给每块建索引、查询时召回 Top-K 拼进提示词。索引我用了两套并行,一套是关键词倒排(BM25),一套是本地嵌入向量。为什么两套都要,第 4 节细说。

先看切块。这一步的代码不长,但决定了后面所有效果的上限:

import re from pathlib import Path HEAD = re.compile(r"^(#{1,6})\s+(.*)$") def split_by_heading(text, max_chars=900): lines = text.splitlines() blocks, cur_head, buf = [], [], [] for ln in lines: m = HEAD.match(ln) if m: if buf: blocks.append((cur_head, "\n".join(buf).strip())) level = len(m.group(1)) cur_head = cur_head[:level-1] + [m.group(2).strip()] buf = [] else: buf.append(ln) if buf: blocks.append((cur_head, "\n".join(buf).strip())) out = [] for head, body in blocks: if len(body) <= max_chars: out.append({"heading": " > ".join(head), "text": body}) continue # 超长块按句号/换行二次切分,带 120 字重叠 sents, cur = re.split(r"(?<=[。!?\n])", body), "" for s in sents: if len(cur) + len(s) > max_chars and cur: out.append({"heading": " > ".join(head), "text": cur}) cur = cur[-120:] + s else: cur += s if cur.strip(): out.append({"heading": " > ".join(head), "text": cur}) return out

两个参数值得掰开说。max_chars=900是我试出来的经验值,太小(比如 300)会把一个完整论证切碎,检索到的片段缺少上下文,模型答得断断续续;太大(比如 2000)会让单块信息密度下降,向量表示被稀释,相似度打分反而不准。900 字大概对应 500 到 900 token,一次召回五块刚好压在两千到四千 token,是成本和效果的平衡点。120字的重叠是为了防止关键句正好落在切口上,代价是索引体积增加约 15%。

索引结构我用 SQLite 存,一张表存片段元信息,一张表存倒排,向量部分如果不想装重型依赖,可以直接存 numpy 的 float32 二进制,查询时全量算余弦。几千个片段的全量计算在本地也就几十毫秒,够用。等库涨到几万片段再考虑上专门的向量索引,那时候你早就摸清自己的使用模式了。

4. 让模型真正"读得懂你的库":切片、召回与重排

4.1 按标题树切块,别按字数硬切

前面给了按标题切的代码,这里说为什么这么切。Markdown 的标题层级本身就是作者做的语义分层,一个###下面通常是一个完整的小论点。按固定字数硬切的问题在于,它会无视这个结构,把两个不相干的论点粘在一起,或者把一个论点劈成两半。我做过对比:同一个问题,按字数切的召回片段里有三成是"上下文残缺"的,按标题切的只有不到一成。

标题路径还有个额外价值——它可以作为片段的天然标签。每块的heading字段存成"父标题 > 子标题"的形式,查询时如果问题里出现了和某个标题高度重合的词,可以直接给这块加权。比如你问"卡片笔记的原子化原则",标题里带"卡片笔记"的块先加 20% 分,很多时候比向量相似度还灵。

另一个细节是代码块和引用块的处理。技术笔记里的代码块往往很长,直接切会把语法切断,检索到了也没法用。我的做法是代码块整体作为一块,超过两千字的单独截断并在 metadata 里标记truncated: true,生成时明确告诉模型"这段代码被截断了"。这个标记很关键,不然模型会对着半截代码煞有介事地分析,得出完全错误的结论。

4.2 召回阶段的三路合并:向量、关键词、双链邻居

只用向量召回有个经典短板:专有名词。你库里自造的项目代号、缩写、人名,嵌入模型没见过,向量表示很飘。只用关键词召回则相反,同义表达完全失效——你搜"成本控制",文档里写的是"预算约束",一个字都对不上。

所以我做三路召回再合并。第一路向量,取 Top 20;第二路 BM25 关键词,取 Top 20;第三路是双链邻居,从命中的片段所属笔记出发,找出与它直接相连的一层笔记里的片段,取 Top 10。三路各自打分后做归一化,用加权求和合并,权重我用的是向量 0.5、关键词 0.35、双链 0.15。

第三路的权重看着低,实际贡献不小。知识库里最珍贵的往往不是某一段写得多好,而是几篇笔记之间的关联。你问"上次那个方案的验收标准是什么",向量和关键词都可能召回不准,但如果你记得它和"项目复盘"那篇有关,双链邻居就能把它带出来。

合并后的候选集大概三十到五十块,直接全塞给模型太多,需要一次重排。重排我用的是一个简单办法:把候选块和问题一起喂给模型,让它只输出相关性打分,不输出内容。这一步成本不高(每块输入很短),但能把明显不相关的剔掉。如果你不想多花一次调用,用交叉编码器类的本地小模型做重排也行,本地跑没有额外费用,代价是首次加载慢一点。

4.3 引用必须可点,否则等于没有

生成答案时我强制要求模型给每个论断标注来源,格式是[[文件名#标题]]。这带来两个直接好处:一是你点一下就能跳到原文核对,二是能大幅压住幻觉。我实测过,加了引用要求之后,"编造一个不存在的笔记名"的情况从偶发变成基本没有,因为模型在生成引用时会先"回忆"一下上下文里到底有没有这个文件。

实现上不复杂,在提示词里明确给出上下文片段的编号和来源路径,要求引用时使用编号对应的路径。生成完做一次后处理,用正则提取所有[[...]],逐个检查文件是否存在,不存在的当场标红或者直接删掉该句。这个校验环节我一开始嫌麻烦跳过了,结果有一次整理出一篇"综述",里面引用了三篇我根本没写过的笔记,差点当真。

注意:引用校验要区分"笔记不存在"和"标题层级不存在"两种。前者多半是幻觉,后者往往是我自己改了标题没更新索引,属于索引过期,重新跑一次增量索引就好。

5. 提示词模板与结果落盘:把答案留在笔记里

5.1 三种我每天都在用的模板

模板这件事,写得太花哨没用,关键是稳定输出结构化结果,方便程序解析。我常用的三个模板,覆盖了八成的日常需求。

概念卡片模板,用于把一个模糊的概念整理成可复用的卡片。输入是召回的若干片段,输出固定四段:一句话定义、三个关键特征、一个反例、相关笔记链接。反例这一段特别有价值,因为它逼着模型区分"是什么"和"不是什么",很多模糊概念在这一步才被真正想清楚。

对比模板,用于两个概念的横向比较。输出固定为一个 Markdown 表格,行是维度,列是两个概念。难度在于维度要模型自己找,所以提示词里要加一句"维度必须从材料中归纳,材料未提及的维度标注为'材料未覆盖'"。不加这句,模型会热心地把教科书上的通用维度全列一遍,看着很全,实际跟你的笔记毫无关系。

周汇总模板,用于把一周内新建或修改的笔记压缩成摘要。这个模板的关键是输入不是召回结果,而是按文件修改时间筛选出的文件列表加正文。输出要求按主题聚类而不是按日期罗列,因为按日期罗列等于把日记重抄一遍。

三个模板的共同点是:输出格式固定、字段名固定、不允许自由发挥。这一点跟"让 AI 更有创造力"的直觉相反,但在知识库里恰恰需要这种克制——你要的是可检索、可对比、可批量处理的结构化内容,不是散文。

5.2 结构化输出与自动回写 frontmatter

生成的东西要落盘,落盘就要考虑两件事:写到哪个目录、带什么元信息。我的规则是生成内容一律写到90_AI生成/下,文件名格式为主题-YYYYMMDD.md,frontmatter 固定四个字段:source(使用的召回片段来源列表)、model(模型名)、created(生成时间)、reviewed(是否人工复核,默认 false)。

这个reviewed字段是我后来加的,加完才意识到它有多重要。AI 生成的内容和人工写作的内容混在一起,时间长了你自己都分不清哪句是自己想通的、哪句是模型顺嘴说的。有个显式标记,复习的时候知道自己该重点看哪些。批量处理也方便,一条查询筛出所有reviewed: false的文件,逐个过一遍。

回写用 Python 直接操作文件系统,不动 Obsidian 的界面,这样即使 Obsidian 没开也能跑。唯一要注意的是写完给个短延迟再让 Obsidian 索引,或者干脆在 Obsidian 里装个自动刷新插件。我遇到过写完立刻去搜搜不到的情况,原因是文件系统事件还没被 Obsidian 捕获,等两秒就好。

from pathlib import Path from datetime import datetime def write_note(title, body, sources, model): p = Path("90_AI生成") / f"{title}-{datetime.now():%Y%m%d}.md" p.parent.mkdir(exist_ok=True) fm = ["---", f"source: {sources}", f"model: {model}", f"created: {datetime.now().isoformat(timespec='seconds')}", "reviewed: false", "ai_generated: true", "---", ""] p.write_text("\n".join(fm) + body, encoding="utf-8") return p

6. 完整排查链路:五个把我卡住的报错

6.1 401 与 429:先分清是 Key 问题还是节奏问题

401 基本只有三种原因:Key 错了、Key 过期了、请求头格式不对。排查顺序我固定为:先用 curl 测原始 Key,再用网关测,最后用插件测。三级都失败说明 Key 本身有问题;curl 通、网关不通,多半是网关没读到环境变量(systemctl启动的服务读不到你 shell 里的export,这是经典坑,我为此查了四十分钟);网关通、插件不通,去看插件是不是自作主张加了/v1前缀,导致路径变成/v1/v1/chat/completions。

429 是限速。这里有个反直觉的点:批量跑脚本时最容易触发 429 的往往不是生成请求,而是重排请求——因为重排的调用次数是生成的好几倍。我的处理是给所有请求加指数退避,初始 1 秒,每次翻倍,最多重试 4 次。同时在网关层加一个简单的令牌桶,把并发压到 2 到 3。压并发之后总耗时反而短了,因为不再有大量请求撞墙重试。

6.2 上下文超限:中文 token 估算为什么会翻车

我最初按"一个汉字一个 token"估算,结果超限报错。中文在不同分词器下的 token 数差别很大,常见范围是 0.6 到 1.5 之间,取决于文本里英文、数字、标点的比例。技术笔记里代码和英文术语多,token 密度反而可能更高。

稳妥的做法是别自己估,直接用返回里的usage字段。每次调用的响应都会带你实际消耗的输入输出 token 数,把它记进日志,跑上几十次你就有自己库的实测系数了。我的库实测下来,中文正文的系数大约 0.75,含大量代码的片段能到 1.2。有了这个系数,切块大小和召回数量就能算准。

还有一层:多轮对话会把历史一起带上。插件里聊到第十轮,前面九轮的内容都在上下文里,很容易爆。我的处理是超过六轮就主动截断最早的对话,或者在网关层做滑动窗口。

6.3 同步冲突与编码乱码

索引文件放在库目录里,同步工具会把它当普通文件同步,两个设备同时跑索引就会产生冲突副本。我的解法是把_ai_index/加进同步工具的排除列表,索引本来就是派生产物,哪台设备需要哪台自己建。

乱码问题出在 Windows 和 Unix 混用的时候。Python 读写文件不显式指定encoding="utf-8",在部分环境下会用系统默认编码,中文直接变问号。我现在的规矩是所有open、read_text、write_text全部显式带encoding="utf-8",一个不漏。BOM 头也要注意,某些编辑器保存的文件带 BOM,解析 frontmatter 时会多出一个不可见字符,导致---匹配失败。

6.4 幻觉引用:模型编了一个不存在的文件名

这个坑前面提过,这里补完整排查链路。第一次发现是整理出的综述里引用了[[项目验收清单]],我印象里没这个文件,搜了一下确实没有。追查下去发现,召回片段里有一篇笔记提到了"验收清单"这个词,但那是正文里的普通词组,不是双链。模型看到这个词,顺手给它套了个双链格式。

修复方案分两层。提示词层面明确写"只能引用上下文中明确标注了来源路径的片段,不得自行构造链接"。后处理层面做正则提取加文件存在性校验,不存在的链接降级为普通文本并记一条警告日志。两条一起上之后,同类问题基本消失。

还有个变种是"引用对了文件名但标题层级错了"。这个不怪模型,是我改了标题却没重建索引。解法是把文件修改时间也存进索引,查询时如果发现某文件的 mtime 比索引时间新,提醒重建。这条提示我加在了脚本输出里。

6.5 结果质量突然下降

有段时间生成的摘要明显变差,内容空洞。排查了半天发现是切片参数被我自己改过——为了省 token 把max_chars从 900 调到了 400,片段太碎,召回的都是零散句子,模型拿不到完整论证,只能自己圆。教训是:参数改动要记录。我现在在脚本顶部放一个PARAMS字典,每次改动写一行注释说明日期和原因,出问题先回头看这个。

7. 成本、速度与长期维护:把账算在明处

7.1 token 预算表与缓存策略

先给一张我自己在用的预算表,按每周整理一次的频率估算,不同库规模可以按比例缩放。

操作单次输入 token单次输出 token频次备注
单笔记摘要1500300每周 20 篇可缓存,内容不变不重算
跨笔记问答3500600每天 5 次不适合缓存
概念卡片生成2500500每周 10 次可缓存
重排打分800 × 30 块50每次问答成本大头,值得优化

重排那一行是很多人忽略的成本黑洞。三十块候选,每块单独打一次分,输入加起来两万多 token,比生成本身还贵。我的优化是把候选块两两拼在一起打分,一次评两块,调用次数减半;再进一步,先按向量分数砍掉后 50%,只对前十五块做重排。这两刀下去,重排成本降到原来的三分之一,效果几乎没变化。

缓存策略要分层。完全确定性的请求(同样的提示词、同样的输入、temperature=0)可以长期缓存;带随机性的生成缓存一天就够;涉及时间的查询("最近一周")不要缓存。我在网关里给缓存加了ttl字段,按请求类型分别设。

7.2 增量索引与库变大后的性能拐点

全量重建索引在库小的时候毫无感觉,几千个文件也就几十秒。但每次改一篇笔记就全量重建,纯属浪费。我的做法是增量:遍历时比对文件的 mtime 和已存索引里的 mtime,只处理变化的文件,同时删掉已经不存在文件的索引记录。

这里有个容易漏的点:改一个文件的标题,会影响它的所有双链邻居的元信息。所以增量更新要额外处理"被引用的文件",把它们也标记为重算。我第一版漏了这一步,导致改完标题后双链邻居指向的还是旧路径,跳转全是死链。

性能拐点大概出现在两个位置。一个是片段数量过万,SQLite 的倒排查询还能扛,但全量向量计算的延迟开始明显(超过 200 毫秒),这时候该上近似的向量索引了。另一个是库文件超过五千个,文件系统遍历本身的耗时开始占大头,这时候要做目录级的分批处理,别一次全塞进内存。

最后说个维护习惯。我在索引目录里放了一个manifest.json,记录上次索引时间、处理的文件数、片段总数、使用的切块参数和嵌入模型名。换设备、隔几个月回来接着用的时候,看一眼这个文件就知道当前状态,不用猜。这个习惯是从踩坑里长出来的——有一次隔了两个月重启,忘了当初用的是哪版切块参数,重建之后检索效果完全不一样,找原因找了一晚上。

这套东西跑到现在,最实际的收益不是"AI 帮我写了多少内容",而是那些躺在库里两三年的旧笔记重新被调用的频率明显上来了。检索做对了,模型不需要多聪明,把对的东西摆到它面前,答案自然就准了。

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

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

立即咨询