做智能文档问答系统之前,我印象里最深的画面是一堆PDF、Word、Markdown散落在网盘,平时找东西全靠文件名。真正开始做之后才发现,文档问答的难点根本不在“大模型会不会说话”,而在“它能不能在正确的段落上说话”。这篇文章想聊的,是一个我习惯叫它“主手”的核心模块——也就是串起解析、索引、检索、生成这一整套流程的调度中枢。如果你正打算给自己的资料库搭一套智能文档问答助手,或者已经在用现成框架但总觉得回答质量不稳定,这篇内容应该对你有用。
为了不让“主手”听起来像个玄学概念,我先说清楚它的定位:它就是整个文档问答服务的执行主体,一份文档进来怎么处理、一个问题进来怎么响应、中间某个环节挂了怎么兜底,都由它统一编排。市面上很多RAG(检索增强生成)框架其实已经隐含了主手,但当你需要定制文档类型、控制检索策略、调教提示词的时候,还是得亲手搭一套自己的主手,才能真正知道自己手里的数据长什么样、模型能力边界在哪。
1. 主手到底在解决什么问题
1.1 一份文档从入库到给出回答的完整链路
先拆一个最朴素的场景:你的知识库里有几十份产品说明书,用户问“这个设备最大载荷是多少”,背后发生了哪些事?
第一步是文档入库。PDF要解析出文字,Word要处理分页符,扫描件要过OCR,表格不能变成一团乱码。解析完之后是清洗和切分,怎么断句、怎么按语义切块,直接影响检索能不能找对地方。切好的文本块做嵌入(Embedding)变成向量,连同原文一起存进向量库。
第二步是线上问答。用户问题也要转成向量,去向量库里找最相似的几个文本块。但这里有个容易被忽略的细节:找回来的块不一定真的有用,可能只是字面相近。所以还需要一个重排序环节,或者干脆混合关键词检索再合并。最后,把筛选后的文本块组装进提示词,交给大模型生成回答。
这一整套流程,每一步都可以单独用脚本跑通,但合起来就需要一个统一调度的“主手”来管理。它不负责具体的算法细节,而是负责流程编排、中间状态记录、异常处理和数据流转。
1.2 为什么不能靠几个脚本硬凑
很多人一开始偷懒,把解析脚本、检索脚本、生成脚本分开放,跑的时候串联起来。临时用一两次没问题,但一旦要接入真实产品,问题就暴露出来:
- 文档上传失败时,半成品数据残留在索引里,下一次搜索会搜到脏数据。
- 某个环节超时,没有统一的重试机制,用户只能看到“服务异常”。
- 排错困难,不知道答案出了问题是在解析阶段就错了,还是检索召回太差,还是模型胡编。
- 后续想加文件类型、换Embedding模型、调提示词,不知道改动会影响哪一环。
说白了,散装脚本的瓶颈不是功能缺失,而是状态不受控。主手的价值在于把流程变成一条可观测、可打断、可恢复的流水线。这也是我把它叫“主手”而不是“主流程”的原因:它是有主动性、有状态意识的调度者,不是被动按顺序执行的胶水。
1.3 主手的模块边界
明确主手的职责,可以参考这张表:
| 模块 | 职责 | 不负责什么 |
|---|---|---|
| 文档解析器 | 提取文本、保留结构 | 语义理解、向量化 |
| 文本切分器 | 分块、控制块大小 | 判断块是否有价值 |
| 嵌入服务 | 文本向量化 | 检索排序 |
| 检索引擎 | 召回候选、重排序 | 生成回答 |
| 提示词组装 | 拼接上下文与问题 | 模型推理 |
| 大模型接口 | 生成最终回答 | 流程控制 |
| 主手调度 | 编排以上所有模块、缓存、重试、日志 | 具体算法实现 |
这个边界很重要,因为它意味着主手不该去硬编码某一种解析器的细节。它做得越多,后期越容易被具体工具的API变更拖住。
2. 文档入库阶段:地基不稳,后面全是白搭
2.1 文档解析的常见坑
文档解析是整条链路里最脏最累的活,但也是最值得投入的地方。我以前低估了PDF的复杂度,直到被一本带三栏排版的说明书折磨过:普通按行读取会把正文和页眉混在一起,切出来的块语义完全断裂。
我的建议是先把文档类型盘点清楚。常见的有几类:
- 纯文本型PDF:用pdfplumber或PyMuPDF直接提取文本,注意版面分析,尽量按阅读顺序输出。
- 扫描件PDF:必须接OCR,Tesseract对中文效果一般,建议用PaddleOCR或厂商API,同时保留OCR置信度,低置信度的页面要人工复核。
- Word文档:python-docx读取段落和表格,但要注意分页符不是语义边界,排版上的空行不要直接切段。
- Markdown / HTML:保留标题层级,这类结构信息对切分特别有价值,不能因为图省事全压成纯文本。
解析表格是另一个深坑。一个产品参数表,如果用文本流的方式硬读,表格的行列关系会全部丢失。比较好的做法是把表格单独提取出来,行转成“字段: 值”的键值对文本,比如“最大载荷: 500kg”。这样检索和生成都能直接用到结构化信息。
2.2 文本切分策略,经验值比理论值更实用
切分的目标是让每个块尽量是一个自洽的语义单元。纯按固定长度切是最省事的,但经常把一句话从中间截断,后面就没法看了。我在实践中用了个简单策略:固定长度为主,同时尝试在段落边界或标点附近切。
具体来说,chunk_size我通常取 500 到 800 个字符,overlap取 80 到 100 个字符。为什么要保留重叠?因为用户问的问题很可能是横跨两个切分块边界的,比如“最大载荷是多少公斤,提升高度呢”,如果第一块只讲到载荷、第二块只讲提升高度,没有重叠就容易漏信息。重叠区域相当于给上下文加了一层缓冲带。
代码写出来很简单,但要注意别把Markdown的代码块、列表结构切坏:
def split_chunks(text, chunk_size=700, overlap=100): chunks = [] start = 0 while start < len(text): end = min(start + chunk_size, len(text)) # 尽量在最近的换行符或句号处截断 cut = text.rfind("\n", start + chunk_size // 2, end) if cut == -1: cut = text.rfind("。", start + chunk_size // 2, end) if cut != -1: end = cut + 1 chunks.append(text[start:end]) start = max(start + 1, end - overlap) return chunks这段代码只是演示思路。真实场景里,如果你用日志或法律合同这种标题结构很强的文档,最好优先基于标题层级切块,让每个块对应一小节,块内再按长度兜底。这样检索回来的时候,上下文自带主题边界,模型更容易理解。
2.3 向量化与索引存储的选择
切好的块要转成向量。Embedding模型的选择直接影响召回质量,我一般用三种方式压测:一是看模型在领域句对上的相似度排序是否合理,二是跑一批真实问题看召回命中率,三是看嵌入维度与性能开销是否匹配你的服务能力。维度太高、延迟太大,对小团队不划算。
索引存储上,小规模知识库直接用FAISS就能跑得很快,百万级向量以内没必要上分布式向量库。FAISS的优点是轻量、嵌入进主手代码里很方便;缺点是动态增删文档不如专门的向量数据库灵活。我的做法是:知识库内容稳定就用FAISS,频繁更新时改用具备过滤能力的向量库,方便按文档ID过滤脏数据。
3. 检索召回:回答质量的隐形瓶颈
3.1 为什么向量检索经常召回不准确
很多人以为有了Embedding,向量相似度就能解决一切召回问题。实际上,用户问问题的方式和文档原文的表达方式经常差得很远。比如文档里写的是“设备最大承载重量”,用户问的是“这个机器顶多能扛多重”,向量相似度可能排不到最前面。
另一个问题是细粒度差异。两段文档都聊“载荷”,但一段聊飞机载荷、一段聊起重机载荷,它们向量接近,召回来的全是干扰项。所以在检索阶段,单纯靠余弦相似度切top_k很容易翻车。
我习惯的做法是先把召回数量放大,比如top_k=20,再靠后面的重排序或者交叉编码器把真正相关的段落提上来。有些框架把召回和排序混在一起,不太好调试,拆开反而清楚。
3.2 混合检索怎么混才有意义
混合检索的价值在于取长补短:关键词检索(如BM25)擅长精确匹配术语,向量检索擅长语义扩展。它们合并的时候要注意两点:一是分数归一化,否则某一种检索的分数天然压过另一种;二是结果去重,因为同一个块可能同时被两种方式召回。
简单策略是给每个候选分打一个融合分:
final_score = w * normalized_vector_score + (1 - w) * normalized_bm25_score权重 w 需要按场景调。对技术文档这种术语密集的场景,BM25的权重可以给到 0.3 到 0.4;对问答式、口语化查询,向量权重更高。如果批量测试后发现某类问题召回差,就回来调权重,而不是一上来就换Embedding模型。
3.3 重排序模块要不要上
重排序一般用交叉编码器,把查询和候选段落拼成一对输入,直接输出相关性分数。效果比向量排序好,但速度慢,所以通常只在召回top 20之后再对少量候选重排。
我的经验是:如果你的知识库文档量不大、问题类型集中,可以先用LLM本身对候选段落做一次相关性判断。慢是慢一点,但能非常直观地看出召回的段落到底哪一段真正回答了问题。等流程稳定了,再换成交叉编码器能省成本。
4. 生成回答:把检索结果变成人话
4.1 提示词模板的设计要点
到了生成阶段,主手还要负责把检索结果组装成提示词。这里最容易翻车的,是把所有检索结果不分主次全塞进上下文,导致模型被无关信息带偏。
我常用的模板结构是:
请根据以下资料回答用户的问题。 要求: 1. 只使用资料中明确提到的信息回答。 2. 若资料中没有相关信息,请回答“资料中未找到相关信息”,不要编造。 3. 引用多个资料时,说明信息来源是第几段。 资料: [1] ... [2] ... 问题:... 回答:这个模板看起来简单,但有三个关键点:一是明确了“只使用资料”,降低幻觉概率;二是给了拒答出口,模型不会为了面子硬编;三是要求标注来源段,方便用户核对。后两者在面向真实业务时特别重要。
4.2 上下文窗口的组装顺序
大模型的注意力机制决定了它对靠前和靠后的内容印象更深。所以上下文组装顺序不是随便拼的。我按两个原则排序:一是相关性从高到低,最相关的段落放在最前面;二是把“无答案”提示放在资料的头部和尾部都强调一遍,防止模型在上下文过长时忘记。
另外,如果多个段落内容重复,要先合并去重。很多切分块之间本身就有重叠,重排序之后top1和top2可能是同一个段落的不同切法,直接全塞进去既浪费窗口又可能让模型复读。
4.3 幻觉控制与“不知道就拒绝”
做智能文档问答最怕的不是答不出来,而是言之凿凿地编答案。我在生产环境里加了两种控制手段:
第一种是在提示词里强制要求给出证据位置,例如回答末尾标注“(来源:资料[2])”。没有资料来源的回答,在后续审核阶段直接标记为高风险。
第二种是在主手里设置一个置信度判断。如果检索到的所有候选段落相关性分数都低于阈值,就不走生成,直接返回“未找到相关信息”。这个阈值需要根据标注样本去定,我一开始用0.3,后来调到0.35,效果更稳。宁可少答,不要错答,这个原则在文档问答里特别适用。
5. 主手的代码骨架:一条能跑通的产出链路
5.1 核心流程怎么编排
下面这段代码是我常用的主手结构,去掉具体厂商依赖之后,长这样:
class DocQA: def __init__(self, embedder, index, retriever, llm): self.embedder = embedder self.index = index self.retriever = retriever self.llm = llm def add_document(self, doc_id: str, text: str): chunks = split_chunks(text) vectors = self.embedder.encode(chunks) for chunk, vec in zip(chunks, vectors): self.index.add(chunk, vec, doc_id) def answer(self, question: str, top_k: int = 20, threshold: float = 0.35) -> dict: q_vec = self.embedder.encode(question) candidates = self.retriever.search(q_vec, top_k=top_k) # 假设重排序后得到 ranked ranked = self.retriever.rerank(question, candidates) if not ranked or ranked[0].score < threshold: return {"answer": "未找到相关信息", "sources": []} context = "\n".join( f"[{i}] {c.text}" for i, c in enumerate(ranked[:5], 1) ) prompt = build_prompt(context, question) reply = self.llm.generate(prompt) return {"answer": reply, "sources": [c.meta for c in ranked[:5]]}这里把主手逻辑压缩进了一个类,好处是调试点非常集中。你可以在answer里加日志、加缓存、加计时,而不用去翻解析脚本或检索函数。
5.2 缓存、超时与重试
真实服务里,同样的文档问答经常会被问很多遍。我在主手里加了两层缓存:一层是问题级别的缓存,完全一样的问题直接返回上次结果;另一层是段落检索缓存,热门问题的中间检索结果可以复用。这能显著降低大模型调用费用。
超时和重试也要在主手里统一定义。大模型接口偶尔会变慢或者报错,我会设置一个超时时间,比如10秒;超时后重试一次,再失败就转入降级提示,让用户稍后再试。重试的逻辑不能放在每个模块里,否则多次重试搅在一起会把资源耗尽。
5.3 日志与可观测性
主手一定要把每一步的耗时和中间结果记录下来。我常用的日志格式是把问题、召回段落编号、相关性分数、最终答案、响应时间、用的模型版本串起来。出了问题,一眼能看出是召回还是生成环节造成的。
这里我强烈建议把检索出的原始段落也存一份。因为事后的问答对评测如果只看到最终答案,很难判断错在哪个环节。存了原始段落,就能在标注时直接对比。
6. 实测效果与迭代建议
6.1 一套可以落地的评测方法
很多人问我怎么评估智能文档问答好不好用。忘掉那些花哨的指标,我从实操角度推荐一个简单方法:准备30到50个典型问题,覆盖高频询问、易混淆术语、需要多段资料拼装的场景,然后逐个跑一遍,人工给三个维度打分:召回是否准确、回答是否完整、是否有事实性错误。
打分判据可以分档:
- 准确命中:召回的段落里至少一段直接包含答案。
- 部分命中:内容相关,但缺关键细节,比如只答出型号没答出参数。
- 完全跑偏:召回段落与问题无关。
注意:评测时不要只记正确答案,还要记录问题类型。如果发现“需要拼装多段资料”的问题总是答不全,那大概率是切分或重排序的粒度问题,而不是模型能力不够。
我用这个方法迭代了两个版本:第一版纯向量检索,精确术语问题命中率约六成;加上BM25混合检索后升到八成;再加重排序和阈值判断后,明显降级到了“可对外演示”的水平。
6.2 我在实测中踩过的几个坑
切分太碎导致拼装无效是最隐蔽的问题。假设设备的载荷和提升高度写在同一段但被切到两块,检索时可能只召回其中一块,生成器就缺信息了。解决办法不是盲目加大chunk_size,而是让重叠区域覆盖关键指标,同时遇到数字单位时尽量让同一组参数留在同一块。
来源标注缺失会让用户不敢信任结果。即使回答对了,没有来源,用户会怀疑是模型瞎猜的。我在第二阶段加了来源段编号,问答可信度明显提升。维护来源信息不是加分项,而是基础配置。
提示词里塞了太多过时上下文导致回答混乱。有一次我同时把旧版说明书的段落和新版说明书的段落都塞进上下文,模型直接把两个版本混着答。后来我在资料列表里加了版本标记,并提示模型优先采用最新的资料信息,这个现象才消失。
6.3 将来可以怎么扩展
主手的结构一旦稳定,后续加功能就顺了。我觉得最划算的几个方向:一是支持多轮对话,把历史问题作为“对话记忆”传给主手;二是做增量索引,文档更新时只替换相关向量而不是整库重建;三是给不同文档类型配置独立的解析和切分策略,比如合同模板用条款切分,操作手册用步骤切分。
最后一个个人体会:把主手做好,比追最新的大模型版本更重要。每次模型升级,主手只需要改一下接口调用,其他策略都能稳定复用。这大概就是“编程序有条理”带给我的最大红利。