微信扫码
添加专属顾问
我要投稿
从1600多份Word文档到高效RAG系统,揭秘工控行业知识库的实战经验与全链路优化。核心内容: 1. 工控行业售后场景的特殊性与知识库需求 2. 数据清洗、检索策略与架构升级的全流程实践 3. 从POC到MVP的产品化思考与交付经验
Agent 这个词,25 年下半年以来已经有点烂大街了。从我下半年聊过的大几十个项目里看,绝大多数企业实际连知识库都还没整明白就去追求 Agent,纯属本末倒置。知识库未必是所有场景的前置条件,但如果你想让工作流和 Agent 真正能用,把散落在多源异构文档、业务专家脑子里的经验沉淀下来,往往是绕不开的基础工作。
知识库看起来是个老生常谈的需求,但真正做好并不容易。多源异构的非结构化数据怎么清洗、怎么切分、怎么让检索又快又准,这些工程细节是项目成败的关键。此外,知识库是一个可以持续迭代的闭环,用户反馈标注的数据,也是高价值的训练素材。
今天分享的这个落地案例,是一家工控软件厂商的售后知识库项目。他们有 4 万多家存量客户、20 多个工程师,日常大量时间都在应付微信群里的技术咨询。我用两周时间帮他们完成了从数据清洗到系统交付的全流程,目前已经在内部试点使用,检索效果和回答质量都达到了可用于生产的水平。
这篇试图说清楚:
工控行业售后场景的特殊性、为什么选择手搓而不用开源框架、1600 多份 Word 文档的数据清洗与元数据增强、从"搜到了但答错"到高召回率的检索策略迭代、前端从 Streamlit 到 Next.js 的架构升级,以及面向企业级交付的产品化思考等整个项目从需求到交付的全过程。
以下,enjoy:
1
在深入技术细节之前,先看看POC和MVP两个版本最终交付的效果。
1.1
POC 版本 (Streamlit)
POC 阶段的核心目标是验证"数据清洗 + 检索精度"的可行性,用 Streamlit 快速搭建前端做功能验证。
整条链路从数据侧看,是 Word → Markdown + DOCX → 元数据增强 → 向量入库;从查询侧看,是用户问题 → Query Rewrite(解决多轮对话指代问题)→ 向量召回 → Rerank 精排 → Parent-Child 扩展(获取完整文档上下文)→ LLM 生成。这套架构在 POC 阶段就已经跑通,核心检索逻辑后续直接复用到 MVP。
1.2
MVP 版本 (FastAPI + Next.js)
MVP 阶段的核心目标是从"能用"到"好用",支撑部门级试点。
架构上最大的变化是前后端分离:后端用 FastAPI 封装 API,前端用 Next.js 重写交互。RAG 引擎核心代码直接从 POC 复用,没有重写。新增的主要是前端体验层的功能:SSE 流式输出实现打字机效果、SWR 缓存实现历史会话秒开、点踩时弹出输入框收集用户反馈原因、以及一个运营 Dashboard 展示调用量和满意度。数据层增加了 MinIO 做图片存储,解决了 Markdown 图片在生产环境的访问问题。
2
2.1
客户画像
2.2
工控行业的特殊性
工控软件的售后,跟普通 SaaS 客服有明显不同,为了方便各位理解,我从以下五个维度做了简要对比:
一句话总结:环境不可控,问题非标化。同样是"连不上 PLC",可能是驱动版本问题、网络配置问题、PLC 固件问题、权限问题等等。而问题的答案散落在内部论坛、产品手册、老工程师的脑子里。
2.3
真实售后场景还原
第一次做需求沟通的时候,对方负责人提到了一个比较典型的驱动兼容问题场景。客户在售后群里发消息:软件装好了,连 PLC 连不上,一直报通讯超时。
群里工程师的处理流程大概是这样:先初步判断可能是驱动版本问题、网络配置问题还是 PLC 固件兼容问题;然后去内部知识库搜索,找到一篇《XX 系列 PLC 连接故障排查指南》。但这篇文档是写给技术人员的,列了一堆排查步骤和参数配置,不能直接甩给客户。于是工程师得人肉翻译一遍,把关键步骤用客户能看懂的语言重新组织发到群里。结果客户回一句"按你说的改了,还是不行"。然后又得继续排查下一个可能的原因。
类似的场景还有老旧系统兼容问题。客户现场的工控机可能还跑着 Windows XP,安装时报错缺少某个运行库。这类问题工程师隔三差五就会碰到,内部论坛里其实有解决方案,但每次都得重新搜、重新解释,而且不同 Windows 版本的处理方式还不太一样。
还有一类场景涉及售前支持。销售在跟进潜在客户时,经常需要工程师帮忙回答技术问题:软件支持哪些品牌的 PLC?OPC UA 协议支持到什么程度?能不能对接客户现有的 MES 系统?这类协议覆盖率的问题,工程师自己也未必记得全,得翻产品手册、查历史项目案例。但问题是,工程师的时间大量被售后群里的重复问题占用,真正需要他们发挥专业价值的售前场景,反而容易被挤占。
2.4
痛点总结
梳理下来,这类场景的痛点其实挺典型的。
首先是知识碎片化。解决方案散落在内部论坛帖子、各种 Word 文档、甚至老工程师的脑子里。同一个问题,不同工程师可能给出不同的解法,而且很难沉淀下来复用。新来的工程师想找答案,全靠问老同事或者自己摸索。
其次是搜索失效。客户描述问题用的词和技术文档里的术语经常对不上。客户说"连不上",文档里写的是"通讯超时"。客户说"打不开",可能是权限问题、环境问题或者安装问题。传统的关键词搜索很难处理这种语义 gap。
第三是人力错配。高级工程师的时间被大量重复性的售后问题占用,真正高价值的售前支持反而没精力做。售前咨询往往是帮销售拿单的关键环节,但工程师被群消息淹没,响应不及时,客户体验自然也不好。
2.5
为什么知识库是"刚需"
对于这家客户来说,知识库不只是一个锦上添花的工具,而是业务发展的基础设施。
短期来看,知识库能直接提升售后响应效率。工程师遇到问题不用每次都重新搜、重新组织语言,系统能快速给出参考答案和相关文档。即便不能完全替代人工,也能显著减少重复劳动。
中长期来看,更重要的是数据沉淀。企业内部那些散落的非结构化文档、业务专家的经验、历史项目的案例,这些知识资产需要一个载体来承接和积累。知识库系统本身就是这个载体。工程师在使用过程中,每一次"这个答案有帮助"或"这个答案不对"的反馈,都是在帮系统调优,也是在把隐性知识显性化。
从这个角度看,工程师的角色其实是双重的:他们既是知识库的用户,也是重要的人工标注者。系统越用越准,知识越积越厚,逐步就能实现从人工响应到半自动、再到自动化的演进。一方面更好地承接现有的售前售后需求,另一方面在人力规模不变的情况下,能够支撑更大的业务体量。
3
市面上主流的开源 RAG 框架的优势很明显:封装度高、开箱即用,文档上传之后几分钟就能跑起来一个可演示的 Demo。但相应的代价是灵活性和可扩展性受限,很多细节没法深度定制。
3.1
核心考量
对于企业级项目来说,业务方要的不只是一个能演示的原型,而是一个能长期使用、持续迭代的系统。这意味着系统要能接入客户现有的业务入口(比如企微机器人、内部 OA),要能根据实际效果调整检索策略,出了问题要能追溯到具体环节。这些需求在封装度较高的框架里往往不太好实现。
当然,这并不是说开源框架不能用。如果项目周期短、定制需求少、主要目的是快速验证可行性,用现成框架是完全合理的选择。但这个项目的情况不太一样:客户希望这套系统能逐步从效率工具演进成企业的知识基础设施,前期是提升售后响应效率,后期是沉淀业务经验、支撑更大体量的业务。这种长期迭代的诉求,决定了需要对各个环节有足够的控制力。
3.2
具体差异
从实际开发的角度,手搓方案和开源框架的差异主要体现在几个地方。
数据清洗这块,理论上可以线下处理好再上传到框架里,但实际操作中清洗逻辑往往要反复调整。这个项目的 1600 多份文档里有大量论坛爬取的内容,噪音类型很杂,需要针对性地写正则规则,跑完还得人工抽检。这个过程本身就是高度定制化的,跟框架关系不大。
Embedding 模型方面,主流框架其实都支持接入第三方模型。但这个项目在开发过程中切换了三次模型(从本地 Ollama 到 OpenRouter 再到最终方案),每次切换都涉及向量空间兼容性的问题,需要重新入库。这种灵活调整在框架里也能做,只是排查问题的时候会多一层黑盒。
检索策略是差异最明显的地方。这个项目用了 Parent-Child 连坐召回加本地 Rerank 的方案,检索链路比较长,中间有不少调参的空间。如果用框架的话,这部分逻辑往往被封装得比较深,想调整就得去改框架源码。
前端交付方面,客户希望有独立的 Web 界面,后续可能还要接入企微。框架自带的 Chat UI 不太满足需求,最后还是得自己用 Next.js 写一套。
3.3
关于产品化的思考
经常会有人问我做了这么多知识库项目,为什么没有沉淀出一个成熟的产品或框架?
我的理解是,知识库这类场景,工作量的大头在数据处理而不在框架本身。每个客户的文档结构、噪音类型、业务术语都不一样,就算有一个完美的框架,碰到几千份文档里夹杂的时间戳、下载统计、论坛回复,还是得一条条写规则清洗。这部分工作没有太多捷径可走。
真正能复用的,其实是工程经验和最佳实践。比如数据清洗的标准流程和质检方法、Embedding 和 Rerank 模型的选型决策依据、检索策略的调参方法论、反馈闭环的数据库设计模板。这些经验在不同项目间是可以迁移的。代码本身反而是相对次要的部分,因为每个项目的技术栈和部署环境都不太一样。
目前我在做的事情,是把一些通用性比较强的模块抽象出来:清洗工具链、向量入库管道、反馈数据收集框架。这些模块还不是一个完整的产品,但能显著降低后续项目的启动成本。
4
这部分占了整个项目大约 70% 的实际工作量。很多人以为 RAG 的核心是模型或框架,但实际做下来会发现,数据质量才是决定效果的关键因素。
4.1
原始数据摸底
拿到数据后我先做了一轮整体盘点。这批文档来自客户内部的知识管理系统,是从内网 wiki 导出的,全部是 Word 格式。总共有 1600 多份,文档长度分布极不均匀:最短的只有几百字,属于简单的 FAQ 类型;最长的接近 3 万字,是完整的产品配置手册。统计下来,大概有 98% 的文档字数在 5000 字以内,只有约 2% 属于超长文档。这个分布特点也影响了后续的分块策略设计。
4.2
噪音分析与采样
数据清洗不能盲目开干,得先搞清楚噪音长什么样。我采用的是多轮抽样加人工审核的方式。
第一轮随机抽了 10 份文档,用 python-docx 提取纯文本内容,然后把这些文本丢给大模型,让它识别里面哪些内容属于噪音、哪些是真正有价值的知识。大模型在这一步很有用,它能快速发现一些不容易注意到的模式,比如论坛特有的元信息格式。第二轮又随机抽了 20 份,重点覆盖第一轮没涉及到的目录和分类。每轮抽完之后,把识别出的噪音模式整理成正则规则,跑一遍全量数据,再抽样验证清洗效果。这个过程迭代了三四轮,才基本收敛。
这批文档的噪音类型比较典型。因为是从内网 wiki 导出的,所以夹杂了大量页面元信息:页眉页脚的重复文字、上传时间戳("2024-03-15 14:30 上传")、下载统计("(1.09 MB, 下载次数: 118)")、论坛交互元素("点击文件名下载附件"、"您的浏览器不支持 video")等等。另外还有一些代码块的格式残留(比如行尾的"复制代码"按钮文字)、纯数字行、用户抱怨性质的内容("文件下载不了")——这些都不是真正的知识,需要清掉。
注:红框圈出的都是噪音信息(对比下方图片清洗后效果)
如果是其他类型的知识库场景,噪音类型可能会有所不同。比如从 PDF 转换过来的文档,常见的噪音是页码、表头重复、分栏错位;从邮件归档导出的内容,噪音可能是邮件签名、转发链、自动回复;从客服工单系统导出的数据,噪音往往是工单模板字段、系统生成的流程日志。总之,噪音分析是高度场景相关的,没有通用方案,只能针对性处理。
4.3
噪音清洗规则
基于多轮采样的结果,最终整理出一份相当长的正则规则清单。核心逻辑是匹配后直接跳过这一行,不进入后续处理流程。下面是部分典型的规则:
NOISE_PATTERNS = [# 页面结构类噪音r'.* - 汇总信息$', # 页尾汇总标题r'^帖子列表$', # 论坛导航元素r'^详细帖子内容$', # 论坛模板文字r'^未知标题$', # 占位符标题# 论坛交互类噪音r'^共找到 \d+ 个帖子$', # 搜索结果统计r'^【主帖内容】$', # 论坛结构标记r'^【回复 \d+ - .*】$', # 回复区标记r'^回复时间:.*$', # 回复时间戳r'^暂无回复$', # 空回复提示r'^回复列表$', # 回复区标题# 附件与下载类噪音r'^点击文件名下载附件$', # 附件提示r'^附件下载$', # 下载区标题r'^\([\d\.]+\s*(MB|KB),\s*下载次数:\s*\d+\)$', # 下载统计r'^.*\.zip$', # 纯附件名行r'^文件下载不了$', # 用户抱怨(非知识)# 多媒体与格式类噪音r'^您的浏览器不支持 video 或 audio 标签$', # 多媒体兼容提示r'复制代码$', # 代码块按钮残留r'^-+$', # 分隔线r'^\d+$', # 纯数字行(页码等)]# 时间戳格式多变,单独处理UPLOAD_TIME_PATTERN = re.compile(r'^\d{4}-\d{1,2}-\d{1,2} \d{1,2}:\d{1,2} 上传$')# 图片占位符的识别模式IMAGE_PLACEHOLDER_PATTERN = re.compile(r'^(.*?\.(?:png|jpg|jpeg|gif|bmp))\s*\(.*下载次数.*\)')
这些规则需要根据实际数据不断调整。一开始写得太宽松,会漏掉噪音;写得太激进,又可能误删有用内容。所以每次调规则之后,都要重新抽样验证一遍。
4.4
双重输出:机器侧 + 用户侧
清洗流程最终设计成一次处理、双重输出的模式:
之所以同时输出两种格式,是因为它们服务不同的目的。Markdown 是给机器看的,方便后续的切分和向量化,取消了原始 Word 的格式复杂性。Clean DOCX 是给用户看的,当系统返回答案并附上引用来源时,用户可以点击卡片直接打开原始文档。打开后还能看到完整的格式、表格、图片,并且标题做了高亮处理,方便快速定位。
核心代码逻辑大概是这样:
def process_file(file_path):doc = Document(file_path)clean_doc = Document()md_lines = []# 1. 提取嵌入图片,保存到 images 目录for element in doc.element.body.iter():if element.tag.endswith('blip'):# 保存图片,记录路径pass# 2. 逐段处理内容for para in doc.paragraphs:text = para.text.strip()if is_noise(text):continueif is_image_placeholder(text):md_lines.append(f"")clean_doc.add_picture(img_path)else:md_lines.append(text)clean_doc.add_paragraph(text)# 3. 双重保存clean_doc.save(f"{title}_Clean.docx")save_markdown(md_lines, f"{title}.md")
这个流程跑完之后,每份原始文档会产出两个清洗版本,再加上一条元数据记录(包含文件路径、分类、标题等)。
4.5
LLM 元数据增强
清洗完成后,元数据里只有物理属性:文件名、路径、目录分类。这些信息对于检索来说是不够的。举个例子,用户问"MQTT 怎么配置",但文档标题可能叫《数据采集通讯模块使用说明》,光靠文件名匹配是搜不到的。
为了解决这个问题,引入了 LLM 元数据增强。基本思路是让大模型阅读每份文档的内容,然后生成三类语义信息:一句话摘要(summary)、5-8 个关键技术术语(keywords)、以及 3-5 个"这份文档能回答的问题"(questions)。
这样做的好处是多维度的。摘要可以用于结果页的快速预览,用户不用点开就能大概知道这份文档讲什么。关键词可以作为辅助召回手段,弥补向量检索在专业术语上的不足。而 questions 字段配合 HyDE(Hypothetical Document Embedding)检索策略,能显著提升问答场景的召回率,因为用户的问法和文档预设的问法往往更接近。
批量处理时用了多线程加断点续传的机制,每处理 10 条就保存一次进度,避免中途中断丢失数据。调用的是云端大模型 API,并发跑下来整体还是比较快的。
def process_item(item):# 断点续传:跳过已处理的if item.get("summary"):return# 调用 LLM 生成元数据result = llm.generate(prompt, content[:5000])item["summary"] = result["summary"]item["keywords"] = result["keywords"]item["questions"] = result["questions"]
最终产出的元数据结构大概是这样:
{"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890","filename": "数据采集通讯配置.docx","title": "数据采集通讯配置","category": "通讯协议/MQTT","summary": "介绍了组态软件中配置 MQTT 通讯的完整步骤,包括 Broker 连接参数和 Topic 订阅方式。","keywords": ["MQTT", "Broker", "Topic", "QoS", "通讯配置"],"questions": ["如何配置 MQTT 连接?","Topic 格式怎么填写?","QoS 级别有什么区别?"]}
4.6
图片处理:从 Word 提取到 MinIO 服务
工控场景的技术文档有一个很明显的特点:图文并茂。配置步骤、界面截图、连线示意图、报错截图,这些图片本身就是知识的一部分不能丢。如果用户问“这个参数在哪里设置”,系统只给一段文字说明,体验像的文档一样,跟看原文没区别。但如果能给一张截图“就是这个位置”,用户体验完全不一样。
数据清洗阶段,我把 Word 里的嵌入图片都提取出来,保存到本地 images 目录。Markdown 版本里用标准的图片语法引用,比如 。这样 LLM 生成回答的时候,如果参考文档里有图片,它会把图片链接一起返回。
POC 阶段用的是 Base64 内嵌方案:读取本地图片文件,转成 Base64 字符串,直接嵌入 HTML。这个方案简单粗暴,能用但不好用。图片内嵌在响应里,体积大、加载慢、没法缓存。
MVP 阶段引入了 MinIO 对象存储,走标准 HTTP 服务的路子。对象存储的好处是轻量、通用、无缝集成到各种前端框架。图片通过 HTTP URL 访问,浏览器可以正常缓存,后续还能接 CDN 加速。
整个流程分两步。第一步是离线迁移:遍历本地 images 目录,把所有图片上传到 MinIO,同时生成一份映射文件(JSON 格式),记录本地路径和 HTTP URL 的对应关系。
# 离线迁移:上传图片并生成映射文件image_mapping = {}for file_path in images_dir.glob("/*"):if file_path.is_file():# 上传到 MinIOclient.fput_object(bucket_name, file_path.name, str(file_path))# 生成 HTTP URLurl = f"http://{minio_endpoint}/{bucket_name}/{file_path.name}"# 记录映射关系: "images/xxx.png" -> "http://..."image_mapping[f"images/{file_path.name}"] = url# 保存映射文件with open("image_url_mapping.json", "w") as f:json.dump(image_mapping, f)
第二步是在线替换:LLM 返回的回答里如果包含 Markdown 图片语法,在返回前端之前做一次 URL 替换。读取映射文件,用正则找到  这样的模式,把本地路径换成 MinIO 的 HTTP URL。
def process_markdown_images(self, text):# 加载映射文件with open("image_url_mapping.json", "r") as f:image_mapping = json.load(f)# 正则匹配 Markdown 图片语法pattern = r'!\[(.*?)\]\(images/(.*?)\)'def replace_with_url(match):alt_text = match.group(1)filename = match.group(2)key = f"images/{filename}"if key in image_mapping:url = image_mapping[key].replace(" ", "%20") # URL 编码空格return f''return match.group(0) # 未找到则保持原样return re.sub(pattern, replace_with_url, text)
这套方案的好处是解耦。数据层只负责上传和生成映射,业务层只负责查表替换。后续如果换存储方案(比如从 MinIO 换到云厂商的 OSS),只需要重新跑一遍离线迁移脚本,业务代码不用改。
5
这部分讲的是从"搜到了但答错"到检索效果稳定可用的迭代过程。检索层是 RAG 系统的核心,如果召回的文档不对,后面 LLM 再强也没用。
5.1
Embedding 模型选型
项目初期我尝试过本地部署 Embedding 模型,用 Ollama 跑了一个 bge-m3。本地推理的好处是零成本、无网络依赖,但稳定性是个大问题。当文档长度超过 2000-5000 字符时,Ollama 进程会频繁崩溃,跑批量入库时失败率很高。
后来切换到云端 API 方案,用的是 OpenRouter 的 Qwen3-Embedding 模型。这个模型支持 8k 以上的上下文窗口,完全兼容 LangChain 的接口,迁移成本很低。稳定性和速度都比本地方案好很多。
# Embedding 模型配置embedding_function = OpenAIEmbeddings(model="qwen/qwen3-embedding-8b",openai_api_key=OPENROUTER_API_KEY,openai_api_base="https://openrouter.ai/api/v1",check_embedding_ctx_length=False)
5.2
分块策略的工程权衡
前面提到,这批文档 98% 都在 5000 字以内,只有 2% 是超长文档。这个分布特点决定了分块策略不能太激进。
对于短文档选择整篇入库,不做切分。这样能保持语义完整性,检索时不会出现"断章取义"的问题。对于超过 6000 字的长文档,使用 RecursiveCharacterTextSplitter 进行切分,chunk_size 设为 6000,overlap 设为 500。
为什么选 6000 而不是更长或更短?这是一个平衡点。它足够容纳大多数完整的技术章节,同时又不会因为太长导致检索时的大海捞针问题。
另外一个关键设计是富语义文本块。每个切片的开头都会附带文档的标题、摘要和关键词。这样即使检索到的是文档的第 N 个切片,LLM 也能通过附带的上下文信息理解这篇文档的整体背景。
# 富语义文本块结构enriched_content = f"""Title: {metadata['title']}Summary: {metadata['summary']}Keywords: {', '.join(metadata['keywords'])}Content Chunk: {chunk_text}"""
5.3
Parent-Child 连坐召回
这是检索层最重要的优化,也是解决"找到了但答错了"问题的关键。
最初的问题是这样的:用户问一个具体的技术参数,向量检索确实返回了正确的文档,但 LLM 的回答却是"未找到相关信息"。排查后发现,问题出在入库策略上。每个切片开头都附带了全文摘要,当用户问宏观问题时,Chunk 0(包含简介和摘要)的语义相似度最高,"霸榜"了 Top-K。而真正包含参数表格的正文切片因为排名靠后被丢弃了。
最终方案是引入"连坐召回"策略。核心思路是:把切片视为"子文档",把整篇文档视为"父文档"。只要检索到了任意一个切片,就强制召回它所属的整个文档的全部内容。
具体流程:
1)向量检索,找到 Top-K 最相似的切片
2)提取这些切片所属的文档名(去重)
3)根据文档名二次查询,拉取每个文档的所有切片
4)把同一文档的切片按顺序拼接,重构完整上下文
def retrieve(self, query, top_k=3):# 1. 向量检索,召回候选切片initial_docs = self.vector_store.similarity_search(query, k=SEARCH_K)# 2. Rerank 精排,筛选 Top-Kselected_docs = self._rerank(query, initial_docs, top_n=top_k)# 3. 提取唯一文档名unique_filenames = set(doc.metadata.get('filename') for doc in selected_docs)# 4. 连坐召回:拉取每个文档的全部切片expanded_results = collection.get(where={"filename": {"$in": list(unique_filenames)}},include=["documents", "metadatas"])# 5. 按 chunk_index 排序,重构完整上下文# ...
这个策略的效果很明显。哪怕向量只命中了一个摘要,这一枪也能把整个文档的内容全部带出来,彻底解决了长文档切分后"语义分散"的问题。
5.4
Rerank 精排:从 API 到本地化
连坐召回解决了召回率的问题,但带来了一个新问题:Context 太长。如果召回的 5 个切片分别属于 5 个不同的文档,系统就会一口气把这 5 篇文档的全量内容都喂给 LLM,上下文经常超过 10k tokens,响应时间也跟着飙升。
解决方案是引入 Rerank(重排序)。Rerank 模型的作用是给候选文档打分,筛选出真正相关的 Top 几个。它不生成文字,只做判别排序,计算量比 LLM 小很多。
在引入 Rerank 之前,我先构建了一套检索层评测体系来量化效果。核心指标是 Recall@K(召回率),即正确答案所在的文档是否出现在检索结果的前 K 个中。为此整理了 30 个真实的售后问题作为测试集,每个问题都标注了标准答案应该来自哪份文档。
Baseline 评测结果比较出乎意料:在没有 Rerank 的情况下,仅用 Top-5 向量检索配合连坐召回,Recall@5 已经达到了 96.7%(30 个测试用例中只有 1 个未命中)。这说明前面的分块策略和元数据增强效果不错,向量检索本身已经比较准了。
那为什么还要引入 Rerank?核心目的不是提升召回率,而是“漏斗整形”。Baseline 方案为了不漏掉答案,漏斗口开得很小(Top-5),但进入 LLM 的内容可能包含很多无关的“陪跑文档”。Rerank 的作用是先把漏斗口放大(进一步提高召回率),然后用一个精排模型把不相关的文档剔掉,只把最相关的几篇给 LLM。
具体来说,演进过程是这样的:
第一步用云端 API 验证效果,确认引入 Rerank 后召回率确实能到 100%。但 API 方案的延迟和网络稳定性都不理想,于是切换到本地部署 bge-reranker-v2-m3 模型,利用 Mac 的 MPS 加速推理。
本地化之后遇到了性能瓶颈。一开始对 Top-50 个候选文档做重排,结果单次请求耗时超过 12 秒,完全不可接受。后来回头看评测数据,既然 Baseline Top-5 已经 96.7%,说明绝大部分答案都在 Top-20 以内。继续扩大到 Top-50 的边际收益极低,但计算成本是线性增长的。
于是做了一个关键的工程决策:把粗排候选集从 50 降到 20。结果重排耗时从 12 秒降到 3 秒以内,召回率保持在 100%,而且是本地部署零成本。这是一个典型的工程甜点,在用户可接受的延迟范围内,实现了精度和成本的最优平衡。
# Rerank 核心逻辑SEARCH_K = 20 # 粗排候选集大小,评测数据支撑的工程决策def _rerank(self, query, initial_docs, top_n=3):pairs = [[query, doc.page_content] for doc in initial_docs]with torch.no_grad():inputs = self.rerank_tokenizer(pairs, padding=True,truncation=True, return_tensors='pt')inputs = inputs.to(self.device) # MPS 加速scores = self.rerank_model(inputs).logits.view(-1,).float()# 按分数排序,返回 Top-Ndoc_score_pairs = sorted(zip(initial_docs, scores),key=lambda x: x[1], reverse=True)return [doc for doc, score in doc_score_pairs[:top_n]]
6
POC 阶段用 Streamlit 快速验证了核心算法,但很快就碰到了它的天花板。Streamlit 适合做数据展示和简单交互,但不适合做复杂的 Web 应用。
6.1
Streamlit 的局限
几个比较明显的问题:
交互体验差。Streamlit 的渲染机制是全量刷新,每次提交问题都会重跑整个页面。没有流式输出,用户只能干等几秒钟看到完整回答。
图片显示困难。前面提到,为了让图片在 Streamlit 里正常显示,最开始用了 Base64 暴力内嵌。这种方案加载慢、占内存、没法缓存。
多轮对话复杂。Streamlit 的 session_state 机制虽然能实现对话历史,但代码写起来很别扭,维护成本高。
扩展性差。想加一个 Dashboard 看板,想做复杂的交互动效,基本都得绕很多弯路。
6.2
架构升级:前后端分离
MVP 阶段决定彻底重构前端,采用前后端分离架构。
后端用 FastAPI,主要考虑是它和 Python 生态兼容性好,能直接复用 POC 阶段的算法代码。检索引擎那套逻辑一行不用改,直接 import 过来就能用。
# FastAPI 复用 POC 算法代码sys.path.append(os.path.abspath('../../POC'))from rag_engine import IndustrialRAGrag_engine = IndustrialRAG()
前端用 Next.js + TailwindCSS,走现代 Web 应用的标准路线。Next.js 的优势是生态成熟、Server Components 性能好、部署方便。TailwindCSS 写样式快,而且容易保持设计一致性。
6.3
核心交互功能
流式打字机效果。模拟 ChatGPT 的逐字输出体验,降低用户等待焦虑。后端返回完整响应后,前端用 setInterval 逐字渲染。短文本慢一点(20ms/字),长文本快一点(5ms/字),打字过程中显示一个闪烁的光标。
const interval = setInterval(() => {setMessages(prev => prev.map(m =>m.id === aiMsgId? { ...m, content: fullResponse.slice(0, i + 1) }: m));i += (fullResponse.length > 500 ? 5 : 1);if (i >= fullResponse.length) {clearInterval(interval);}}, speed);
引用源展示。每条 AI 回答下方展示来源文档卡片,让用户知道答案的依据。卡片支持 hover 高亮和点击跳转。
反馈按钮。消息气泡上设计了点赞/点踩按钮,平时隐藏,鼠标悬停时浮现。点踩时会弹出一个输入框,让用户填写具体原因。这些反馈数据会入库,用于后续优化。
数据看板。给管理员用的运营监控界面,展示活跃用户数、累计提问量、反馈分布、满意度等指标。数据从后端 API 实时拉取。
注:上述功能在 MVP 前端中均已完成 UI 开发,但部分交互逻辑仍在优化中。目前采用 A/B 测试策略,一部分用户继续使用 POC 版本(功能完整但界面简单),另一部分用户试用 MVP 版本(界面更现代,功能逐步对齐)。反馈按钮的后端对接已在 POC 中跑通,MVP 端正在完善。
6.4
前端性能优化
前后端分离之后,所有数据都要远程请求。在网络条件一般的环境下,每次切换页面都会看到 Loading 状态,体验很不流畅。
引入了 SWR(Stale-While-Revalidate)策略做客户端缓存。核心逻辑是"先展示缓存,后台静默更新"。用户点击历史会话时,直接从内存读取缓存数据瞬间展示,同时后台异步校验数据新鲜度,只在有差异时才重新渲染。
const { data: messages = [] } = useSWR(selectedSessionId ? `/history/${selectedSessionId}` : null,fetcher,{ revalidateOnFocus: false });
这个优化消除了大部分可感知的加载等待,体验接近本地应用。
6.5
图片问题的正确解法
Streamlit 阶段用 Base64 暴力内嵌解决图片显示问题,MVP 阶段引入了 MinIO 对象存储,彻底解决了这个问题。
思路很简单:数据清洗阶段把图片上传到 MinIO,生成标准的 HTTP URL。LLM 回答中如果包含图片引用,后端先做一次 URL 替换,把本地路径换成 MinIO 的 HTTP 地址,再返回给前端。
def process_markdown_images(self, text):pattern = r'!\[(.*?)\]\(images/(.*?)\)'def replace_with_url(match):filename = match.group(2)if filename in image_mapping:return f''return match.group(0)return re.sub(pattern, replace_with_url, text)
这套方案标准化、可缓存、支持 CDN 加速,是生产环境的正确做法。
7
知识库系统最大的价值不是上线那一刻的效果,而是能不能越用越准。这需要一套完整的数据闭环:收集用户行为、识别 Bad Case、沉淀反馈数据、持续优化系统。
7.1
数据库设计
整套数据层用的是 Supabase(PostgreSQL),主要有四张核心表。
用户表(profiles)。对接 Supabase Auth,存储用户基本信息和角色(admin/user)。角色字段用于控制权限,比如管理员可以看所有用户的会话,普通用户只能看自己的。
会话表(chat_sessions)。管理多轮对话的上下文,每个会话有独立的 ID 和标题。用户回来继续问的时候,可以按会话恢复上下文。
消息表(chat_messages)。不仅存储对话内容,还存储中间过程数据。每条 AI 回复都记录了:改写后的查询词(用于复盘 Query Rewrite 质量)、检索到的文档列表和相关度分数(用于评估召回效果)、各阶段耗时统计(用于定位性能瓶颈)。这些埋点数据对后续优化非常重要。
反馈表(feedback)。用户的点赞点踩记录,以及点踩时填写的具体原因。这是 RLHF 数据集的基础。
-- 消息表:存储对话 + 埋点数据CREATE TABLE chat_messages (id UUID PRIMARY KEY,session_id UUID REFERENCES chat_sessions(id),role TEXT, -- 'user' or 'assistant'content TEXT,-- 埋点字段metadata JSONB -- 包含 rewritten_query, retrieved_docs, latency_stats);-- 反馈表:构建 RLHF 数据集CREATE TABLE feedback (id UUID PRIMARY KEY,message_id UUID REFERENCES chat_messages(id),score INT, -- 1=Like, -1=Dislikecomment TEXT, -- 用户填写的具体原因created_at TIMESTAMPTZ);
7.2
反馈机制实现
反馈交互的设计目标是尽量降低用户操作成本,同时收集足够有价值的信息。
点赞比较简单,用户点一下直接入库。点踩则需要多一步:弹出一个输入框,让用户填写具体原因。比如"引用的文档是旧版本的"、"步骤描述不完整"、"答案根本不对"等等。这些文字反馈比单纯的点踩有价值得多,能帮我们定位问题到底出在检索层还是生成层。
POC 阶段在 Streamlit 里已经跑通了完整的反馈流程。用户点踩后,前端弹出 text_area 输入框,提交后数据写入 Supabase 的 feedback 表。同时通过数据库触发器自动更新用户表里的"累计反馈数"统计字段。
实现过程中踩了几个坑。一个是 RLS(行级安全策略)的无限递归问题:定义"管理员可以看所有数据"的策略时,查询语句本身又触发了 RLS 检查,导致死循环。解决方案是用 SECURITY DEFINER 函数封装权限判断逻辑。另一个是 Streamlit 的 session_state 机制,每次交互都会重跑脚本,如果不小心创建了匿名的 Supabase Client,就会触发权限校验失败。必须在 session_state 里持久化已登录的 token。
7.3
多轮对话与 Query Rewrite
多轮对话要解决的核心问题是指代消解。用户经常说"它怎么配置"、"刚才那个参数在哪里",这种指代性问题直接拿去检索肯定找不到答案。
解决方案是引入 Query Rewrite。在检索之前,用 LLM 阅读对话历史,把指代性问题改写成完整的自包含问题。比如用户问"它能过滤 IP 吗",结合上一轮关于 Wireshark 的讨论,改写成"Wireshark 能否按 IP 地址过滤抓包数据"。
改写用的是轻量级模型,耗时只有 1 秒以内,对整体延迟影响不大。同时在前端展示改写后的查询词(比如"🔄 优化检索词: Wireshark IP 过滤"),这个操作也是让用户感知到系统"听懂"了,增加信任感。
7.4
运营看板与 Bad Case 处理
系统上线之后,需要一个地方看整体运行状态和识别问题。
运营看板展示的核心指标包括:日活用户数、累计提问量、反馈总数、满意率(点赞数/总反馈数)、平均响应延迟。数据从 Supabase 实时聚合,前端定时轮询刷新。
更重要的是 Bad Case 识别。点踩数据入库后,管理员可以在后台查看具体的问答记录:用户问了什么、系统检索到了哪些文档、最终给出了什么答案、用户为什么不满意。这些信息足够定位问题根因——是检索召回错了,还是召回对了但生成答案不对。
7.5
为什么不是一上来就微调
很多人一提到"RLHF"、"数据飞轮"就想到微调模型。但在这个项目里,我选择的优先级是先把反馈数据收集机制跑通,而不是急着微调。
原因有几个。首先,微调需要足够量级的高质量数据。如果一开始就想通过人工生硬地整理几百条问答对来微调,抛开微调本身的局限性不说,这个数据集的整理就需要巨大的技术和业务磨合成本。哪些问题是高频的、标准答案应该怎么写、术语表述是否一致,这些问题没有捷径,只能靠长期积累。
其次,知识库的使用场景本身就是一个自然的标注过程。工程师每天都在用系统回答真实的售后问题,他们的点赞点踩、补充纠正,本质上就是在做标注。只不过这个标注是嵌在日常工作流里的,没有额外的学习成本和操作负担。时间长了,高质量的 Golden Dataset 就自然沉淀下来了。
这些反馈数据可以怎么用?最直接的是优化检索策略。通过分析点踩记录里的"检索召回"字段,可以发现哪些类型的问题召回率低,针对性地补充元数据或调整分块策略。其次是优化 Prompt。统计"召回对了但答错了"的 Case,分析 LLM 的回答问题出在哪里,迭代 Prompt 模板。再往后才是考虑微调。等数据量足够、效果瓶颈明确的时候,再针对性地做小范围微调。
这个顺序的核心逻辑是:先把数据收集的基础设施建好,让业务专家在日常使用中不知不觉地完成标注,然后用数据驱动后续的每一步优化。这比一开始就搞一个大工程去"生产数据"成本低得多,也更可持续。
8
整套系统涉及多个组件:前端、后端、向量数据库、关系数据库、对象存储。为了保证环境一致性和部署便捷性,我采用了 Docker Compose 进行全栈容器化。所有组件打包在一个配置文件里,一条命令就能启动整套服务。对于私有化部署场景,镜像可以导出成离线包,客户现场直接加载,不需要联网拉取。
配置和模型管理上有两个原则。一个是配置即数据:RAG 系统的参数(Prompt 模板、检索参数、阈值等)存在数据库的配置表里,后台可以直接修改,无需重新发版。另一个是模型挂载:Rerank 模型文件比较大,不打包进镜像,而是用 Volume 挂载到容器里。后续模型更新只需要替换文件重启容器。
后续维护方面,根据企业对数据隐私的要求,一般有两种方式。一种是纯内网隔离模式:所有数据留在客户本地,系统后台提供"导出诊断包"功能,安排工程师定期上门,导出脱敏后的统计数据(响应延迟、反馈分布、错误日志),带回分析后给出优化建议,再下次上门时更新配置。另一种是远程维护模式:客户允许系统与外部建立安全连接,我可以通过 OTA 方式远程更新 Prompt 模板、检索参数、阈值等配置,响应速度更快。两种方式的选择取决于客户的安全合规要求,核心原则是严禁收集原始问答内容和文档原文,只处理允许的元数据。
9
目前完成的是一个功能完整的 MVP,能解决实际问题。但从 MVP 到真正的企业级产品,还有不少事情要做。这里简单讲一下后续三个阶段的思考。
9.1
阶段一:可信度建设
知识库类产品最怕的不是"搜不到",而是"搜到了但答错了"。工控场景下,一个错误的技术参数可能导致客户设备配置失败甚至损坏。所以第一阶段的重点是提升系统的可信度。
核心方向有几个。一个是知识源权重体系:不是所有文档都一样权威,官方手册应该比论坛帖子优先召回,2025 年的文档应该比 2020 年的优先,已废弃版本的内容要降权。另一个是可解释召回:现在已经在做的引用展示要进一步强化,让用户能看到"依据来源 + 相关度分数 + 匹配片段高亮",答案可验证、可追溯。还有置信度评分:当系统判断召回结果不够可靠时,主动提示"建议人工核实",降低幻觉风险。
9.2
阶段二:运营闭环
MVP 阶段已经实现了点赞点踩和反馈收集,但这只是起点。真正的数据闭环需要把反馈转化为知识库的持续优化。
具体来说,反馈回写要形成闭环:用户点踩 → 运营后台看到处理队列 → 定位问题原因 → 修正后回写知识库。运营指标也要升级,从简单的"调用量"升级到"问题解决率"(点赞数/总提问)、"转人工率"(无法回答/总提问),这才是真正衡量业务价值的指标。另外还需要 Bad Case 预警机制,比如单日点踩数超过阈值、或者连续多人问同一问题都没结果,系统应该主动告警,而不是等用户投诉才发现问题。
9.3
阶段三:生态集成
很多知识库试点项目失败的真正原因,不是技术不行,而是上下文切换成本太高。用户正在企业微信群里答疑,却要切换到浏览器打开知识库网页,再复制答案发回去,这种体验注定用不起来。
所以知识库不应该是一个独立的网站,而应该是一种能力,嵌入到用户已有的工作流中。企业 IM 集成是优先级最高的方向:企微、钉钉、飞书的机器人,群里 @一下就能直接回复卡片式答案。其次是浏览器插件或侧边栏,让用户在 OA、CRM 等系统里直接提问。再往后是标准化的 API 和 SDK,方便客户把知识检索能力嵌入自有系统。更长远来看,还可以发布为 MCP Server 或 LangChain Tool,让各种 Agent 工作流能够直接调用。
另外,中大型企业还会有一些特殊要求:审计日志(谁在什么时候问了什么)、细粒度权限管理(不同部门看到不同范围的知识)、多租户隔离等等。这些功能在技术上都不复杂,但需要根据具体客户需求逐步完善。
10
10.1
关于市场的一些观察
过去一年,企业大模型应用的讨论热度很高,但真正落地的案例其实没那么多。很多公司还在观望,担心技术不成熟、成本太高、效果不可控。而另一些公司则走向另一个极端,追着最新的模型和框架跑,Agent、多模态、长上下文……概念很多,但离业务价值越来越远。
从企业老板的视角看,这一年经历了几个阶段。年初 DeepSeek R1 开源的时候,很多企业负责人有一阵恐慌情绪,觉得 AI 要颠覆一切。然后内部浅尝辄止,发现没那么神奇后开始祛魅。到现在,相当一部分人还在观望。
我比较愿意合作、也觉得比较理想的客户,往往是在 2023 年下半年或者 24 年上半年就开始积极试错的那批企业主。他们愿意买单、愿意"先被割一波韭菜"教学费,但在这个过程中不断完善认知,而且是自上而下立项推进,而不是甩给副手去干。前几天我的知识星球会员群中有人问我,他的领导觉得不需要搞什么知识库,直接把文档丢给大模型网页端问效果也不错。我当时的看法是,这可能不是技术判断的问题,而是优先级的问题——更高级别的领导不关心或者这件事压根不在他的优先级中,这件事就很难推动。从上班的角度来说,与其自证,不如先自己研究清楚。
10.2
一些项目实施上的建议
我个人的观察是,现阶段企业 AI 应用的核心不在于技术有多先进,而在于能不能找到一个足够具体的场景,解决一个足够真实的问题。技术成熟度从来不是障碍,开源模型对于大多数企业级应用来说都已经够用了。真正的障碍是数据质量、是业务理解、是能不能把 AI 的能力嵌入到用户的日常工作流里。
分享这个案例实践也是想说明:知识库是大部分企业 AI 应用的刚需起点。相比于从零搭建一个 Agent 系统,先把存量知识整理好、让员工能快速找到答案,投入产出比要高得多。而且这个过程本身就是在积累数据资产,比如:反馈日志、Bad Case、用户高频问题,这些都是后续做 Prompt 优化甚至模型微调的基础。
如果你也在考虑类似的项目,可以记住三个关键词:先评估再动手,摸清业务痛点和数据现状,避免一上来就追求技术完美;小步快跑,从 POC 到 MVP 到推广,每个阶段都要有明确的验收标准,快速试错;价值导向,时刻盯住 ROI,如果算不清楚这件事能带来多少价值,那就别着急做。
完整项目脚本与脱敏测试文档已发布至知识星球
(课程随更新持续涨价中 p.s.星球成员可享受1元兑换)
课程试看直接访问小鹅通:https://2l1vp.xetlk.com/s/O43pC
53AI,企业落地大模型首选服务商
产品:场景落地咨询+大模型应用平台+行业解决方案
承诺:免费POC验证,效果达标后再合作。零风险落地应用大模型,已交付160+中大型企业
2025-12-17
企业AI真瓶颈:不在模型,而在语境!
2025-12-16
短语检索不等于BM25+向量检索| Milvus Phrase Match实战
2025-12-16
让AI真正懂数据:猫超Matra项目中的AI知识库建设之路
2025-12-10
最新力作:一招提升RAG检索精度20%
2025-12-10
Apple 入局 RAG:深度解析 CLaRa 框架,如何实现 128x 文档语义压缩?
2025-12-09
客服、代码、法律场景适配:Milvus Ngram Index如何百倍优化LIKE查询| Milvus Week
2025-12-09
一键把碎片变成有料笔记:NoteGen,一款跨平台的 Markdown 笔记应用
2025-12-07
Embedding模型选型思路:相似度高不再代表检索准确(文末附实战指南)
2025-10-04
2025-10-11
2025-09-30
2025-10-12
2025-12-04
2025-11-04
2025-10-31
2025-11-13
2025-10-12
2025-12-03
2025-12-10
2025-11-23
2025-11-20
2025-11-19
2025-11-04
2025-10-04
2025-09-30
2025-09-10