为什么叫「小面包」?
先说名字。给项目起名这件事,我倾向于食物系——小面包,小巧、松软、随取随用,掀开包装就能吃。这个工具也想做到这样:双击打开,把文件夹拖进来,剩下的它自己搞定。
它的学名叫 FindWise,但平时我还是叫它小面包。🥯
它解决什么问题?
电脑里散落着几百份 PDF 讲义、DOCX 报告、XLSX 表格。Windows 自带搜索只能按文件名找,可我记忆里的线索往往是内容:「上次那份讲了部署流程的文档」「提到 RRF 的那篇笔记」——文件名里根本没有这些词。
小面包文件助手做的事:
- 添加文件夹,自动解析索引
.pdf / .docx / .xlsx / .txt / .md - 关键词搜索——中英文即时命中,精确匹配型号、编号、术语
- 语义搜索——「讲部署流程的」也能找到,哪怕原文写的是「发布上线步骤」
- AI 问答——直接提问,答案基于你自己的文档生成,附引用来源,点击可跳回 PDF 原页
全部在本地运行,一个字节都不出电脑。
双路召回 + RRF 融合
这是整个项目最核心的设计。关键词和语义两种检索各有盲区,所以两路都做,再融合:
查询文本 ├─► jieba 分词 → SQLite FTS5 MATCH(bm25 排序)→ 关键词命中列表 ├─► sentence-transformers 嵌入 → FAISS 内积检索 → 语义命中列表 └─► RRF 融合两路排名 → top_k 结果(附片段预览、页码、双路得分)融合用的是 RRF(Reciprocal Rank Fusion)——不看分数只看排名,天然免疫两路分数量纲不一致的问题:
def rrf(rankings, k=60, weights=None): """score = Σ w_i / (k + rank_i + 1),k=60 是公认稳健的默认值。""" if weights is None: weights = [1.0] * len(rankings) scores = {} for ranking, weight in zip(rankings, weights): for rank, chunk_id in enumerate(ranking): scores[chunk_id] = scores.get(chunk_id, 0.0) + weight / (k + rank + 1) return scores实际配置里关键词路权重 1.0、向量路 0.8——查编号时关键词说了算,查概念时语义来补位。整个函数是纯函数,单测写起来非常舒服。
中文分块:段落优先,滑窗兜底
RAG 的效果一半取决于分块。小面包的策略是段落优先——按空行切段落,攒到 500 字左右成块;遇到超长段落再用带 60 字重叠的滑动窗口硬切,保证语义不在边界处断掉:
paragraphs = [p.strip() for p in _PARA_SPLIT.split(text) if p.strip()]for para in paragraphs: if len(para) > chunk_size: chunks.extend(_hard_split(para, chunk_size, overlap)) # 滑窗硬切 elif buf_len + len(para) <= chunk_size: buf.append(para) # 攒段落 else: chunks.append("\n".join(buf)) # 满了就出块 buf = [para]每个 chunk 记住自己来自哪个文档、第几页——这直接决定了 AI 回答里「引用锚点」能不能一键跳回 PDF 原文位置。
增量索引:只碰变化的文件
每次扫描把磁盘上的 size/mtime 和库里记录的对比:新增和变更的重建,消失的连带 chunks、FTS 索引、向量一起清掉。解析失败的文件标记 status=error,不会中断整批索引,下次运行自动重试,最后弹一份失败清单。
RAG 问答:接谁的模型都行
问答链路就是标准 RAG:检索 top_n → 拼装编号上下文(标题/页码/摘录)→ 丢给 LLM,流式逐字输出到右侧面板,答完附来源列表。
LLM 走 OpenAI 兼容协议,所以 Ollama、LM Studio、任何云服务都能接——改一下 settings.json 里的 base_url 和 model 就行。我本地用 Ollama 跑 qwen2.5:7b,完全离线。默认还有个 Dummy 适配器,不配任何模型也能把整条链路跑通,适合第一次上手验证。
架构:UI 和业务彻底分家
整个项目最花心思的不是某个算法,而是分层纪律:
| 层 | 模块 | 职责 |
|---|---|---|
| UI | ui/ | PySide6 三栏界面:知识库 / 结果 / 详情 |
| 任务 | tasks/ | QThreadPool 后台线程 + 信号回 UI |
| 服务 | services/ | 索引、搜索、问答的用例编排 |
| 检索 | search/ | 关键词召回、向量召回、RRF |
| 索引 | indexing/ | 扫描、分块、增量写入 |
| 数据 | db/ | SQLite 连接、迁移、SQL 仓储 |
硬性规则:业务层禁止 import PySide6。所以 core/db/parsing/indexing/search/services 这些层在无 GUI 环境下可以完整跑单测;可替换组件(解析器、嵌入模型、向量库、LLM)全部走 Protocol 接口,FAISS 装不上就换 NumPy 实现,嵌入模型不在就关掉向量路——失败一律降级,绝不崩溃。
遇到的坑
-
FTS5 的 unicode61 不切中文。SQLite 全文检索对中文无效,必须先用 jieba 预分词、存成空格分隔的 token 再入库——这是中文搜索能工作的前提,也是最容易踩的第一坑。
-
faiss-cpu 在 Windows + Python 3.13 的轮子。查资料时看到很多旧帖子说装不上,实际 2026 年的今天官方轮子已经有了,
pip install faiss-cpu直接成功。老经验会过期,动手验证最可靠。 -
嵌入模型的首载问题。sentence-transformers 第一次要从 HuggingFace 下载模型,国内直连超时。走 hf-mirror 镜像 + 模型缓存到 D 盘后,缓存命中时完全离线加载,实测 14 秒可用。
-
GBK 编码的老 txt。UTF-8 打开失败要自动回退 GBK,否则九十年代导出的文本文件会让索引线程报错。
质量保障
- 127 个 pytest 用例:分块边界、中英混合分词、PDF/DOCX/XLSX/GBK 解析、FTS 命中、RRF 排序、向量库增删查、Worker 线程信号、端到端链路
- 无头 UI 冒烟(
QT_QPA_PLATFORM=offscreen),CI 友好 - 真实文件端到端验证:PDF 逐页定位、中英文检索、RAG 引用锚点跳转
技术栈
| 组件 | 技术 |
|---|---|
| UI | PySide6(三栏布局,深浅主题) |
| 全文检索 | SQLite FTS5 + jieba |
| 向量检索 | sentence-transformers + FAISS(可回退 NumPy) |
| 融合排序 | RRF(纯函数实现) |
| 文档解析 | PyMuPDF / python-docx / openpyxl |
| LLM | OpenAI 兼容协议(SSE 流式,Ollama 实测) |
| 打包 | PyInstaller(exe 旁绿色 data/ 目录,不写 C 盘) |
做这个项目最大的收获:桌面工具的「好用」一半在检索质量,另一半在失败时的表现。FAISS 缺席、模型未配、文件损坏、编码诡异——每一种「不完美环境」都有确定的降级路径,用户永远能看到东西在正常工作,这比多堆一个花哨功能有价值得多。