“rea”是我维护的一个内部项目代号,说白了就是一套基于检索增强生成(RAG)思路做的知识库问答系统。当时团队里每天都有大量文档、制度、操作手册散落在各个共享目录里,新同学进来找不到答案,老员工也经常为“某个流程在哪个文档里”翻半天。后来我们花了三周搭起这套“rea”,核心就一件事:让用户用自然语言提问,系统从海量文档里检索出最相关的片段,再交给大模型组织成一段有根据的回答。它解决的问题非常直接:把静态文档变成可对话的知识服务。
RAG 这个思路并不复杂,本质上就是“开卷考试”——不让大模型闭卷硬答,而是先给一段参考资料,再让它结合资料回答。这样既不要求模型把这些内部文档背下来,也不需要为每个新文档去重新训练模型,文档更新时只需要更新检索库。如果你正准备做企业内部知识库、客服辅助、流程问答这类需求,又不想一头扎进微调的大坑里,那这篇文章的实操路径可以直接抄。下面我会把设计思路、核心细节、代码实现和踩坑记录都讲透,尽量少讲空话。
1. 项目整体设计与思路拆解
1.1 选 RAG 而不选微调,是我反复比对的结论
很多同事一听到“让大模型懂我们的业务”,第一反应就是微调。但微调这件事,在小团队、中小型数据量下真的未必划算。从成本上讲,微调需要准备人工标注的高质量问答对,动辄几百上千条;训练环节还要专门的显存资源,如果找外包训一次,费用也不低。更要命的是,业务文档几乎每周都在变,微调模型不可能跟着这个节奏走——你今天刚训完,明天政策一改,模型里的旧知识就成了误导源。
RAG 的天然优势在于“知识外置”。文档变了,我就重建索引;发现了错误片段,我直接删掉那条数据;新员工培训材料更新了,重新跑一遍入库脚本就行。模型本体根本不用动。微观到具体能力上,RAG 还能在回答里给出出处,比如“这段结论来自《项目变更管理办法》第3章”,这对企业内部使用非常重要。微调则是把知识揉进参数里,系统给不出溯源,出了问题很难定位是模型的问题还是训练数据的问题。下面这张表是我当初做技术选型时给自己列的对比,白纸黑字写下了理由。
| 对比维度 | RAG 方案 | 微调方案 |
|---|---|---|
| 数据更新 | 更新索引即可,分钟级完成 | 重新训练,周期以天计 |
| 硬件要求 | 普通 GPU 或纯 CPU 也能跑 | 需要较高显存,训练资源紧张 |
| 知识溯源 | 能展示引用的原文片段 | 基本无法溯源 |
| 冷启动成本 | 只需要文档切片和向量化 | 需要标注数据集和训练流程 |
| 适合场景 | 内部制度、流程、产品说明、知识库 | 固定格式转换、特定风格输出、模型固化专属能力 |
当然这里不是否定微调。如果任务是需要模型“学会一种表达风格”,比如把所有报告改写成固定话术,或者处理固定格式的票据文本,那微调确实有优势。但对“从一堆文档中找答案”这个核心目标,RAG 的性价比高出一个量级。我最后定的方案就是“领域知识靠检索,语言组织能力靠大模型”,两边各干各擅长的事,互不拖累。
1.2 整体架构与数据流向
整个系统拆开看,就是四个层:数据层、索引层、检索层、生成层。数据层负责把各种格式的文档统一加载成纯文本;索引层把文本切块后转成向量并写入向量存储;检索层拿到用户问题后,先从库里捞出候选片段,再做重排;生成层把候选片段和问题组装成提示词,交给大模型产出最终答案。我习惯用图书馆来类比:数据层是成捆的书,索引层是做书目的编目员,检索层是帮你在书架间找书的馆员,生成层则是那位读完资料后用自己的话给你解释的讲解员。
实际数据流是“用户提问 → 问题向量化 → 向量检索 + 关键词检索 → 合并候选 → 重排序 → 拼装提示词 → 大模型生成 → 返回回答”。在编码时我把每一层都封装成独立模块,后续想替换其中的向量库、模型或者检索策略,都不用重写其他部分。这也是“rea”项目能快速迭代的主要原因——我们第一版用的是简单的向量检索,后来在检索层加了一个混合检索模块,整个切换只花了一个下午。
1.3 目标定位和效果评估标准
做之前必须想清楚:这个系统到底解决什么问题,不解决什么问题。我给“rea”定的范围很明确:面向内部知识问答,它能回答“流程怎么走”“规则是怎么规定的”“某个模块的接口参数是什么”这类事实性问题;它不做闲聊、不写长文、不做多轮复杂推理。把范围收缩到“事实问答”,后面所有评估指标都清晰了。
评估上我们没有追求花哨的端到端准确率,而是拆成两个指标:一个是检索命中率,看真实问题能否在检索结果前 5 条里找到正确答案的内容;另一个是答案可用率,由三位业务同事人工给回复打分,判断“照着这个回复能不能直接指导行动”。检索命中率盯住了“信息是否被找到”,答案可用率盯住了“最终用户是否满意”。实践证明,大部分回答质量变差,问题都出在检索层而不是生成层,所以这个拆法能快速定位瓶颈。第一版上线时检索命中率大约只有 65%,经过切片参数调整和混合检索改造后提到了 92% 左右,整个优化过程后面会详细展开。
2. 核心细节解析与实操要点
2.1 文档处理的三个关卡:加载、清洗、切分
文档加载看着简单,实际上坑最多。一个中型企业的文档库里,常见格式就有 PDF、Word、Markdown、Excel,PDF 里还有扫描件和文字版之分。扫描件必须走 OCR,否则文字是图像,向量化之后全是噪音。文字版 PDF 也不是直接读文本就完事,很多排版本里的页眉页脚、目录页码都会混进来。我在加载阶段就做了两层处理:先用专门的文档解析器提取文本,再用正则规则自动干掉页眉页脚、图片说明、连续空行。
清洗完成之后才是重头戏:切分。切分决定了向量检索的颗粒度,太粗会把好几段不相关内容揉进一个向量里,检索时容易捞出不相关的内容再交给大模型;太细则每段信息量不足,检索时经常匹配到细枝末节,却丢掉了核心观点。我第一版用固定字号每 200 字一切,结果回答质量惨不忍睹,后来才换成递归字符切分器,按段落、句子两级结构去切,尽量保证一个切片内部主题连贯。
切分参数上,我实测下来比较稳的组合是:切片长度 600 到 800 字,相邻切片重叠 80 到 120 字。重叠是很多人容易忽略的——我解释一下为什么必须重叠。如果一句话跨在两个切片之间,前一个切片里缺了后半句,后一个切片里没有前半句,整句语义就断了。重叠部分能给切片之间的语义“补缝”,让跨边界的内容至少完整出现在某个切片里。下表是不同参数的实际表现,我在 2000 多条内部问答测试集上跑过一轮对比。
| 切分策略 | 切片长度 | 重叠长度 | 检索命中率 | 上下文完整性 |
|---|---|---|---|---|
| 固定切分 | 200 | 0 | 61% | 较差 |
| 固定切分 | 500 | 50 | 74% | 中等 |
| 递归按语义切分 | 600 | 100 | 88% | 较好 |
| 递归按语义切分 | 800 | 120 | 92% | 好 |
这里有一点要提醒:切片长度并不是越大越好。我试过 1500 字的大切片,检索命中率没有明显提升,反而消耗了更多大模型输入 token,回答时把太多背景信息塞进去,重点内容容易被稀释。所以最后锁定的就是 800 字左右、重叠 100 到 120 字这个区间,兼顾完整性和精准度。
2.2 向量化与索引配置
向量化就是把一段文字用一个高维数组表示,语义相近的文本,向量在高维空间里的距离也近。做好向量化,第一步是选对嵌入模型。如果文档内容是中文为主,就必须选在中文语料上有较好表现的模型,这一点我吃过亏——第一版用一个面向英文优化的通用嵌入模型,中文长句的语义相似度计算经常错乱,问“报销流程怎么走”和“费用报销办法”明明是一回事,向量距离却很远。后来换成中文语料训练的嵌入模型,召回率肉眼可见地提升。
索引层面我用的是一种支持近邻搜索的向量存储,核心参数包括索引类型、邻居数和查询返回数。这类索引的底层逻辑是空间分层:把向量组织成多层图,搜索时从粗糙层起步逐层下钻,既能保证速度,也能维持一定准确度。我一般把邻居数设为 16 到 32,查询时取 50 个候选,这样既不牺牲太多召回率,查询耗时也能控制在百毫秒以内。如果文档量在十万条切片以下,参数不是特别敏感,但超过百万条后,索引参数的调优就会直接影响线上体验。
入库前还有一个小操作:向量归一化。把所有向量都缩放到单位长度,这样计算相似度时直接用内积等价于用余弦相似度,少一次额外计算,也更容易设定统一的阈值。很多向量库默认就是这么处理的,但如果你是自己实现,别漏掉这一步。
2.3 检索策略:单靠向量检索真的不够
第一版我只用了向量检索,上线后发现两类问题:一是精确词匹配失效,比如用户输入的编号是“QO-2024-009”,向量检索很难把它和文档里的原文精确对上;二是领域缩写和同义表达混合时,向量检索可能把不太相关的长文档排到前面。这时候我加了关键词检索。关键词检索不依赖语义,直接按词项匹配算分,对编号、专有名词非常有效。最后把向量检索结果和关键词检索结果合并,取并集,再用重排序模型统一打分,效果立刻不一样。
重排序这个环节,是很多人做 RAG 会跳过但值得认真考虑的一步。向量检索和关键词检索各自返回前几十条结果,合并后可能有重复、有噪音,直接全塞给大模型既浪费 token 又容易干扰判断。我的做法是:先用向量和关键词各自取前 50 条合并成候选池,再用一个轻量级重排序模型逐条计算查询与切片的相关性分数,最后只保留前 5 条送给生成层。这个过程相当于把“粗筛”和“精排”分开,保证真正有价值的内容排在前面。
重排序模型的选择也讲技巧。优先选能在 CPU 上推理的小模型,因为精排阶段只需处理几十条文本,对绝对推理速度要求不高,但对中文语义理解能力有一定要求。我对比过几个主流方案,准确率差异确实存在,但没有到离谱的程度,反而是“用还是不用”这一层决策带来的收益最大。可别小看合并策略,它让检索命中率从那 65% 涨到了接近 85%,重排序又在这基础上往上拉了几个点。
2.4 Prompt 设计和生成参数
检索做得好,如果 Prompt 写得烂,答案照样一塌糊涂。我给“rea”设计的提示词模板核心是三条约束:只能依据检索到的内容回答;如果检索内容里找不到答案,直接说明不知道,不要编造;回答时把对应来源标注出来。这三条约束看起来简单,但缺一不可。
你是一个严谨的文档助手。请根据下面的参考片段回答问题。参考片段来自企业内部资料库,可能包含多条内容。
回答要求:
- 只使用参考片段中的信息作答,不要使用你固有的知识。
- 如果参考片段中没有相关内容,请回答“资料库中未找到相关信息”。
- 在回答末尾列出参考的文档标题。
参考片段: {context}
问题: {question}
生成温度这个参数我也做过实验。温度越高,回答越多样但越容易发散;温度越低,回答越保守越稳定。对知识问答场景,我把温度定在 0.1 到 0.2,基本就是“点谁打谁”的状态,不会自由发挥。还有个细节是同时关闭“随机采样”以外的纠偏配置,不然模型可能硬把话题圆回来。生成层还要控制输出长度,我在调用接口时设置了最大输出 token 上限,避免模型为了凑字数把原本简洁的答案扩写成长篇废话。
另外,Prompt 里检索片段过多也会适得其反。我最初把 10 条检索结果全塞进去,模型经常被不相关内容干扰,后来裁剪到 5 条,回答稳定性和准确性反而上升。这里的逻辑是:数量越少,上下文越聚焦,干扰项越少。前提是这 5 条确实包含正确答案,所以要先把检索质量做扎实,再来谈生成层的精简。
3. 实操过程与核心环节实现
3.1 环境与依赖准备
动手之前先把环境搭好。我建议直接用 Python 3.10 以上的虚拟环境,避免系统 Python 环境被搞乱。基础依赖包括文档解析、文本切分、嵌入模型、向量存储和大模型调用这五类库。在真正开始写代码前,先跑通最小闭环:一段文本 → 向量化 → 存入向量存储 → 查询返回结果。这一步能提前暴露环境问题和模型加载问题,免得后边写到一半才发现底层组件不工作。
python -m venv rea_env source rea_env/bin/activate pip install python-docx pypdf openpyxl pip install sentence-transformers pip install vector-store-client pip install openai安装完之后,做一个 10 分钟的自测:加载一个短文本,调一个嵌入模型转到向量,写进向量存储,再查询同样的问题看能不能召回。如果这步顺畅,后面就只是业务逻辑的堆叠。没有先跑通基线就急着做完整系统,是很多同类项目翻车的第一原因。
3.2 数据准备与切分:从原始文档到干净切片
数据准备这一步,我写了一个统一的数据加载脚本。脚本扫描指定目录下的所有文件,根据扩展名分发到不同解析器,输出统一格式的文档对象,包含元信息(标题、路径、更新时间)和正文内容。正文清洗规则集中在一个函数里,方便后续调整。
def clean_text(text: str) -> str: # 去除页眉页脚常见噪声 lines = [ln.strip() for ln in text.splitlines() if ln.strip()] # 跳过纯图片说明行 lines = [ln for ln in lines if not ln.startswith("图 ") if not ln.startswith("表 ")] # 合并被断行拆开的中文语句 text = "".join(lines) return text切分逻辑使用的是递归字符文本切分器,它优先按照段落标记切分,段落过长再按句子边界切分,尽可能避免把一句话拦腰切断。我在每个切片对象里都把来源文档的标题和原段落序号记录进去,这样后面溯源时可以直接定位到原始文档。
from custom_text_splitter import RecursiveTextSplitter splitter = RecursiveTextSplitter( chunk_size=800, chunk_overlap=120, separators=["\n\n", "\n", "。", ";"], ) chunks = splitter.split_text(cleaned_docs)切分之后不要急着入库,先抽样看几条结果。重点检查两个地方:切片里是否出现上下文断裂,重叠区域是否正常;切片是否残留页眉或者目录噪声。实测中我发现很多看似干净的文档,清洗后仍会残留下划线、特殊符号,这些内容在向量化时容易被当成独立的语义特征,干扰检索。为此我在清洗函数里再加了一层过滤,把不常见的控制字符和连续标点直接移除。
3.3 向量化与索引构建
向量化环节我封装了一个模型加载函数。嵌入模型在本机加载之后,经过第一次调用预热(跑一两条文本),后续推理速度才会稳定。对于首批 5000 个切片,在普通显卡上大概需要几分钟,CPU 环境会慢一些,但也能接受。这里有个建议:向量化过程最好做批量处理,一次把几十条文本喂给模型,比一条一条循环快得多。
from local_embedding import load_embedding_model model = load_embedding_model("zh_text_embedding_v3") batch_size = 64 vector_store = init_vector_store(index_type="HNSW") for i in range(0, len(chunks), batch_size): batch = chunks[i:i+batch_size] vectors = model.encode(batch, normalize_embeddings=True) vector_store.add_documents( documents=batch, embeddings=vectors, metadatas=[{"source": doc.source} for doc in batch] )索引构建完成后要做一个简单抽样测试:挑出 5 个典型问题,不写生成层,先直接看检索返回的片段是不是和问题相关。这一步能单独验证索引和检索质量。我在第一版就把“生成”和“检索”混淆在一起调优,结果总是说不清问题出在哪,后来强制自己先把检索层调到满意,再碰生成层,效率才高起来。向量存储还会定期更新,我写了一个增量索引脚本,只对新文档做向量化,避免每次全量重建。
3.4 问答主流程串联
主流程其实就是把检索和生成串起来。用户输入问题后,第一步把问题也向量化,得到查询向量;第二步同时做向量检索和关键词检索,合并候选;第三步重排序取前 5;第四步构造提示词;第五步调用大模型接口。我在这里用伪代码把主流程展示出来,实际项目中你只需要替换成对应库的 API 即可。
def answer(question: str) -> str: # 检索阶段 vec_results = vector_store.search_by_vector( model.encode(question), top_k=50 ) kw_results = keyword_index.search(question, top_k=50) candidates = merge_results(vec_results, kw_results) # 重排序 reranked = reranker.rerank(question, candidates, top_k=5) # 生成阶段 context = "\n\n".join([c.text for c in reranked]) prompt = prompt_template.format(context=context, question=question) answer_text = llm.chat(prompt, temperature=0.2) return answer_text这段代码最关键的地方是 merge_results。两个检索结果的打分口径不一样,不能直接相加,我用的是“先并集去重,再交给重排序统一打分”的思路。重排序模型看到的是明文文本,不关心原来向量分数是多少,这样就绕开了分数归一化的问题。我在这个流程上线后观察了几天,发现回答质量非常依赖候选池里的内容是否真的覆盖了正确答案,所以宁可每一步多召回一些,也不能在早期阶段把候选砍得太狠。
3.5 调参与成本控制
这套系统跑起来之后,真正费钱的是大模型接口调用。每个问题哪怕没有检索到内容,也要付出至少一轮输入和输出的费用。为了控制成本,我做了三层措施。第一层是缓存:完全相同或相近的问题在一天内重复出现时,直接返回前一天的结果,不再调用模型。第二层是合理的候选数量:从 10 条降到 5 条之后,tokens 消耗几乎减半,而答案质量没有下降,这本身就是最直接的成本优化。第三层是模型分级:简单问题用轻量级模型,只有复杂问题才走大模型,整体费用能再降两到三成。
性能方面也要看峰值。团队内部几十个人同时用的时候,向量检索和重排序都跑在 CPU 上,扛得住;但到了大模型接口并发调用阶段,如果所有请求都同步排队,用户会明显感觉到卡顿。我用了一个简单的异步任务队列,把请求排队后立即返回一个“查询中”状态,用户几秒后刷新再拿结果。对内部知识问答这种场景,延迟三到五秒完全可以接受,没必要为了实时性去烧高成本的专用算力资源。实际上“rea”项目最后部署在普通服务器加云服务商的大模型接口上,成本控制得相当理想。
4. 常见问题与排查技巧实录
4.1 检索结果不准:先问自己三个问题
检索结果不准,是 RAG 项目里最常见也最让人挠头的问题。遇到这种情况,先别急着调生成层,按照下面的顺序排查:第一个问题,测试语句是否包含专有名词或编号?第二个问题,切片是否过短或过长?第三个问题,向量模型对中文语义的区分能力是否够用?
举一个我真实遇到的例子。当时问“半年考核归档的截止日期是什么”,检索返回的片段是文档目录和标题,正文完全没被命中。后来一查,切片长度 200 字时,正文关键段落被切得七零八落,匹配分数自然很低。我调整到 800 字并加上重叠后,再测同一句话,命中片段已经能在前 3 里出现。还有一个案例是用户输入了繁体“截止”和简体“截止”,向量模型对这两种写法处理得不好,后来我在清洗阶段做了简繁归一化,问题迎刃而解。这条完整的排查路径我整理成了下面的速查表。
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 检索结果与主题完全无关 | 嵌入模型对中文支持差 | 换中文语料优化的嵌入模型 |
| 多个相关文档被漏掉 | 切片太短或重叠不够 | 调大切片长度,增加重叠 |
| 精确编号无法命中 | 向量检索对精确词不敏感 | 增加关键词检索并合并结果 |
| 繁体、全角、异体字干扰 | 文本未规范化 | 清洗阶段统一简繁和全角半角 |
| 召回内容多但关键信息缺失 | 检索候选数太少 | 提高 top_k,再交给重排序精排 |
4.2 生成的回答与资料矛盾:多半是幻觉和上下文污染
生成内容与检索资料不一致,是 RAG 最容易让人失去信心的故障。我排查后发现两类原因:一是模型确实产生了幻觉,二是上下文里混入了太多不相关片段,模型被干扰了。第一个原因好理解,第二个原因在代码里很难发现,因为提示词里有 5 条检索结果,其中可能有 1 条与问题无关,模型却从这条无关内容里抓取了一个词强行组织进答案。
针对幻觉,我把温度降到 0.2,同时在提示词里明确写了“如果参考片段中没有相关内容,请回答资料库中未找到相关信息”,效果立竿见影。针对上下文污染,我调整了重排序的阈值,低于某个相关度分数的片段直接不进入候选池。这一步实际上是用“宁缺毋滥”的思路来保护生成质量。不要怕候选不够,宁可回答“未找到”,也比硬编造一段似是而非的答案好得多,特别是面向企业内部流程时,错误回答会直接误导员工做事。
4.3 中文编码与特殊符号的坑
处理中文文档,编码问题永远是避不开的同行者。我遇到过三种情况:一是从旧系统导出的 Word 里夹带 GBK 编码,直接按 UTF-8 读取会乱码;二是 PDF 解析出的文本里混入大量的全角空格和特殊换行符;三是 Markdown 文档里的反引号、双星号和表格竖线被当成纯文本,干扰切分和检索。第一类问题通过自动检测文件编码解决,第二类在清洗阶段用正则把全角空格转半角、连续换行并一行,第三类要看文档库的实际情况,决定是否去掉 Markdown 标记。
还有一个容易被忽略的点:切片结果的存储编码。如果向量库的文本字段默认编码不是 UTF-8,后续做关键词检索时会导致中文分词错乱。我在建库之前统一把所有文本做了规范化检查,包括去掉零宽字符和控制字符。做中文 RAG,这一套编码处理流程跑不掉的,早做早省心。
4.4 长文本截断与耗时优化
当文档本身特别长,比如一份几百页的操作手册,被整体切分入库后,切片数量会很大,检索和重排的时间随之上升。更麻烦的是,如果一个问题的答案分散在第 20 页和第 80 页,单个切片无法覆盖完整答案,检索回来的片段可能都只是局部信息。针对这种情况,我在索引层额外维护了一层“章节摘要”:把每个章节先做摘要存入一个较小的向量库,问题先命中章节,再在章节内部做细粒度检索。这相当于先定题再翻页,检索精度和速度都有提升。
耗时优化上,关键是不要等重排序跑完才看到结果。实际线上流程里,重排序虽然是轻量模型,但几十条候选逐条计算也要几百毫秒。我在高并发时会动态把候选数从 50 降到 30,重排时间能缩短三分之一。至于大模型生成环节,这是用户感知最明显的一段延迟,我会把生成超时设置为 15 秒,如果超过直接提示用户稍后重试,而不是让页面无限转圈。合理使用缓存和异步任务队列后,“rea”的 P90 响应时间从最初的 12 秒降到了 7 秒左右,对内部工具来说已经相当够用。
所有东西调完上线之后,我最深的体会是:RAG 项目失败的头号原因不是模型选得不好,而是检索环节和数据处理环节做得不够细。每次改动切分参数、清洗规则或者重排序策略,我都在本地拉着那套问答测试集跑一遍评分,用数据说话而不是凭感觉。这个项目后来扩展出了不少新能力,比如把每日更新的资讯文档自动接入索引,又比如做了一套简单的权限过滤,让不同角色只能检索到各自范围内的资料。可以说,只要数据质量做扎实了,上层的能力扩展会变得非常顺。如果你也在做类似的知识库问答系统,先在数据处理和检索评估上多花点时间,后面你会感谢现在的自己。