“AI知识库问答”这两年已经不算什么新鲜词了,但不少人从“看教程”到“真正跑通一个能用的系统”之间,还是隔着不少坑。我之前写过这个系列的第一篇,聊了技术选型和整体思路,这篇就直接点,讲实操:怎么一步步把一份份散落的文档,变成一个能回答你问题的知识库问答系统。如果你正在折腾RAG(检索增强生成)相关的方案,或者被Word、PDF解析、向量化、检索不准这些问题卡住,这篇就是给你准备的。
我这套东西不做太重的架构,本地能跑,数据能私有化,核心链路就是:文档解析 -> 文本清洗 -> 分块 -> 向量化 -> 存储 -> 召回 -> 问答生成。这篇我会把每一步的关键选择讲透,包括为什么这么做、参数怎么定、哪些地方容易翻车,最后附上我实际调试过程中遇到的一堆问题和解法。
1. 整体设计思路:先把RAG链路拆明白
1.1 为什么选择RAG而不是微调
第一个要先说清的问题是:为什么做AI知识库问答,绝大多数情况下应该选RAG,而不是去微调一个模型。原因很简单,一个企业内部的知识库、产品手册、论文资料库,特点是内容高频更新、数量大、对准确率要求高。微调的本质是改变模型本身的权重,它适合让模型学习一种风格、一种输出格式、一套稳定的思维方式,但不适合去“记”那些随时会变的业务文档。
举个例子,你上个月把某个产品的操作手册微调进了模型,这个月手册改了三十处,那你就得重新准备数据、重新训练、重新评估,整个周期短则几天、长则以周计。而RAG方案里,文档更新了,只需要把增量内容重新解析、分块、向量化后写入向量库,不改模型、不重训练,分钟级别就能完成一次知识刷新。
从成本角度看,RAG也更划算。微调要有训练资源,哪怕用LoRA这类高效微调手段,也得准备GPU环境、微调数据集、评估集。RAG方案里,推理用的大模型既可以是本地部署的开源模型,也可以是API服务,向量化模型同样有大量轻量选择,一台普通CPU服务器甚至一台开发机就能把整条链路跑起来。我自己的实践是,先跑通RAG解决“能不能答”的问题,如果后面真的有“固定风格输出”这种需求,再考虑在RAG基础上叠加微调。
1.2 一条完整的RAG链路需要哪几个环节
把RAG问答拆开看,它就是一条流水线,任何一个环节的质量都会直接影响最终答案的质量,所以第一步要把链路看清。
整条链路分两大段:离线索引(Ingestion)和在线问答(Retrieval + Generation)。
离线索引干的事情,是把原始文档变成向量库里可检索的内容。具体来说包括:文档加载,也就是从Word、PDF、Markdown、HTML这些格式里把文本抽出来;文本清洗,去掉页眉页脚、多余换行、表格乱码这类噪声;分块(Chunking),把长文本切成大小合适、语义完整的片段;向量化(Embedding),把文本片段转换成高维向量;最后写入向量数据库。
在线问答干的事情,是拿用户的问题走同样的向量化处理,去向量库里做相似度检索,把最相关的若干文本片段捞出来,然后把这些片段作为上下文,连同问题一起交给大模型,让它组织成一段自然语言回答。
这两个阶段的关系,有点像建图书馆和查图书馆。离线索引阶段是采编、入库、上架,在线问答阶段是查目录、取书、做摘要。很多人的系统做得不好用,往往是只盯着在线问答阶段的模型提示词调来调去,却没发现是离线索引阶段的书没摆对位置,书都找不到,你让图书管理员怎么给你讲清楚。
1.3 这篇实操覆盖的范围与目标
这一篇的实操路径,我的定位是“能跑起来、能调出效果”。具体目标有三条:
第一,把文档解析做扎实,Word、PDF这些最常见的格式,不能出现乱码、丢段落、表格错乱这种事。
第二,把分块和向量化的参数讲透,不追求某个固定的“标准答案”,而是给你一套怎么根据自己的文档情况定参数的思路。
第三,给出一个在中等规模知识库场景(几千到几十万篇文档)下足够用的问答实现方式,同时兼顾私有化部署和后续扩展。
这里面我不会去讲怎么写一个RAG框架的源码,因为对绝大多数人来说没有必要。市面上的Dify、FastGPT、RAGFlow都已经相当成熟,我更建议的做法是,先用一个成熟框架把链路搭起来,理解每个环节在干什么,然后再根据效果去替换某个环节里的具体实现。这样既不会重复造轮子,又能做到出了问题知道去查哪一环。
2. 文档解析与预处理:这一步偷懒,后面全是坑
2.1 解析Word文档的常用姿势与坑
知识库里的文档,我接触下来Word格式占了相当大比例,尤其是企业内部资料。Word解析的目标,是把docx里的正文、表格、标题层级尽可能完整地抽出来。
如果只是最简单的场景,用python-docx库就能干。遍历document.paragraphs可以拿到段落文本,遍历document.tables可以拿到表格内容。但实际写起来没那么简单,因为一个真实的Word文件里往往藏了不少会搞乱解析的东西。
第一个坑是批注、修订、文本框。python-docx默认能把正文段落读出来,但如果内容在文本框里、在页眉页脚里,甚至是以嵌入式对象的形式存在的,直接遍历paragraphs是拿不到的。我遇到过一份产品需求文档,关键结论全放在文本框里,解析出来只剩一堆零碎段落,检索召回率惨不忍睹。
第二个坑是表格数据。Word里的表格如果直接按单元格文本拼接,很容易失去语义。比如一个表格有三列:参数名称、默认值、说明,如果你把一整行拼接成一大段文本,检索时用户问“默认值是多少”,这段文本的向量和其他参数行的向量会互相干扰。更合理的做法是,把每一行转成一段结构化描述,比如“缓存过期时间的默认值是3600秒,说明是控制缓存刷新频率”,这样一个表格行就对应一个语义完整的文本块。
实际处理时,我建议的流程是这样的:先用python-docx拿到所有段落和表格;对表格做行级拼接,每行生成独立文本,同时保留表头信息;然后按文档结构顺序,把段落和表格行合并成一个有序的文本流;最后再对整个文本流做清洗。清洗的几个固定动作包括:去掉连续空行、去掉多余空格、把全角字符统一转半角、去掉常见的页眉页脚关键词行。
2.2 解析PDF文档的坑与对策
PDF是另一个知识库主力格式,也是解析环节最容易出问题的地方。很多人第一次跑通知识库时用的都是纯文本型PDF,一切顺利,直到有一天扔进去一个扫描件,检索结果就彻底失灵了。
PDF解析要分情况。
一种是数字生成的PDF,也就是用Word、LaTeX这类工具导出的,文字信息本身就存在,这时候用pdfplumber或PyMuPDF(fitz)就能抽到很干净的文本。PyMuPDF的速度明显更快,我处理几百页的PDF,几秒就能完成,而pdfplumber会慢不少,但是它的文本定位信息更全,适合需要提取坐标、表格线的场景。
一种是扫描版PDF,本质是图片,必须走OCR。OCR的选型,开源场景paddleocr的文档版面分析和中文识别效果都还不错,追求更高精度可以用商业API。这里要注意一个问题:OCR输出往往带位置信息,如果直接把整页拼成一段文本,阅读顺序可能错乱,尤其是分栏排版、带表格的页面。建议按“栏”来切分文本块,再按从上到下、从左到右的顺序拼回页面内容。
还有一个常见的坑是PDF里的表格。PDF本身是不带表格结构语义的,你用PDF解析库提取到的“表格”,也只是一堆带坐标的文字碎片。如果文档中表格很重要,我建议两步走:先用PyMuPDF抽出文字和坐标,再用pdfplumber的表格提取能力做二次处理,或者干脆用专业的文档解析库(比如RAGFlow内置的DeepDoc,或者unstructured库)。
清洗环节和Word类似,PDF也会有页眉页脚、页码、水印,这些都要在写入向量库之前去掉。我的做法是:先跑一遍完整文档,把高频出现的页眉页脚文本统计出来,然后把这些行从全文中删除,比逐份文档手写规则高效很多。
2.3 分块策略:切多大、怎么切才算好
分块这一步,是很多人最容易忽略又最关键的一步。它直接决定了检索时能不能把最相关的内容捞出来。分块太小,单个片段语义不完整,检索容易抓到“半句话”;分块太大,片段里混了太多不相关内容,向量化后语义被稀释,而且超过模型上下文窗口后还会被截断。
分块的第一个原则,是尊重文档本身的结构。一个标题下、一个表格行、一个列表项,都是天然的语义边界。我自己的做法优先级排序是:先考虑用文档结构做边界,比如Markdown的标题、Word的标题级别;没有明确结构时,才用固定长度加重叠窗口的方式去切。
固定长度分块的参数,没有“银弹”,但有一个经验区间。中文场景下,单个分块的字数我建议在300到800字之间,重叠窗口设成50到150字。为什么要有重叠?因为如果一个语义完整的段落恰好被切在分块边界上,重叠窗口能保证边界信息在相邻两个分块里都出现,检索时不论命中哪块都不会丢失关键内容。
更讲究一点的做法,是基于句子的滑动窗口。也就是说先按句号、问号、感叹号把文本切成句子,然后往块里加句子,直到块的长度超过你设定的上限,就结束当前块并加入下一句作为重叠内容。这样做的好处是,块与块之间的切点一定在句子上,不会把一句话拦腰切断。我用的一个简单实现在这里直接给出:
def chunk_by_sentences(text, max_chars=500, overlap_chars=50): import re sentences = re.split(r'(?<=[。!?!?])', text.strip()) chunks = [] current = "" for sent in sentences: if not sent.strip(): continue if len(current) + len(sent) > max_chars and current: chunks.append(current) # 保留尾部overlap内容,避免跨块语义断裂 current = current[-overlap_chars:] + sent if len(current) > overlap_chars else sent else: current += sent if current.strip(): chunks.append(current) return chunks这个函数看起来简单,但实际用下来,比很多框架默认的分块器要干净不少。如果你用的是LangChain,可以直接用它的RecursiveCharacterTextSplitter,把chunk_size设成500,chunk_overlap设成80,separators按中英文都加进去,效果也不错。
3. 向量化与存储:Embedding选型和向量数据库对比
3.1 Embedding模型怎么选才不后悔
Embedding模型的作用,是把文本映射成向量,向量之间的距离代表语义上的远近。选Embedding模型是件“选错代价很大”的事,因为你一旦跑完向量化,后面想换Embedding模型,等于整个库要重新向量化一遍。
选择Embedding模型,核心看三件事:语言支持、向量维度、检索效果。
语言支持上,国内场景优先选对中文友好的模型。我常用的几个,包括BAAI的bge-large-zh-v1.5、bge-m3,以及智源的text2vec-large-chinese。如果文档里中英混杂,bge-m3这种多语言模型会更稳,它对中英混合文本的语义建模比纯中文模型好不少。
向量维度影响的是存储成本和检索速度。大模型(比如1024维)语义表达更丰富,但相同数据量下需要的存储空间和检索计算量都更大。对几百篇文档的私人知识库,其实用384维或512维的小模型也够用;但如果你要做的系统文档量是几十万篇级别,建议直接用4096维以内的知名模型,后续检索精度上限更高。
这里还有一个很容易踩的坑:查询向量化和文档向量化要尽量用同一个模型,而且对应到模型本身的指令说明。像bge系列,官方建议在查询侧加上“为这个句子生成表示以用于检索相关文章”这类指令前缀,文档侧不加。如果你用的框架没处理这个细节,建议手动查一下模型文档并按它的规则来,否则相似度匹配效果会打折扣。
3.2 向量数据库选型对比
向量数据库是整个系统的存储底座。选型的时候不要被“必须用专门的向量数据库”这个说法框住,关键看你自己的场景和运维能力。
如果你只是个人知识库、几百上千个文档,直接用轻量级方案就行。最简单的是用sklearn的cosine_similarity在内存里做暴力检索,数据量在几万条向量以内,毫秒级响应完全是够的。也可以用sqlite-vss、chroma,这类嵌入式向量库不需要单独部署服务,配置成本很低。
如果是团队使用、数据量到达几十万条向量以上的级别,就需要考虑独立部署的向量数据库了。我列一个简单的对比表:
| 方案 | 部署复杂度 | 适合数据量 | 特点 |
|---|---|---|---|
| 内存暴力检索(numpy/sklearn) | 极低 | <5万条 | 简单直接,适合原型验证 |
| Chroma | 低 | <50万条 | 嵌入式/服务均可,Python生态好 |
| Milvus | 中 | 百万级以上 | 功能全面,性能强,集群化部署能力强 |
| Qdrant | 低-中 | 百万级以内 | Rust编写,单机性能好,API干净 |
| Elasticsearch + 向量插件 | 中-高 | 中大规模 | 适合已有ES运维经验,同时做全文检索 |
我的建议是:一开始做原型,别急着上重型数据库,先用Chroma或者直接内存检索跑通链路;等文档量上来、多人同时用、需要高并发检索了,再迁移到Milvus或Qdrant不迟。迁移本身不难,因为你的数据源是同一套向量文件和元数据,重新写一遍即可。
3.3 向量化处理流程与索引细节
向量化的处理流程很固定,但有几个细节值得注意。
第一,向量化之前要清洗文本里的无关信息。前面解析出来的文本,里面可能还有大量HTML标签、URL、乱码字符。这些噪声向量化之后,会拉低检索相似度。我的习惯是,先跑一轮正则清洗,把URL、重复标点、连续空行清掉,再进入分块。
第二,写入向量库时,一定要把元数据存进去。元数据至少包括:文档ID、文档标题、分块序号、原始文本。为什么原始文本也要存?因为向量检索出来的是向量,回答时你需要把对应的原文拼进Prompt里,如果向量库里不存原文,还得反查一次文档,平白增加一次IO。
第三,索引参数里有两个东西要关注:度量方式和索引类型。度量方式默认选余弦相似度(cosine),对文本语义匹配最直观。索引类型上,数据量小的时候用暴力检索(FLAT)精度最高;数据量大再换成IVF或HNSW。HNSW是目前比较推荐的近似最近邻算法,速度和精度平衡得好,但这属于后期优化点,你第一步把库搭起来,用默认的FLAT就完全够跑。
下面是我在实际项目中经常用的向量化写入代码(以Chroma为例):
import chromadb from chromadb.utils import embedding_functions # 指定Embedding模型,bge-m3需要sentence-transformers支持 ef = embedding_functions.SentenceTransformerEmbeddingFunction( model_name="BAAI/bge-m3", normalize_embeddings=True ) client = chromadb.PersistentClient(path="./kb_store") collection = client.get_or_create_collection( name="knowledge_base", embedding_function=ef, metadata={"hnsw:space": "cosine"} ) # documents是分块后的文本列表, ids是唯一标识, metadatas包含文档标题、来源等 collection.add( documents=text_chunks, ids=chunk_ids, metadatas=chunk_metas )这段代码看起来简单,但有几件事值得展开说:normalize_embeddings=True也用了余弦相似度,这里用来确保向量归一化,避免文本长度干扰相似度计算;PersistentClient会把向量数据持久化到本地目录,重启后数据不丢。最容易被忽略的是embedding_function这个参数,它决定了写入和查询时用的Embedding模型是一致的,千万不能写完库之后又换个模型去查询,那检索结果会彻底乱套。
4. 问答链路实现:检索、Prompt与引用溯源
4.1 检索效果调优:先调相关性,再调上下文
检索是整个问答系统里最具决定性的环节。很多人把大量精力放在写Prompt上,但如果检索回来的片段本身就不相关,大模型再会组织语言也是巧妇难为无米之炊。
检索调优的第一步,是理解“相似度≠相关性”。向量相似度高,只代表两段文本在语义上接近,不代表它能直接回答用户问题。比如用户问“系统怎么增加用户”,知识库里有一篇文档叫“用户系统故障排查”,向量相似度可能很高,但它里面讲的是排查故障,不是增加用户的操作。要缓解这个问题,就要在检索结果上做更细的过滤。
我的做法是加一道“关键词过滤”的保险:先用相关性分数过滤出Top K个片段,然后在这K个片段里做一个简单的关键词匹配,确保用户问题里的核心实体(比如“增加用户”)至少出现在某一段里。如果所有Top K片段都没有命中关键词,就降低Top K范围重新检索一次。这种方法虽然土,但在知识库问答场景下非常有效。
另一个关键参数是Top K的设置。太小的Top K会让大模型只能看到少量上下文,容易漏信息;太大的Top K会让上下文变得杂乱,模型反而被无关信息干扰。我的经验值是:普通问答场景Top K取4到6;如果知识库中文档粒度较细、分块较短,Top K可以取8到10;如果问题需要跨多个来源综合,Top K甚至可以取到12,但过多时要在Prompt里明确告诉模型“只基于提供的资料回答,不要自行扩展”。
4.2 Prompt设计:怎么让大模型给出高质量回答
检索到了合适的上下文,接下来就是Prompt的活了。我一直认为,RAG的Prompt设计,核心是做好“约束”和“兜底”。
先给一个我调过的通用模板:
你是一个知识库问答助手。请基于以下提供的资料片段回答问题。 资料片段: {context} 用户问题:{question} 要求: 1. 如果资料片段中提供了明确信息,请直接根据资料回答; 2. 如果资料片段信息不足以回答问题,请明确回答“资料中未找到相关信息”,不要编造; 3. 回答时请标明信息来源于哪份资料片段(用[来源1]、[来源2]标注); 4. 尽量用简洁的段落输出,不要列出与问题无关的内容。这个模板里有几个关键设计。
第一,“基于资料片段”这个限定非常重要。不加这个限定,大模型会自由发挥,看似答得流利,实际是幻觉的高发区。加上之后,模型会倾向于从给定上下文中组织语言,跑偏概率明显下降。
第二,“信息不足时明确回答未找到”这一条,本质上是在给幻觉上保险。RAG系统最大的风险不是答错,而是答错还答得像模像样。让模型学会“承认不知道”,比让它硬答更有价值。
第三,“标注信息来源”这条,是我强烈建议加上的。它不仅让用户看到答案有据可查,更重要的是,你可以在系统里把来源链接展示出来,让用户自己判断答案的可靠性。这一步对提升使用者信任度非常关键。
Prompt还有一个比较容易被忽视的细节:{context}和{question}之间要有清晰的边界说明,而且如果context过长,要做截断。很多模型的窗口本身就是有限制的,context塞得太满,模型会丢失对问题本身的注意力。
4.3 引用溯源:让知识库问答更有说服力
引用溯源在RAG系统里的价值,很多人一开始是忽视的。但如果你的知识库用于医疗、法律、设备操作、企业制度这类对准确性要求高的场景,没有来源的答案跟一本正经的胡说八道没区别。
实现引用溯源其实不难。核心思路就是在检索阶段保留每个片段的元数据,然后在拼接Prompt给每条片段加一个来源标识,最后要求模型在回答时带上对应的标识。比如,检索回来的片段分别是来自“A文档第3页”和“B文档第12页”,那么在Prompt中这样组织:
[来源1] 出自《设备操作手册》第3页:xxx [来源2] 出自《故障排除指南》第12页:xxx模型在回答时,会自然地使用“[来源1]”这样的标记。你在展示结果时,把“[来源1]”渲染成一个可点击的链接,指向对应的文档和页码,用户体验立刻提升一个档次。
我自己在做一个设备维保知识库项目时,加了引用溯源之后,用户的使用频率提升了将近一倍。原因很简单,一个不知道出处的AI答案,没人敢拿去操作设备;而有了出处,答案再长也有人敢看。这一步的投入产出比,在所有优化项里几乎是最高的。
5. 常见问题与排查技巧实录
5.1 答非所问:问题不在模型,在检索
我见过最多的负面反馈就是“AI回答得一点都不准”。这种问题八成不是模型不好,而是检索没做好。
排查思路我一般分三步走。第一步,把用户的问题拿去向量库里做一次单独检索,看看Top 5的片段里有没有跟问题直接相关的。如果Top 5里相关片段很少,说明是分块或向量化的问题,要去检查分块是否切碎了语义、Embedding模型是否对中文友好。第二步,如果Top 5里确实有相关片段,但模型回答依然不对,那就看Prompt里有没有对上下文做足够的限定,是不是模型没在认真用这些片段。第三步,如果以上都正常,再检查是不是多轮对话时,历史消息冲淡了当前问题的检索上下文,这时候可以把“历史对话摘要+当前问题”一起作为检索条件。
这里有一个非常值得说的经验:很多人做问答系统时,把“问题”整个丢给检索器。但如果问题很长、包含很多背景描述,检索效果反而会变差。比如“我们办公室的打印机最近老是卡纸,而且卡纸之后还一直亮红灯,售后之前来修过一次又坏了,我想知道第二次报修应该打哪个号码”,这种长问题直接向量化,语义重心会被背景信息稀释。我的做法是先让大模型把长问题压缩成一句话核心问题,再进行检索,命中率明显提升。
5.2 文档解析乱码与分块错乱
文档解析的乱码问题,场景化特别强。PDF里有些字体没有被正确编码,抽出来是乱码字符;Word里有些使用了特殊字体或嵌入对象,文本顺序错乱;扫描PDF的OCR结果,有时把“0”和“O”、“1”和“l”混淆。
针对乱码,我的排查建议是:先抽取几页原始文本,人工看看乱码集中在哪类文档、哪类位置。如果是个别字体导致的,通常换一个解析库就能解决,比如同一份PDF,PyMuPDF解析乱码的话,可以试试pdfplumber。如果是整个扫描件OCR的问题,就要去优化OCR的预处理,比如先把图片转成灰度、提高分辨率、再做二值化,OCR准确率会有肉眼可见的提升。
分块错乱的问题,更多是“跨块语义断裂”引起的。比如一份操作手册,步骤1和步骤2分到了两个不同的块里,用户问“操作第一步应该点击哪里”,如果向量化时命中的是步骤2所在的块,回答就会缺头。解决方法是两个:一是分块时增加重叠,让相邻块之间有一些共享内容;二是在清洗阶段把“步骤一、步骤二”这种强结构信息做成结构化元数据,检索时优先保证命中完整的流程片段。
5.3 性能与成本控制:小资源也能跑得动
最后一个绕不开的话题,是资源有限的时候怎么让这个系统跑起来、跑稳。很多开箱即用的方案(比如本地部署的7B、13B模型)对硬件要求很高,普通人一台办公电脑可能跑不动。
我的建议是分层处理:Embedding模型对资源要求相对低,用CPU也能跑,只是慢一些,但批量处理文档时慢点可以接受;真正吃资源的是推理用的大模型。如果你没有GPU,优先考虑调用云端API,现在各家都有比较便宜的推理服务,个人项目用起来成本不算高。如果你必须纯本地部署,模型尽量选量化版(比如Q4、Q5量化),同时把上下文长度限制在2048或4096以内,否则推理延迟和显存占用都会很难看。
另一个成本控制点是:不要让每次问答都重新检索全部文档。对中等规模知识库,完全可以给每个文档建立摘要,先做文档级粗筛,再进入分块级精准检索,这样既能提高速度,又能减少不必要的向量检索开销。这个技巧对几百篇文档的场景效果不明显,但当你文档量上万以后,差别非常明显。
写在最后
做AI知识库问答这件事,技术上没有太多玄学,本质上就是把文档处理干净、把检索做准、把Prompt约束好。我刚开始做的时候也走过弯路,比如花了很多时间调Prompt,后来才发现是检索回来的片段本身不对;又比如选了一个很重的向量数据库,结果文档量根本没到那个规模,服务器白买了两台。回头总结,最关键的经验就是:先跑通一条最小链路,再一项一项调精度,而不是一上来就把架构整得特别大。
如果你也是刚开始折腾,我建议你拿二三十篇真实文档,走一遍我这篇文章里的流程,把解析、分块、向量化、检索、回答整个链路跑顺了,再考虑优化。另外一个小技巧:给你的知识库写一个“测试问题集”,一次性准备二三十个真实场景的问题,每次改完参数跑一遍,对比答案质量,这样你才能知道自己每一步的改动到底是变好了还是变差了。
这个系列如果后面有第三篇,我再聊聊如何把知识库从“能回答”升级成“回答得好”,包括重排序、混合检索、答案质量评估这些东西。先把自己手里的文档跑起来,才是最快的进步方式。