1598 字
8 分钟
小面包文件助手:给本地文件一个会思考的搜索引擎

为什么叫「小面包」?#

先说名字。给项目起名这件事,我倾向于食物系——小面包,小巧、松软、随取随用,掀开包装就能吃。这个工具也想做到这样:双击打开,把文件夹拖进来,剩下的它自己搞定。

它的学名叫 FindWise,但平时我还是叫它小面包。🥯

它解决什么问题?#

电脑里散落着几百份 PDF 讲义、DOCX 报告、XLSX 表格。Windows 自带搜索只能按文件名找,可我记忆里的线索往往是内容:「上次那份讲了部署流程的文档」「提到 RRF 的那篇笔记」——文件名里根本没有这些词。

小面包文件助手做的事:

  1. 添加文件夹,自动解析索引 .pdf / .docx / .xlsx / .txt / .md
  2. 关键词搜索——中英文即时命中,精确匹配型号、编号、术语
  3. 语义搜索——「讲部署流程的」也能找到,哪怕原文写的是「发布上线步骤」
  4. 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 和业务彻底分家#

整个项目最花心思的不是某个算法,而是分层纪律:

层模块职责
UIui/PySide6 三栏界面:知识库 / 结果 / 详情
任务tasks/QThreadPool 后台线程 + 信号回 UI
服务services/索引、搜索、问答的用例编排
检索search/关键词召回、向量召回、RRF
索引indexing/扫描、分块、增量写入
数据db/SQLite 连接、迁移、SQL 仓储

硬性规则:业务层禁止 import PySide6。所以 core/db/parsing/indexing/search/services 这些层在无 GUI 环境下可以完整跑单测;可替换组件(解析器、嵌入模型、向量库、LLM)全部走 Protocol 接口,FAISS 装不上就换 NumPy 实现,嵌入模型不在就关掉向量路——失败一律降级,绝不崩溃。

遇到的坑#

  1. FTS5 的 unicode61 不切中文。SQLite 全文检索对中文无效,必须先用 jieba 预分词、存成空格分隔的 token 再入库——这是中文搜索能工作的前提,也是最容易踩的第一坑。

  2. faiss-cpu 在 Windows + Python 3.13 的轮子。查资料时看到很多旧帖子说装不上,实际 2026 年的今天官方轮子已经有了,pip install faiss-cpu 直接成功。老经验会过期,动手验证最可靠。

  3. 嵌入模型的首载问题。sentence-transformers 第一次要从 HuggingFace 下载模型,国内直连超时。走 hf-mirror 镜像 + 模型缓存到 D 盘后,缓存命中时完全离线加载,实测 14 秒可用。

  4. GBK 编码的老 txt。UTF-8 打开失败要自动回退 GBK,否则九十年代导出的文本文件会让索引线程报错。

质量保障#

  • 127 个 pytest 用例:分块边界、中英混合分词、PDF/DOCX/XLSX/GBK 解析、FTS 命中、RRF 排序、向量库增删查、Worker 线程信号、端到端链路
  • 无头 UI 冒烟(QT_QPA_PLATFORM=offscreen),CI 友好
  • 真实文件端到端验证:PDF 逐页定位、中英文检索、RAG 引用锚点跳转

技术栈#

组件技术
UIPySide6(三栏布局,深浅主题)
全文检索SQLite FTS5 + jieba
向量检索sentence-transformers + FAISS(可回退 NumPy)
融合排序RRF(纯函数实现)
文档解析PyMuPDF / python-docx / openpyxl
LLMOpenAI 兼容协议(SSE 流式,Ollama 实测)
打包PyInstaller(exe 旁绿色 data/ 目录,不写 C 盘)

做这个项目最大的收获:桌面工具的「好用」一半在检索质量,另一半在失败时的表现。FAISS 缺席、模型未配、文件损坏、编码诡异——每一种「不完美环境」都有确定的降级路径,用户永远能看到东西在正常工作,这比多堆一个花哨功能有价值得多。

小面包文件助手:给本地文件一个会思考的搜索引擎
https://theywh.me/posts/xiaomianbao-file-assistant/
作者
鸢尾花
发布于
2026-10-24
许可协议
CC BY-NC-SA 4.0