
先交代一下背景。我之前在一家汽修连锁平台做技术顾问当时团队里有一批老师傅经验确实深厚但只要他们请假或者离职知识就跟着人走了。管理层一直想做一套内部知识库把维修手册、历史工单、故障码这些资料统一管起来让新来的技师也能快速上手。试过传统的关键词搜索方案效果很一般因为技师提问的方式千奇百怪“车子启动抖动发动机灯亮”和“P0300故障怎么修”本质上是一回事但关键词搜索根本关联不上。后来我决定用 RAG 来解这个问题用 Milvus 做知识库底座用 FastAPI 把整条链路串成服务。前后折腾了两个多月最终跑通了从智能问答到工单自动生成的一整条闭环。这篇文章就把这套系统的完整落地过程拆开来讲包括选型理由、数据清洗、向量化、检索调优、接口实现和工单状态流转适用对象是正在做垂直行业 RAG 落地、或者想给传统业务加一个“能干活”的智能问答系统的同学。1. 系统整体设计为什么是 RAG Milvus FastAPI1.1 选型决策RAG 而不是微调其实一开始也有人提过要不要用大模型微调把维修知识直接训进模型里。这个方案我不是没考虑过但仔细评估之后放弃了。汽修领域有个很现实的问题知识更新极快。新车型上市、厂家发布技术公告、老车型出现新的通病几乎每个月都有新内容。如果走微调路线每次知识更新都要准备训练集、重新训练、做评测整套流程走下来少说一两个星期。而且微调属于“黑盒记忆”模型回答得再漂亮也没有办法告诉用户“我这句话的依据来自哪本手册的哪一页”。这个特性在汽修场景里是致命的——技师用你的系统查故障你给的结论必须有出处否则没人敢照着做。RAG 的工作方式正好绕开了这两个问题。知识更新只需要把新文档切块、向量化、写进向量库问答阶段先检索再让大模型基于检索结果生成答案天然自带引用来源。用一句话总结我的选型判断微调负责“学会”RAG 负责“查得到”而汽修领域更需要的是后者。1.2 技术栈搭配与整体架构技术选型上其实也做过比较。向量数据库试过 Elasticsearch 的 dense vector 插件、Chromadb、Qdrant最后选 Milvus 是因为它在百万级向量上的检索性能稳定而且支持标量字段与向量字段的混合过滤。这个能力在汽修场景特别有用比如我可以先按“车型大众迈腾”过滤再在结果里做向量检索召回精度比纯向量检索高不少还能直接存 JSON 格式的 metadata省了不少设计成本。后端框架用 FastAPI 基本没什么争议。团队原来是做 Python 的FastAPI 的异步特性在同时处理检索请求和大模型调用时很省心Pydantic 模型校验能直接挡掉大量非法参数自动生成的 Swagger 文档给前端联调和测试也省了很大力气。整套系统的调用流程是技师在 Web 端输入问题FastAPI 接口接收请求把问题向量化去 Milvus 里检索最相关的 N 条知识点把知识点、用户问题、历史对话拼装成 Prompt调用大模型生成带引用的答案同时从答案里抽取工单要素生成维修工单草案进入人工确认流程确认后自动派单。链路看着不复杂但每一步都有不少坑下面展开讲。2. 汽修知识库的构建数据清洗、切分与向量化2.1 数据来源与清洗RAG 的效果上限很大程度上取决于知识库的原始数据质量。我做这个项目时数据来源五花八门主要包括这几类数据来源格式典型内容清洗难度维修手册PDF拆装步骤、扭矩规格、电路图说明高故障码库Excel/CSVDTC 码、含义、可能原因、维修建议低技术公告 TSBPDF/网页厂家针对通病发布的维修方案中历史维修工单业务系统导出故障描述、诊断过程、维修操作高配件目录Excel/JSON配件编号、名称、适用车型低其中 PDF 是最头疼的。很多维修手册是扫描版复制出来的文字是乱的需要先过一遍 OCR。我用的方案是 PaddleOCR识别中文的效果不错跑完再统一把全角字符转半角、去掉页眉页脚、把多行表格转换成一行行的“键值”文本。比如原来的三列表格我转成“故障码:P0300 | 含义:1缸失火 | 可能原因:点火线圈/火花塞/喷油嘴”这样转成向量之后检索匹配效果比把表格整体作为一个文本块要好得多。历史工单的数据清洗更花时间。业务系统导出来的工单字段混乱有的“故障描述”写得很详细有的只写“发动机异响”。我当时的处理原则是只保留故障描述超过 20 个汉字、并且含有维修结论和操作内容的工单清洗后大概保留了 60%。这些工单是后来问答效果能否贴合实际业务的关键因为手册讲的是“标准流程”工单记录的是“真实修法”。2.2 文档切分策略切分是 RAG 里最容易被低估的环节。我一开始图省事按固定 token 数切每 500 个 token 一段结果检索出来的片段经常把前后无关的内容拼在一起回答自然前言不搭后语。后来改成“结构感知切分”优先按文档的章节层级切分二级标题和三级标题各自成为独立的切片单元如果某个章节太长再继续细分到段落级每段控制在 300 到 500 个 token相邻段落之间保留 50 个 token 的重叠。汽修手册的结构感极强诊断流程通常分“症状”、“可能原因”、“检查步骤”几个固定模块按结构切分的好处是检索召回的那一段往往自带完整的上下文逻辑大模型拿到的不是一个被腰斩的知识片段。技术公告类文档还会在切片时把“适用车型”、“故障现象”、“维修方案”三个关键部分用正则抓出来单独做冗余存储确保二次检索时每条记录都带全关键信息。2.3 Embedding 模型选择与向量写入Embedding 模型我对比过好几款。OpenAI 的 text-embedding-3 效果可以但数据要出网汽修资料里的 VIN、客户信息敏感这个方案直接被否了。最后在本地部署的是 BAAI/bge-m3。选择理由有三个中文效果在同级模型里排得上前列支持 1024 维信息密度比 384 维模型高不少同时支持稠密检索和稀疏检索后续想升级混合检索不用换底座。如果你的机器配置有限用 m3e-large 也可以512 维稍微牺牲一点精度换速度。向量写入的代码我贴在下面用的是 pymilvus 2.4 的 MilvusClient API整体流程比较直观from pymilvus import MilvusClient from sentence_transformers import SentenceTransformer import json # 加载 embedding 模型 model SentenceTransformer(BAAI/bge-m3) client MilvusClient(urihttp://localhost:19530) # 假设 docs 是切分后的文本列表, 每个元素为 {content: ..., metadata: {...}} docs load_split_docs() data [] for i, doc in enumerate(docs): vector model.encode(doc[content]).tolist() data.append({ content: doc[content], metadata: json.dumps(doc[metadata], ensure_asciiFalse), vector: vector }) client.insert(collection_nameauto_repair, datadata) print(f成功写入 {len(data)} 条数据)有个容易忽略的细节向量维度必须要和 collection 创建时指定的维度一致。bge-m3 输出 1024 维如果你的 collection 建的是 768 维插入时不会报错但很多数据会被静默丢弃检索结果莫名其妙变差。另外大批量写入时建议分批每批 500 条左右写太快 Milvus 有时会出现写入抖动分批之后稳定得多。3. Milvus 部署与集合设计3.1 Docker Compose 部署 MilvusMilvus 部署方式不少单机测试可以用 milvus-lite生产环境一般建议用 Docker Compose 起 standalone 模式。官方默认的 compose 文件会同时拉起三个组件Milvus 主服务、etcd负责元数据存储、MinIO负责数据持久化。我第一次部署时就踩了坑下载的镜像版本之间不兼容服务起来了但 etcd 一直报连接拒绝。后来严格用官方 docker-compose.yml 里锁定的版本号一次通过。部署好后验证服务是否正常docker compose ps # 看到 standalone、etcd、minio 三个服务都是 Up 状态就说明启动成功 curl http://localhost:9091/healthz # 返回 OK 表示 Milvus 服务健康版本这里特别提醒一句网上很多教程是各自版本的组合如果你用的 Milvus 镜像比较新但 Attu 管理工具还是旧版本大概率连不上。我用的组合是 Milvus v2.4.x Attu v2.4.x匹配得很好。装 Attu 也很简单docker run -p 8000:3000 -e MILVUS_URLlocalhost:19530 zilliz/attu:v2.4装完浏览器打开http://localhost:8000在界面上填 Milvus 地址为localhost:19530就能连上后面浏览数据、测试检索、看索引状态都靠它。3.2 集合 Schema 设计与索引配置Milvus 里的一个 collection 可以理解成一张表字段要提前定好。我设计的 schema 很简洁client.create_collection( collection_nameauto_repair, dimension1024, metric_typeCOSINE, auto_idTrue, schema_fields[ {name: content, type: VARCHAR, max_length: 4000}, {name: metadata, type: JSON} ] )有几个设计考量向量的相似度计算选 COSINE。汽修文本长度差异大用 COSINE 做相似度评估比 L2 更合适因为它只看方向的相似性不受文本长短影响。metadata 只存 JSON 对象里面塞了来源、车型、系统分类等字段。这样后续做过滤检索时可以直接按 metadata 字段过滤不需要建额外表。auto_id 置为 TrueID 由 Milvus 自动管理减少业务侧心智负担。索引方面汽修知识库数据量一般在几十万条以内HNSW 是最优解。我用的是默认参数{M: 16, efConstruction: 200}检索时的 ef 参数设成 64。如果你的服务器内存紧张可以换 IVF_FLAT但召回率会略降我的建议是内存够就别省这点空间。3.3 Attu 可视化运维Attu 对我这种习惯看界面的选手来说非常实用。在 Attu 里可以直接查看集合里的数据预览确认每条记录的字段有没有写错可以在“查询”标签页里手动试跑向量检索实时看返回的相似度和内容最常用的是“索引”管理页面索引构建状态一目了然。项目上线初期我排查检索问题几乎每天都要打开 Attu 确认数据量和索引状态。如果你的团队平时没有专业运维负责 Milvus强烈建议把 Attu 纳入标配它能省掉很多命令行操作的排查时间。4. FastAPI 接口层的实现4.1 项目结构与核心代码FastAPI 项目结构我按功能拆得比较清楚方便团队协作auto_repair_api/ ├── app/ │ ├── main.py # FastAPI 入口 │ ├── routers/ │ │ ├── chat.py # 问答接口 │ │ └── workorder.py # 工单接口 │ ├── services/ │ │ ├── retriever.py # Milvus 检索服务 │ │ ├── generator.py # LLM 调用 │ │ └── embedder.py # Embedding 服务 │ ├── models/ │ │ └── schemas.py # Pydantic 模型 │ └── config.py # 配置文件 ├── docker-compose.yml ├── requirements.txt └── .env入口文件很轻量主要做模块注册和配置加载from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.routers import chat, workorder app FastAPI(title汽修智能问答与工单系统) app.add_middleware( CORSMiddleware, allow_origins[*], allow_methods[*], allow_headers[*], ) app.include_router(chat.router, prefix/api, tags[chat]) app.include_router(workorder.router, prefix/api, tags[workorder])4.2 问答接口检索加生成的完整链路问答接口是整套系统的核心。完整流程先向量化用户问题再查 Milvus然后把召回结果拼进 Prompt交给大模型生成。这里贴一段简化但能跑通的示例代码from fastapi import APIRouter, HTTPException from pydantic import BaseModel from app.services.embedder import get_embedder from app.services.retriever import MilvusRetriever from app.services.generator import call_llm router APIRouter() class ChatRequest(BaseModel): question: str session_id: str class ChatResponse(BaseModel): answer: str references: list[str] router.post(/chat, response_modelChatResponse) async def chat(req: ChatRequest): embedder get_embedder() retriever MilvusRetriever() # 1. 向量化用户问题 query_vec embedder.encode(req.question) # 2. Milvus 检索 TopK 结果 hits retriever.search(query_vec, top_k5) if not hits: raise HTTPException(status_code404, detail知识库中没有相关内容) # 3. 组装带引用来源的上下文 context \n\n.join( f[来源参考{i1}] {hit[content]} for i, hit in enumerate(hits) ) # 4. 组装 Prompt 并调用大模型 prompt f你是资深汽车维修专家请结合下面的知识内容回答用户问题。 如果知识内容不足以回答请直接说明不要编造。 知识内容 {context} 用户问题{req.question} 回答要求 1. 先直接给出结论 2. 分步骤说明诊断和维修思路 3. 在回答末尾标注参考来源的编号 answer call_llm(prompt, session_idreq.session_id) return ChatResponse( answeranswer, references[hit[content] for hit in hits] )这段代码有几个点值得展开。第一检索结果我直接放进了「引用来源」字段前端可以让技师点击查看原始资料这对建立系统信任度很重要。第二Prompt 里强调“知识不足就直说”这个设计能显著减少大模型胡编乱造。汽修领域一旦幻觉出一个错误的维修方案轻则白折腾半天重则影响行车安全所以我在 Prompt 里把“宁可不答、不可乱答”写在最前面。4.3 会话上下文与流式输出智能问答不能只支持单轮提问。技师实际使用中往往是连续追问“P0300 怎么修”得到回答后紧跟着问“那 2 缸也有问题怎么办”如果系统不能记住前文第二个问题就变成无源之水。我用 Redis 做会话缓存以session_id为 key 保存最近 5 轮对话。FastAPI 里读 Redis 有绿色线程优势不会阻塞请求。检索时把当前问题和最近几轮的问答摘要一起做拼接再去做向量检索这样上下文能帮检索器更准确理解“那”指代的是什么。流式输出也很有必要。大模型生成答案通常要几秒如果整块返回前端会一直转圈。我用 FastAPI 的 StreamingResponse 做了 SSE 流式返回前端可以逐字展示答案。这里有个体验上的细节把“引用来源”放在整段回答流式发送完之后再单独推给前端因为引用来源是检索阶段就已经确定的不需要等生成完才知道这样做可以提前展示“系统已经找到 3 份相关资料”降低用户等待焦虑。实际代码如下from fastapi.responses import StreamingResponse router.post(/chat/stream) async def chat_stream(req: ChatRequest): # 省略检索和Prompt组装逻辑... async def event_generator(): # 先推送检索到的参考资料 yield fevent: refs\ndata: {json.dumps(references, ensure_asciiFalse)}\n\n # 再流式推送LLM生成结果 async for token in stream_llm(prompt): yield fdata: {token}\n\n return StreamingResponse(event_generator(), media_typetext/event-stream)5. 检索效果调优与 RAG 评估5.1 召回质量优化TopK、重排序与查询改写RAG 上线跑了一个星期最集中的反馈是“系统经常检索到不相关的内容”。我自己测试后发现纯向量检索在汽修这种专业术语密集的领域确实容易出现语义相似但实际无关的召回。比如“发动机抖动”和“方向盘抖动”走的都是“抖动”但维修方向完全不同。解决这个问题我用了几板斧。第一个是重排序。初始召回先取 TopK50然后用跨编码器模型 bge-reranker-large 对这 50 条做精排最后只取前 5 条送进 Prompt。重排序对相关性的提升非常明显基本是把召回质量从“勉强能用”拉到了“接近人工筛选”的水平。代价是每次查询多了大约几十毫秒的推理时间这个成本可以接受。第二个是查询改写。汽修技师口语化提问多比如“亮灯了踩油门没劲”这个问法直接去向量化效果很差。我用大模型先把口语化问题改写成标准检索式把问题里的模糊描述替换成更严谨的术语。改写后的查询是“车辆发动机故障灯亮加速无力可能原因有哪些”检索效果好非常多。这个方法实现成本低、收益大属于典型的小投入高回报。第三个是元数据过滤。在设计知识库时我给每份资料打了“车型”、“系统分类”的标签查询时如果识别到用户提到了具体车型就直接向 Milvus 传过滤条件只在该车型的知识范围内检索。这不仅提升了精度还让检索速度更快。5.2 Prompt 工程关于 Prompt我的经验是不要太花哨但要把边界划清楚。汽修场景里的提示词我总结了一个模板基本要素包括角色设定、任务描述、检索内容、回答规则、输出格式。回答规则里三条最重要一是必须基于检索内容回答二是不允许编造故障码和维修步骤三是如果信息不足就明确告知需要补充什么资料。这三点每一句都是拿真实业务事故换来的。Prompt 还有个容易被忽略的点要对多轮对话做长度控制。如果历史对话太长Prompt 总量超过了模型上下文窗口后面的内容会被截断导致回答质量下降。我在生成 Prompt 前会做一次 token 统计超过阈值就只保留离当前问题最近的两轮对话同时把前面更早的对话压缩成一句话摘要这样核心信息不丢长度也控制得住。5.3 RAG 效果评估方案做知识问答系统评估是逃不掉的。我用了一套轻量但有效的评估方案分享给你参考。第一步是建测试集从真实工单和技师提问里挑选了 60 条高频问题请了两名资深维修技师人工写标准答案作为评测基准。测试集里除了常规问题还故意放了几条知识库根本无法回答的问题用来测试系统的“拒答能力”——这点非常重要很多 RAG 系统做不到优雅拒答硬答就容易产生幻觉。评测分两个维度检索质量和生成质量。检索质量看两个指标Top-5 命中率也就是正确答案出现在前 5 条召回结果里的比例MRR看正确答案排在第几名。生成质量主要靠人工打分从“内容正确性”、“依据相关性”、“完整性”三个角度每项 1 到 5 分。每改一次 Prompt 或重排序策略都用同一套测试集跑一遍对比分数变化。后来我也试过用大模型做自动评分用 GPT 级别的模型当裁判给被测模型的答案按同样维度打分和人工打分的一致性在 80% 左右。这个方式可以在开发阶段快速迭代但最终上线前一定要安排真人技师做一轮终评因为维修方案的正确性只有真正修过车的人说了算。6. 工单闭环从问答结果到维修工单6.1 工单生成与字段抽取问答系统如果只做问答价值终究有限。我做的闭环是问答结果结束后系统自动从整个对话里抽取维修工单要素先生成一份“工单草案”由前台人员或技师确认后流转到维修流程。这样既省了重复录入又保证了工单里的诊断依据来自知识库检索结果。工单要素我定义了下面这些字段字段示例抽取方式客户/车辆信息车牌号、VIN、车型从会话上下文或用户档案中读取故障描述发动机故障灯亮怠速抖动用户首次提问复制DTC 故障码P0301从问答结果中正则抽取或模型抽取诊断结论1缸失火疑似点火线圈故障从LLM回答中抽取维修建议检查更换点火线圈从LLM回答中抽取配件清单点火线圈×1模型抽取人工确认这里有个实操技巧与其让模型直接从对话中抽取结构化字段不如在回答生成的 Prompt 里就约定问答结果的输出格式。我让大模型在生成答案时同时输出一个 JSON 块其中包含故障码列表、诊断结论、维修方案、配件需求等关键字段然后再由接口解析。Prompt 里加上“如果没有识别到某字段输出 null不要猜测”能极大减少脏数据。抽取完成后返回给前端由人工确认后生成正式工单。6.2 工单流转状态设计工单闭环不只是“生成一张单子”还涉及状态流转。我设计的工单状态机比较标准五个状态足够覆盖业务场景待审核由问答系统自动生成的工单草案等待前台或技师确认待维修审核通过工单进入维修队列等待分配技师维修中技师领取任务开始执行维修待验收维修完成等待质检或车主验收已完成验收通过工单结案相关数据归档。整条链路里最容易出问题的是第一步“待审核”。自动抽取的字段如果直接创建正式工单很容易因为一个字段错误导致后续流程连环出错。所以我刻意把“审核”作为单独状态保留而且审核不需要修改太多内容主要是确认诊断结论和配件清单是否正确。这一步的人工审核成本很低但换来的可靠性提升非常大。工单进入“已完成”状态后整个闭环最关键的增值动作就出现了——工单数据回流。6.3 知识回流闭环完成并验收的工单是极高质量的知识资产。我把这些工单定期清洗后重新向量化写回 Milvus 知识库这样系统会随着使用越用越聪明。原本知识库里“标准手册”占多数跑了一段时间后真实维修案例的比例越来越高召回结果里频繁出现的都是“曾经真实修好过的问题”的解决方案比手册里的标准流程更贴合实际业务。这个知识回流机制是工单闭环最大的价值所在没有这个设计系统只能算一个静态问答工具有了它才真正形成“使用即积累”的数据飞轮。7. 常见问题与排查实录跑这套系统的过程中踩了不少坑下面把最典型的几个问题整理成速查表给后来人省点事。问题现象可能原因解决方案Attu 连不上 MilvusAttu 和 Milvus 版本不匹配统一使用 v2.4 系列版本插入数据后检索结果为空向量维度与 collection 不一致检查 dim 配置删除重建 collection中文检索效果差用了英文为主的 embedding 模型换 bge-m3 或 m3e-large招回的片段上下文不完整固定 token 切分导致章节被切断改为结构感知切分保留章节层级大模型回答经常不引用检索内容Prompt 中没有强调“必须基于知识内容回答”加一条强约束知识不足时直接拒答并发请求时 FastAPI 响应变慢Milvus 连接被阻塞或没有复用使用连接池避免每次请求新建连接工单抽取字段经常为空Prompt 未约定结构化输出格式在生成答案的 Prompt 中强制输出 JSON 块还有一个容易被坑的地方Milvus 部署久了日志里偶尔会出现 “memory limit exceeded” 或者索引构建缓慢的警告。这通常不是代码问题而是 Docker 容器内存限制太小。我是通过调整 Docker Desktop 的内存配额建议至少 8GB解决的问题出现前不会有人提前告诉你等出现再查就会浪费不少时间。另外提醒一下 FastAPI 里调用 LLM 的并发问题。如果用的是同步方式的 LLM SDK在 async 接口里直接调用会阻塞事件循环导致其他请求全部排队。正确做法是用run_in_executor把同步调用丢到线程池里或者直接用支持异步的 SDK版本。这个问题在流量小的时候完全感知不到一旦并发上来接口响应时间会从 200 毫秒一路飙到 10 秒排查起来也容易摸不着头脑。我自己的经历是上线第三天恰好赶上内部测试几十个技师同时刷问题接口直接超时后台日志一查发现就是 LLM 调用阻塞了事件循环。改成异步之后问题当场消失。这类问题在接口层面是隐性的不压测根本发现不了建议做这类系统的同学上线前务必做一轮简单压测哪怕用 Locust 跑几十个并发也行。最后再分享一个小技巧Milvus 里的索引构建完成之前查询虽然能返回结果但速度会非常慢而且结果不稳定。我一开始没注意这个问题一些新写入的数据在查询时明显响应变慢后来去 Attu 里查看索引状态才发现索引还在 loading。之后我养成了习惯——每次大批量写入新数据后都会去 Attu 确认索引状态变成Loaded才继续对外提供服务。这种“等索引就绪再开放查询”的习惯能帮你避开很多看似随机出现的性能问题。