上次发了个动态说要做个开源的 AI 文档阅读器,后台私信和群里直接炸了,天天有人催更。今天总算把代码整理出来,可以讲点干货了。这个项目不花哨,核心就一件事:把 PDF、Word、TXT、Markdown 丢进去,系统自动建索引,然后你直接提问,它基于文档原文给答案,顺带能出摘要和提炼关键点。
我这半个月基本把市面上常见的方案都试了一圈,也踩了不少文档解析的坑,最后落地的这版是纯开源、可本地跑、也能接各大模型 API 的双通道设计。今天不堆功能清单,就把整个项目的设计思路、技术选型、关键参数、部署过程和真实踩坑情况一次性讲清楚。不管你是做知识库、做私有化问答,还是单纯想给自己的技术文档搞个“聪明帮手”,这篇应该都能给你省不少时间。
1. 先说清楚:这个工具到底解决什么问题
1.1 长文档检索的真实痛点
很多人觉得看 PDF 有什么难的?直接打开 PDF 阅读器搜索关键词不就行了。但真到实际工作里就会发现,这套方式根本撑不住。我自己的场景是经常要翻项目验收报告、设备技术手册和跨部门的流程文档,动不动几十上百页。最痛苦的是你自己明确知道某句话就在这份文档里,可你就是找不到它在哪里,关键词搜索出来几十个结果,一个一个点进去看到的全是相似又无关的段落。
还有个更常见的问题:多个文档之间信息交叉。比如一份产品手册里写了接口定义,另一份升级日志里写了接口变更历史,新来的同事想搞清楚某个接口该不该用,得同时打开三个文件来回对照。这种工作本质上是把非结构化的文本,重新组织成信息点之间的关联关系。人肉做这件事效率极低,而这恰恰是大模型加检索增强最能发挥价值的地方。
这个项目就是冲着这两个痛点去的:第一,把文档变成可以语义检索的知识库,而不是只能做字面匹配的静态文件;第二,在问答时把答案和原文出处绑定,用户点开引用就能跳到对应的原始段落,不用再担心大模型凭空编造内容。
1.2 为什么选择免费开源
市面上不是没有类似的工具,有在线版的,也有商业企业版的。但对我这种习惯折腾的人来讲,闭源在线服务的几个问题实在很难接受。文档数据要传到对方服务器上,这是很多公司和工作室完全过不去的坎;按坐席或者按 API 调用量收费,用着用着价格就上去了,自己内部用还好,给团队用就得精打细算。
所以我从一开始就定了调子:项目必须开源,部署方式必须支持纯本地。本地跑的意味是模型权重、向量索引、对话日志这些东西全留在自己的机器上,数据出不了门,这对文档隐私需求强烈的场景几乎是刚需。更进一步,开源可以让每个用户按自己的实际情况替换解析器、更换模型、调整参数,没必要被一个固定方案卡死。
从技术上讲,这个项目的核心其实是四个组件拼起来的:文档解析、文本分块、向量检索、大模型生成。这四个组件现在都有成熟的开源选择,组合起来没什么秘密,真正的门道在每个环节的参数和取舍上。
2. 技术选型:框架、模型、向量库怎么定
2.1 后端选 FastAPI 而不选 Flask 的原因
后端最开始想图省事用 Flask,后来还是换成了 FastAPI。原因很直白:这个项目里有大量 IO 操作,上传文件要读磁盘,解析文档要跑子进程,向量检索要查索引,最后大模型生成答案还要等流式返回。如果后端是同步阻塞模型,一个用户提问的时候服务端线程全部卡在生成响应上,另一个用户上传文档就得排队,体验非常差。
FastAPI 原生支持异步接口,配合 Python 的 async/await 可以把 IO 等待让出来。我在代码里用async def定义上传和问答接口,文档解析这种 CPU 密集型的操作丢给进程池,避免阻塞事件循环。此外 FastAPI 自带 Swagger 文档,调试接口的时候直接在浏览器里点,非常方便。
实际项目里我用的框架版本也很常规,没有引入太重的依赖。fastapi加uvicorn作为 ASGI 服务器,再加上python-multipart处理文件上传,这一套组合稳定可靠。后来要加流式输出,FastAPI 直接支持StreamingResponse,省掉了我在 Flask 里折腾生成器响应的事。
2.2 本地模型与云 API 的双通道设计
大模型这块我做一个抽象层,同一个接口可以切换两种后端。本地模式推荐 Ollama,现在模型生态也成熟了,llama3.1、qwen2.5这些 7B 到 14B 的模型用消费级显卡就能跑出不错的效果,特别是处理中文文档,Qwen 系列的表现很稳。没有 GPU 的机器也可以跑量化版模型,虽然生成速度慢一点,但做文档问答完全够用。
云 API 模式兼容 OpenAI 格式,这样可以直接接入大厂的商业模型,也方便接国内几家兼容 OpenAI 接口的模型服务。双通道的实现方式其实很简单,代码里统一走 openai 客户端库,只需要在配置文件里写不同 base_url 和 model 名称。这样做的好处是开发调试的时候用本地小模型,跑重活或者追求高质量答案的时候切到云端大模型,数据敏感的业务则全程绑定本地。
嵌入模型同样做了双通道。本地用BAAI/bge-m3这种中文效果很好的 embedding 模型,云端可以换成对应的 API 嵌入接口。有一点必须注意:嵌入模型要和检索语料语言匹配,中文文档就别用纯英文优化的嵌入模型,不然召回结果会非常离谱。
2.3 文档解析器与向量库搭配
文档解析这块我分情况处理。PDF 用pdfplumber,它有比较强的表格识别能力,也容易拿到文本坐标。Word 文档用python-docx,把自然段按顺序读出来。TXT 和 Markdown 本身是纯文本,用 UTF-8 读进来,按换行符做初级切分。
选pdfplumber而不是PyPDF2或者pdfminer.six,最直接的考虑是容错率。PyPDF2对于带表单、带多层图层或者压缩异常的 PDF 经常直接抛异常,而pdfplumber对这类边缘情况的处理要宽容很多。缺点也很明显,处理速度相对慢,所以我在解析时加了缓存,同一个 PDF 解析过一次就把文本结果存成 json,下次不用再解析。
向量库最开始考虑过 FAISS,后来改成了 Chroma。原因很现实:FAISS 是索引库,存储和检索细节还得自己处理,要持久化还得配序列化方案。Chroma 是真正的向量数据库,一个client.get_or_create_collection就能搞定增删改查,数据自动落盘,对中小规模的文档库来说省了太多事。它还内置了 metadata 过滤,我可以在收藏时打上文档名称、页码、章节号这些标签,检索时按标签过滤,精准度提高不少。
3. 核心功能拆解与关键参数设计
3.1 文档解析规则怎么写
解析是整个流程的第一环,也是最容易翻车的一环。我的解析规则不追求把所有格式特性都还原出来,而是抓住文档的语义骨架。对 PDF,按页遍历,把每页里的文本块按阅读顺序拼接,遇到表格则把单元格内容抽出来用制表符拼接成行;对 Word,保留标题层级信息,遇到段落直接读 paragraph.text,空段落则跳过去。
这里有个容易忽略的细节:解析出的文本要做清洗。PDF 里经常出现多余换行、全角半角混乱、空格堆叠的情况,这些杂质如果不处理,后面分块时会切出大量没有意义的内容。我写了一个clean_text函数,把连续换行合并成单个换行,把多个空格压缩成一个,统一把全角逗号句号转成半角。这样做损失了一点视觉效果,但对检索和模型生成都有明显帮助。
清洗完的文本按文档为单位保存成结构化 JSON,里面包含文档名、原始页数、文本块列表。这个中间结构很重要,因为后续问答返回引用时,我需要定位每一段文本来自文档的哪一页,没有这个中间结构到后面就无从查起。
3.2 分块策略:少切一句话,答案就偏了
文本分块是 RAG 系统里真正拉开差距的地方,也是最依赖经验和实验的一环。分块太细,语义不完整,检索召回的片段内容支离破碎;分块太粗,大量无关内容混进来,嵌入向量的语义被稀释,命中率和准确率同时下降。
经过几轮实验,最终定下来的策略是:先按文档的标题层级做粗切,也就是遇到一级标题、二级标题就认为是一个语义块的自然边界;然后把粗切出来的段落再按字符窗口二次切分,每块 512 个字符,相邻块之间重叠 64 个字符。重叠这个设计是刻意保留的,因为检索时真正的目标句子可能正好落在两个块的交界处,没有重叠就漏掉了。
这段参数不是凭空拍脑袋定的。512 字符对中文来说大概五百多字,语义完整性比较好,同时不会超出多数嵌入模型的最大输入长度。64 字符的重叠率约 12%,这个比例不会导致重复内容太多而影响索引效率,也能有效覆盖边界句子。如果你处理的文档是英文,字符窗口可以适当放宽到 800 到 1000,英文的信息密度比中文低。
3.3 从向量检索到流式问答的完整链路
用户提问之后,链路是这样的:先把问题用同一个嵌入模型转成向量,在 Chroma 里做余弦相似度检索,取 topK 等于 5 的文本块;然后把问题和这 5 个块拼装到预设的 prompt 里,交给大模型生成答案。
topK 取 5 是个平衡点。取太少容易漏答案,取太多会塞入大量无关内容,不仅增加 token 消耗,还会干扰大模型对重点内容的聚焦。组装 prompt 时,我在每个文本块前面标注来源页码,并告诉模型只依据这些内容回答,不知道的就直接说不知道。这个方法对抑制幻觉很有效,实测下来编造率明显下降。
流式输出是问答体验的决定性因素。大模型生成内容是个字一个字蹦出来的,如果后端等到全部生成完再一次性返回,那 5 到 10 秒的空白等待会让人怀疑服务挂了。我用了 SSE 流式方案,后端把生成的增量内容不断推给前端,前端一行一行显示出来。用户的第一句话在 0.5 秒左右就能看到,阅读体验非常接近日常用聊天软件的感觉。
4. 手工部署:从零到可用的完整过程
4.1 项目结构与环境准备
项目结构不复杂,核心目录划分得非常清楚。入口是main.py,负责启动 FastAPI 应用;loader/目录放文档解析相关代码,按类型拆成 pdf_loader.py、docx_loader.py、text_loader.py;chunker/目录放分块逻辑;retriever/目录放向量库操作;client/目录放编译好的前端静态文件。整个结构不超过十个 Python 文件,方便任何人接手继续改。
环境准备按常规来就好。建议 Python 3.10 以上版本,因为新版语法和类型提示的兼容性更好。装依赖前先建虚拟环境,避免把系统 Python 环境搞乱。我这边的一次性安装命令大概是这样的:
python -m venv venv source venv/bin/activate pip install -r requirements.txtrequirements 里有 fastapi、uvicorn、python-multipart、pdfplumber、python-docx、chromadb、openai,还有嵌入模型需要的 sentence-transformers。安装完以后,如果本地要用 Ollama,就再执行ollama pull qwen2.5:7b和ollama pull bge-m3。
4.2 后端接口的核心实现
后端就两个核心接口。上传接口接收文件后用 loader 解析,再经过分块和向量化,全部写入 Chroma 持久化存储。这部分关键点在于把文档名作为 metadata 存进去,后续检索才能精确到文档。代码逻辑大概长这样:
@app.post("/api/upload") async def upload_document(file: UploadFile = File(...)): content = await load_document(file) chunks = split_text(content) embeddings = embed_texts(chunks) collection.add( ids=[f"{file.filename}_{i}" for i in range(len(chunks))], documents=chunks, metadatas=[{"source": file.filename, "page": page_no} for page_no in pages], embeddings=embeddings ) return {"status": "ok", "chunk_count": len(chunks)}问答接口是流式返回的,先把UserMessage嵌入,去向量库检索,再组装成系统 prompt,然后通过StreamingResponse把大模型的生成内容推给前端。
@app.post("/api/chat") async def chat(request: ChatRequest): query_vector = embed_texts([request.question]) results = collection.query(query_embeddings=query_vector, n_results=5) context = format_context(results) response = await llm.stream_answer(request.question, context) return StreamingResponse(response, media_type="text/event-stream")接口设计上没有做太多封装,保持简单直接。方便你自己按需改,比如加权限校验或者加文档列表接口,都在这个基础上扩展。
4.3 前端界面的轻量方案
前端没有用 Vue、React 这些工程化框架,只写了原生 HTML 加少量 JavaScript。原因很简单:这个项目最核心的是后端能力和模型能力,前端承担的任务只有两个,上传文件和展示对话流。为了这点功能引入一万多个 node_modules 依赖,完全没有必要。
页面左侧是文档管理区域,支持拖拽上传,上传成功后显示文档名和分块数量。右侧是对话窗口,用户输入问题后通过 fetch 发起请求,再读取 SSE 流式结果逐个拼接到屏幕上。每个回答的末尾会带上引用来源和页码,点击引用的数字能高亮对应的原始文本块。这个引用功能在实践里非常重要,它让用户敢信答案,也方便人工复核错漏。
整个前端就一个index.html加一个app.js,启动后用 FastAPI 的StaticFiles挂载访问,不需要额外开一个 Node 服务,也没有跨域问题。这套轻方案维护成本几乎为零,我后面加功能时改起来非常顺手。
4.4 一张订单文档端到端演示
我拿一份真实的设备订单合同来演示。上传之后解析出 127 个文本块,写入向量库花了约 6 秒,这和文档长度、机器性能都有关系。随后在对话区问“这次订单里设备的质保期是多久?如果出现交付逾期,采取的违约责任措施是什么?”
两个问题都涉及不同页码的信息,传统关键词搜索能搜到“质保”或“逾期”,但没法把两个独立条款关联起来回答。这个工具在检索阶段把相关片段都召回了,第 3 页的质保条款和第 8 页的违约责任条款同时出现在 topK 结果里,大模型拼装后给出的是一个完整回答,而不是几段原文的机械拼接。测试文档的场景让我确认:跨页信息整合这件事,就是这类工具最值得做深的方向。
5. 踩坑实录与现场调优
5.1 PDF 解析与表格错乱问题
第一版代码上线测试时,最头疼的就是 PDF。很多 PDF 并不像表面看上去那样是连续的文本流,而是大量文本框、图片和图层堆叠出来的产物。第一版用简单文本提取,结果段落顺序是乱的,上一段还在讲第一章,下一段直接跳到了附录的表格数字。后来改用按坐标排序的读取方式,才把阅读顺序理顺。
表格错乱是另一个大坑。用 pdfplumber 提取表格时,多行单元格的内容会被打散成碎片,拼接到文本后语义不连贯。我的解法是:如果检测到表格结构,就把每一行单元格用竖线连接符拼成一个文本块,然后整体作为一个分块单元处理。这样原始表格结构虽然被拍平,但同一行的数值关系保住了,模型读到一串数字时能理解它们属于同一行。
扫描版 PDF 是永远避不开的痛。完全没思路的扫描件,也就是内部有字但提取不出来,只能先加一层 OCR。目前工程里我预留了 OCR 接口,推荐 PaddleOCR 或者 Tesseract,但默认不启用,因为处理速度非常慢。如果你的业务里扫描件比例很高,建议把 OCR 做成独立的异步任务,用户上传后先返回处理中,完成后再通知结果。
5.2 检索不准和上下文截断的解决办法
初期用 7B 小模型时,回答质量总是差口气。排查后发现根因不在模型,而在检索结果里混入了噪声。比如提问里出现“合同有效期”,向量检索可能把“有效期届满后双方权利义务终止”这种高度相关但语义偏斜的内容也召回了,占用了 topK 名额。
我做了两个改进。一是把 topK 从 3 提升到 5,让更多相关片段进入候选,配合 prompt 里明确说明“只从给定内容中提取答案,忽略无关片段”;二是加入了简单的重排逻辑,用文本与问题的关键词重合度做加权,对向量相似度靠前的进行二次打分。这个做法虽然没有引入重排模型那么高级,但胜在零成本,改进效果立竿见影。
上下文截断问题出在本地模型只有 8K 上下文窗口。如果某个文档块特别长,把 5 个块全塞进 prompt 很容易截断,导致模型根本看不到后续更关键的信息。我的处理方式是在组 prompt 前按字符长度过滤一次,超出长度上限的块自动剔除,宁可少给也不能让文本被硬切。对长文档,还允许前端传 start_page 和 end_page 参数,把检索范围限定在指定页区间。
5.3 性能优化与显存控制
本地模式下最大的性能瓶颈在嵌入计算。bge-m3模型大概需要 2GB 显存,如果同时加载大模型再加 TensorFlow 或 PyTorch 的运行时,显存很容易爆。我做了两点优化:嵌入计算结束之后立刻把模型从显存卸载,需要再计算时重新加载;把文档解析、分块、嵌入写成异步任务队列,处理大文件时不让前端的交互阶段卡住。
如果你的机器只有 8GB 显存,推荐本地模式用qwen2.5:7b-q4_K_M量化版本,显存占用可以压到 6GB 左右。这个量化模型在文档问答场景下质量损失不明显,反而因为生成速度快了很多,整个系统的可用性大幅度提升。要是显存实在紧张,就老老实实切云 API 模式,把本地资源只留在嵌入计算上。
CPU 机器也有活路。所有模型都可以用 CPU 跑,只是生成速度感人,一个 500 字的回答可能要等三四分钟。我的经验是,如果你确定要 CPU 部署,就别用 7B 模型,用 3B 或者 1.8B 的量化版,配合合理的分块策略,还是能实现基本可用的问答体验。
6. 后续路线与几点个人心得
6.1 接下来打算做的方向
第一个方向是 OCR 的深度集成。很多用户拿到的旧材料是纯扫描版,没有文字层,这块不补上,工具覆盖的场景就少了一大块。我在考虑把 PaddleOCR 做成可插拔模块,本地调用,不上传任何图片数据。这个方案对私有化部署非常关键。
第二个方向是支持更多文档格式。目前已有的 PDF、DOCX、TXT、Markdown 覆盖了大部分办公场景,但用户里已经有人问能不能读 EPUB、Excel 和 PPT。解析逻辑其实不难,主要是把每个格式的文本抽取规则单独实现,再挂到loader/目录下。
第三个方向是知识库的量级扩展。目前单机模式运行几千个文档块没什么问题,但超过十万个块之后,Chroma 的检索速度和内存占用都会受影响。下一步考虑引入更专业的向量数据库,或者做分布式索引,让工具能应付更大规模的私有知识库。
6.2 真正让我觉得值得的瞬间
这半个月的过程中,最强的一次感触是给自己的技术博客站点加了个入口,把一百多篇历史文章离线解析成知识库,然后用语音提问的方式查自己以前的观点。真问了一句“我之前写过关于系统设计中幂等性的文章吗?核心思路是什么?”它不光搜到了相关文章,还给出了我当时总结的三个原则。那种把零散历史碎片变成可对话知识库的体验,确实能让人上瘾。
作为一个自己维护的开源项目,最大的成就感不完全来自功能本身,而是看到别人用起来之后提出问题。有人改造了解析器去读法律文书,有人在嵌入式设备上搞了个极简版,还有人把对话接口接到了自己的知识图谱上。这就是开源的意义,你能造出一把好用的螺丝刀,但别人用它去拧飞机上的螺丝还是组装机器人手臂,这才是最让人期待的延伸。
项目现在已经可以跑通全流程了,如果大家在部署时遇到新坑,欢迎随时交流。后续有阶段性成果我都会记录出来,让这个文档阅读器在真实使用中变得越来越好用。