不用云做 RAG:一个本地笔记问答工具的取舍与坑

项目:lorebox(MIT)|https://atomgit.com/NJPJI_JK/lorebox

技术栈:Python 3.9+ / Ollama / chromadb

现状:9 个命令、7 个核心模块、22 个测试文件共 92 个用例


起因

我攒了一批课程资料和笔记,想随时查,但不愿意把它们上传到任何第三方服务。市面上的 RAG 示例大多假设你有付费 API Key 和云端向量库,于是我写了这个离线版本:笔记放本地,嵌入和生成走本机模型,向量索引存在本机目录里,数据不出机器。

代码量不大,但做的过程中有几次判断失误、几个踩了才知道的坑。这篇把取舍和坑记下来,比单纯贴架构图有用。


整体结构

七个模块,都很薄:

复制代码
cli.py            命令行入口:add / ask / search / list / remove / export / import / stats / doctor
  ├─ chunker.py   把文档切成片段
  ├─ embed.py     选嵌入后端(ollama / onnx)
  ├─ ollama.py    本地 Ollama 的 HTTP 客户端
  ├─ store.py     向量索引(chromadb)
  ├─ retrieve.py  问题 → 相关片段
  └─ ask.py       片段 + 问题 → 提示词 → 答案

一条硬规则:除 cli.py 外,模块之间不互相依赖。这样每个模块都能单独测,不需要起模型服务。


取舍一:为什么是 chromadb

自己写向量检索不难------一段余弦相似度加一个文件存储,两百行能写完。但我选了 chromadb,理由是它替我做对了三件我不擅长的事:持久化、元数据过滤、增量写入。

代价是接受了它的脾气。有两件事花了不少时间:

遥测开关已经失效了。 我在 chromadb 1.5.9 的源码里搜 anonymized_telemetry,只有一处定义、没有任何地方读它。也就是说环境变量和 Settings(anonymized_telemetry=False) 都是摆设。这个字段还在,但不会生效。

ONNX 模型是首次使用时下载的。 chromadb 自带的嵌入函数跑的是 all-MiniLM-L6-v2 的 ONNX 版本,第一次调用会下载 79 MB。我从 AWS S3 拉,速度在 20--70 KiB/s 之间波动,全程将近 50 分钟。这件事本身不致命,但它意味着「完全离线」这个说法在第一次运行时是不成立的------文档里我现在写的是「首次使用需要联网下载模型,之后才真正离线」,不敢再含糊过去。


取舍二:怎么切块

按段落切,不按固定字符窗口切。笔记是人按段落写的,段落边界通常就是语义边界。

累积逻辑大概是:

python 复制代码
for p in paragraphs:
    if size + len(p) > max_chars and buf:
        base.append("\n\n".join(buf))
        buf, size = [], 0
    buf.append(p)
    size += len(p)

看起来没问题,但它有个 bug:buf 为空时,无论 p 多长都会直接塞进去 。于是一个 2500 字的段落配 max_chars=1000,出来的还是一个 2500 字的块------可能超出嵌入模型的上下文长度,而且不报错,只是检索质量悄悄变差。

复现只要一行:

python 复制代码
from lorebox.chunker import chunk_text
print([len(c) for c in chunk_text("x" * 2500, max_chars=1000)])
# 修之前:[2500]
# 修之后:[1000, 1000, 500]

修法是先按换行把超长段落切碎,再走原来的累积逻辑;优先在换行处切,切不动才硬切,尽量不从句子里劈。

另外提一句 --overlap:按段落边界切,一个想法仍可能被拆到两个块里,重叠参数会把上一块的尾部拼到当前块前面。默认关闭------开着索引体积会涨,先让默认行为可预期。


取舍三:双嵌入后端,以及 384 vs 768

这是最近加的东西。原本不装 Ollama 就什么也干不了,而大多数人只是想试试「本地笔记能不能查」,却要先下几个 GB 的模型。这个门槛劝退的人比功能缺失更多。

所以把嵌入抽象成了两个后端:

后端 模型 维度 需要模型服务
ollama(默认) nomic-embed-text 768 要
onnx all-MiniLM-L6-v2(chromadb 自带) 384 不要

配合新加的 search 命令------只检索、不调用语言模型------就出现了一条完全不需要 Ollama 的路径:

bash 复制代码
lorebox add ./notes --embed-backend onnx
lorebox search "笔记里写过 gtk 吗"

这里最容易踩的坑是维度。 384 和 768 不能放进同一个 collection,chromadb 会在很深的地方报一个和后端毫无关系的错。所以 store 在建索引时把后端写进 collection 的 metadata,之后用另一个后端访问就直接拒绝:

复制代码
Error: this index was built with the 'onnx' embedding backend, but 'ollama' was
requested. The two produce vectors of different width and cannot share a
collection. Use a different --store, or delete .lorebox and index the files again.

对早期建的、没有这个字段的索引,按 ollama 处理------那时只有这一个后端,这么推断是安全的。


取舍四:批量嵌入

原来每个片段发一次 HTTP 请求。一份切成 200 块的笔记就是 200 次往返。本地服务虽然不走网络,但每次请求的序列化和调度开销累加起来,索引大文档要等很久。

改成优先走 /api/embed 一次提交整批,旧版本 Ollama 没有这个端点就退回逐条:

python 复制代码
try:
    data = _post("/api/embed", {"model": model, "input": texts}, host=host)
    vectors = data.get("embeddings")
    if isinstance(vectors, list) and len(vectors) == len(texts):
        return vectors
except OllamaError:
    pass
# 回退:逐条 /api/embeddings

重点是那个 len(vectors) == len(texts) 判断。返回条数不对时必须回退------这种情况不抛异常,只会让向量错位挂到别的片段上,检索结果变差但没有任何提示。宁可多花几次请求,也不能要一个安静的错误。


一个让工具完全跑不起来的 bug

这个值得单独说,因为它是「测试覆盖率看着不错、但核心路径从没被执行」的典型。

cli.py 里有个命令函数叫 list:

python 复制代码
@main.command()
def list(store_path):
    ...

被 @main.command() 装饰之后,模块级的 list 就被替换成了 click 的命令对象 。而 add 里有一句:

python 复制代码
chunks = list(chunker.chunk_text(text, overlap=overlap))

运行时这个名字已经是命令对象了。click 的 Command 是可调用的(会转去解析参数),于是切好的文本被当成命令行参数:

复制代码
Error: Got unexpected extra argument (hello\n\nworld)

也就是说 add 这个核心命令完全不可用。 而这个状态存在了好几天,13 个测试全绿------因为没有任何一个测试真正执行过 add。测的都是 chunk_text、Store、ask 这些被 add 调用的零件,没人跑过拼装起来的东西。

连带后果更有意思:增量索引功能(重复添加同一文件时跳过)此前从未被端到端跑通过------崩溃点在切分那一步,早于增量判断。

修法很简单:命令函数改名 list_cmd,用 name="list" 显式注册,命令行用法不变。但真正的教训是加法:补一个真正执行 add 的测试。

python 复制代码
def test_indexes_a_file(self):
    src, target = _note()
    with mock.patch("lorebox.cli.embed.embed", return_value=[[0.0, 1.0]]):
        result = CliRunner().invoke(add, [src, "--store", target])
    self.assertEqual(result.exit_code, 0, result.output)
    self.assertEqual(store.Store(target).list(), [(src, "hello world")])

同类问题后来又在 ask 命令上出现过一次(导入的 ask 模块被同名的命令函数覆盖),也是靠别名导入修的。命令函数不要跟模块重名,这条现在写在 CONTRIBUTING 里。


现状与不足

测试从 13 个涨到 108 个,命令从 2 个涨到 9 个......

还没做的(写在 README 的「当前限制」里):

  • 增量索引以文件为单位......
  • 检索已经加了相似度阈值和 MMR 去重(--min-score,先取 3 倍候选再挑),
    但仍然只有向量相似度,没有关键词检索或混合召回......
  • 只读 Markdown / 纯文本 / RST / PDF......

决定不做的(写在 README 的「设计边界」里):

  • ask 需要本地 LLM。 有人问过能不能让它也免模型服务。答案是不能------
    生成答案本来就离不开模型......
  • 没有云端兜底。 本地模型不可用时不会自动转到云端。

想试试的话

bash 复制代码
pip install "lorebox[local-embed]"   # 带上本地嵌入后端

lorebox add ./notes --embed-backend onnx
lorebox search "笔记里写过什么"
lorebox list --files

不做生成的话,连 Ollama 都不用装。想用 ask 自然语言问答,再装上 Ollama 并 ollama pull qwen2.5。

包在 https://pypi.org/project/lorebox/,源码在 https://atomgit.com/NJPJI_JK/lorebox,MIT 协议。有 issue 或 PR 都欢迎------CONTRIBUTING.md 里列了几个适合上手的切入点。


如果你也在做本地优先的工具,欢迎聊聊你踩过的坑。

相关推荐
m0_682906531 小时前
工作台后端编排链路自检 10-17 11:45:56
ai·大模型·seo·geo·结构化数据·indexnow
cspttty1 小时前
2027风险管理岗准备路线:数学与应用数学如何补齐金融、SQL与Python
开发语言·python·金融
用户8356290780511 小时前
如何使用 Python 格式化 Excel 单元格中的长文本
后端·python
我命由我123451 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法
.Bryce.1 小时前
从强化学习到Agentic RL:让Agent在“干活“中自己变聪明
服务器·网络·agent·rag·agentic rl
TTATTC1 小时前
单体应用如何支撑千所机构独立测评?拆解 sg-exam 行级多租户隔离与动态组卷内核
docker·开源
Wynnhao1 小时前
交付时说“测过了”不够:我做了个可复现的验收证据包,已开源
开源
TTATTC1 小时前
每加一个登录端都要改 Controller?Spring Boot 泛型策略工厂与模板切面演进
docker·开源
严谨的麻辣烫2 小时前
Python HTTP 连接池怎么用?从连接复用到请求异常排查
开发语言·python·http·requests·网络排查