项目: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 里列了几个适合上手的切入点。
如果你也在做本地优先的工具,欢迎聊聊你踩过的坑。