
修车行最值钱的资产从来不是举升机和诊断电脑而是老师傅脑子里那套判断逻辑。同一句“发动机抖动”国六新车和十年前的电喷车排查路径能差出十万八千里。我在做汽修门店数字化系统时最头疼的就是怎么把这套经验从人脑子里搬到系统里让前台接车、新人技师、售后客服都能在几秒内拿到靠谱答案。后来把 FastAPI Milvus RAG 这套组合跑通了还顺势把问答结果转成了可流转的维修工单这才算真正落地。这篇就把整个系统的设计思路、关键代码骨架和踩坑复盘全部摊开讲适合正在做垂直领域知识库或者想用 RAG 做生产级服务的朋友参考。1. 汽修问答为什么必须走到 RAG 这条路上1.1 汽修知识的真实形态手册、通告、案例三张皮汽修领域的知识沉淀和互联网、金融、法律这些行业都不一样。它的核心资产分散在三种形态里第一是品牌方的官方维修手册动辄上千页的 PDF里面是拆装步骤、扭矩参数、电路图第二是厂家不定时发布的技术通报TSB专门讲某个车型在特定工况下的已知故障和官方修法第三才是最有价值的门店历史工单里记录的真实维修案例——技师最后换了什么件、清了什么码、路试结果如何、客户满意度怎么样。这三种资料有一个共同特点全部是非结构化数据。维修手册是排版复杂的 PDF技术通报经常夹杂表格和图片历史工单更是口语化到离谱什么“车主说车没劲”“怠速抖得跟拖拉机似的”都会出现在备注栏里。直接拿全文丢给大模型不行上下文装不下拿关键词搜传统数据库也不行因为用户提问是口语化的跟手册里的规范术语对不上。RAG 解决的就是这个映射问题把语料切块后向量化让用户用自然语言提问系统用语义相似度把最相关的知识块捞出来再交给大模型组织成回答。等于给大模型装了一个“汽修知识外挂”。我第一次跑通原型的时候拿一条“2021 款轩逸怠速抖动读码 P0171怎么查”去问系统能从二十年前的老手册和去年的历史工单里同时召回内容那一刻我就知道这条路走对了。1.2 为什么不是纯搜索也不是微调有些团队一上来就纠结既然汽修术语这么固定我直接用 Elasticsearch 做关键词匹配不就行了如果能检索为什么还要向量这里的关键在于“用户怎么问”和“资料怎么写”之间的鸿沟。用户会问“车加速没劲”手册里写的是“加速无力检查燃油压力调节器”关键词完全不一致。ES 分词后勉强能撞上“无力”但召回排序完全不可控。向量检索的优势是语义级别的匹配能把这个鸿沟填上。那为什么不做微调我自己之前踩过这个坑花大力气整理了一批问答对去微调开源大模型效果有提升但知识是“融化”在模型参数里的厂家发一个新 TSB或者门店沉淀了一批新修法你没法增量更新只能全部重训。汽修知识的更新频率是周级别的这种节奏根本撑不住。RAG 最大的工程优势就是知识即插即用新增文档转成向量灌进 Milvus第二天系统就能回答新问题完全不用碰模型权重。1.3 “工单闭环”到底闭的是什么环标题里“工单闭环”这四个字很多人理解成“系统自动生成工单”。实际上完整的闭环比这个长得多用户描述故障RAG 检索知识库生成诊断建议系统从对话中抽取关键字段生成工单草稿客服或前台确认后工单进入派工、维修、完工流程最关键的。 环节在完工之后——技师实际的维修结论要回写知识库变成下一条可检索的语料。这样一来同一个故障第二次出现时系统召回的不再只是维修手册上的理论而是上次真实解决这个问题的全过程。这套系统的价值不是“回答一个问题”而是让每一次维修都在为下一次维修积累经验知识资产滚雪球。我经常跟同行说一句话RAG 系统的上限不取决于模型取决于你喂进去的知识能不能形成回流。只做问答不回流知识库永远是一潭死水接上工单回流它才是一个活系统。2. 知识库处理从 PDF 和故障码表到干净的 Chunk 数据2.1 数据源盘点与处理工具动手写代码之前先把语料盘清楚。我接手的汽修门店数据大概长这样数据来源原始格式可提取内容处理工具品牌维修手册上百个 PDF 文件文本、表格、扭矩参数、拆装顺序pdfplumber、PyMuPDF故障码 DTC 数据库Excel / CSVP0xxx、C1xxx 等码的含义与排查思路pandas技术通报 TSB扫描版 PDF / Word已知故障、适用车型、修复方法PaddleOCR、python-docx历史维修工单门店系统导出 CSV故障现象、维修项目、更换配件、实际结论pandas、正则清洗客服 FAQ零星文本保养周期、仪表灯含义、常见疑问手动整理这里要特别提醒一个工程陷阱不要一上来就处理所有数据源。我当时从最值钱的比例入手——先把故障码表和最近两年的工单案例做掉再补手册的常见章节。道理很简单RAG 系统冷启动时最怕知识库里什么都有但什么都是浅尝辄止检索返回一堆模棱两可的内容比查不到还让用户恼火。PDF 解析是第一个硬骨头。pdfplumber 的 extract_text() 处理文本型 PDF 效果不错但汽修手册大量使用多栏排版和表格直接提取会把同一行内容切得乱七八糟。我的做法是先调用 page.extract_words() 拿回坐标信息按 y 坐标聚类还原阅读顺序表格块用 extract_table() 转成 Markdown 表格再作为独立 chunk 入库。扫描版资料直接用 PaddleOCR 做版面识别跑一次大概两分钟一页虽然慢但对老款车型粉补的维修通告几乎是唯一办法。2.2 切分策略标题优先故障码保护文本切分是 RAG 系统里“看起来简单、做起来全是坑”的环节。汽修手册有非常清晰的层级结构一级标题是“发动机控制系统”二级标题是“燃油压力检查”三级标题才落到具体步骤。切分时必须“标题感知”不能拿 RecursiveCharacterTextSplitter 按固定字符硬切。我最后采用的策略是先用正则把 PDF 里的标题层级识别出来按标题块切分大段再对超长块做补充切分。每个 chunk 控制在 500800 个 token 左右块之间保留 100 个 token 的重叠避免切断上下文导致的召回率下降。核心代码如下def split_by_headings(text: str, max_tokens: int 700): heading_pattern re.compile(r^(第[一二三四五六七八九十]章|\d\.\d\.?\d*|[一二三四五六七八九十]、), re.MULTILINE) positions [m.start() for m in heading_pattern.finditer(text)] positions.append(len(text)) chunks [] for i in range(len(positions) - 1): segment text[positions[i]: positions[i 1]] # 超长段落按句子切分保留标题前缀 if len(segment) max_tokens: segment recursive_split(segment, max_tokens) chunks.append({heading: extract_heading(segment), content: segment}) return chunks比“切多碎”更重要的一个细节是故障码保护。P0171、P0300 这种四位字母数字组合一旦在切分时被从中间断开整条索引就废了。我的做法是在切分前先做一次敏感实体标记用正则把故障码、车型代码、VIN 段替换成带占位符的形态切完再还原。这行代码救过我一次大命早期版本里 P0171 被切成“P01”和“71”导致所有故障码相关检索全都音信全无。2.3 Embedding 选型与元数据字段设计中文汽修语料Embedding 模型的选择直接决定召回质量。我用过 OpenAI 的 text-embedding-3-small通用场景表现不错但遇到“曲轴位置传感器”和“凸轮轴位置传感器”这种高频机械术语语义区分度不够检索出来的相似结果经常串味。后来换成 BGE-M3维度是 1024中文理解能力明显更适合国内汽修门店的语料而且在 Milvus 里可以直接跑。元数据字段设计这块很多人会忽略。我当时在 Milvus 里给每个 chunk 设计的元数据包括来源文档类型手册/TSB/工单/故障码表、适用车型、年款、发动机型号、故障码如果有、页码。这些字段在检索阶段的价值是巨大的后面可以通过布尔过滤表达式把检索范围限定在特定车型、特定故障码下既减少向量检索的干扰又大幅提升召回精度。3. Milvus 侧的关键决策Schema、索引、混合检索与过滤3.1 Collection 的字段设计要能支撑汽修场景的过滤Milvus 是一个分布式向量数据库和 Elasticsearch 的“向量功能”定位不同它在亿级向量规模下的检索性能是专门设计过的。但对汽修这种几万到几十万 chunk 的小规模场景Milvus 的真正优势不是性能而是字段表达能力。我建 Collection 时用的是动态字段关闭、显式定义 schema 的方式from pymilvus import CollectionSchema, FieldSchema, DataType fields [ FieldSchema(namechunk_id, dtypeDataType.INT64, is_primaryTrue, auto_idFalse), FieldSchema(namecontent, dtypeDataType.VARCHAR, max_length8192), FieldSchema(namesource, dtypeDataType.VARCHAR, max_length128), FieldSchema(namedoc_type, dtypeDataType.VARCHAR, max_length32), FieldSchema(namecar_model, dtypeDataType.VARCHAR, max_length128), FieldSchema(namefault_code, dtypeDataType.VARCHAR, max_length32), FieldSchema(namepage_num, dtypeDataType.INT32), FieldSchema(nameembedding, dtypeDataType.FLOAT_VECTOR, dim1024), ] schema CollectionSchema(fields, descriptionauto_repair_rag_kb, enable_dynamic_fieldFalse)这里有几个值得抠的细节。content 字段我给了 8192 的 max_length不是因为我要往里塞长文本而是为了避免中文长文本截断导致检索结果展示不全。fault_code 字段设计成 VARCHAR 而不是 INT是因为有些诊断代码包含字母前缀用 VARCHAR 才能存下。doc_type 字段是我后来加的它让分数据源检索变成了可能比如只查“门店真实工单”排除“理论手册”这在业务上非常关键。如果你用 Attu 这类可视化工具连接 Milvus 看过数据就会发现一个坑Attu 不是所有版本都能兼容所有 Milvus 版本。我一开始用 Attu latest 连接 Milvus 2.4.x经常刷新不出集合列表后来换成与 Milvus 大版本匹配的 Attu 版本才稳定。所以可视化工具这块版本对齐要提前查好。3.2 索引怎么选HNSW 还是 IVF_SQ8Milvus 的索引类型直接决定检索速度和内存占用。刚开始我图省事全部用 HNSW参数 M32、efConstruction200检索精度确实高但内存哗啦一下上去了。一个只有 5 万条 chunk 的小库居然占了 2 个多 G 内存。后来评估下来发现这数据量根本用不着 HNSW 这种大杀器改用 IVF_SQ8M64、nlist1024内存直接降到原来的三分之一检索耗时在几十毫秒以内完全够用。这里给一个选型建议混合索引方案比单一索引更靠谱。主表用 IVF_SQ8 控制内存单独给高频查询的故障码数据建一个 HNSW 小集合用 PartitionKey 做隔离。不要一上来就追求最高精度先跑通业务再根据评测结果决定要不要换索引。3.3 故障码这类精确条件要单独走一路召回只靠向量检索做汽修问答会漏掉一类非常关键的问题故障码精确匹配。用户说“P0171 是什么故障”嵌入计算后召回的可能是一堆“燃油修正”“氧传感器”相关内容但用户真正想要的是那个码在故障码表里的精确解释。精确匹配是向量检索的弱项必须单独走一路召回。我的方案是混合检索向量召回负责“语义泛化”拿用户问题向量去 Milvus 搜出 Top 20 相似 chunk同时用正则从用户问题里抽出故障码、车型、发动机型号等精确条件走一个轻量关键词检索分支可以是一个故障码表的内存索引也可以是 Milvus 里的标量字段过滤搜出精确命中的内容。最后用 RRFReciprocal Rank Fusion把两路结果融合排序def rrf_fusion(dense_hits, sparse_hits, k60): score defaultdict(float) for rank, hit in enumerate(dense_hits): score[hit.id] 1.0 / (k rank 1) for rank, hit in enumerate(sparse_hits): score[hit.id] 1.0 / (k rank 1) return sorted(score.items(), keylambda x: x[1], reverseTrue)RRF 比直接加分数融合稳因为向量召回和关键词召回的分数尺度不一样直接相加会被大数吞掉。RRF 只看排名不看绝对分数天然免疫尺度问题。这是我这次工程里收益最大的一步召回质量提升是最明显的。3.4 过滤表达式与 Partition 的使用边界Mivus 支持在 search 时带布尔过滤表达式这个功能在汽修场景特别有用。比如用户问“轩逸报 P0171”我可以在向量检索时直接把表达式带上res collection.search( data[query_vector], anns_fieldembedding, param{metric_type: COSINE, params: {ef: 128}}, limit20, exprcar_model in [轩逸, 经典轩逸] and fault_code P0171, output_fields[content, source, doc_type, page_num] )这样检索空间从整个知识库缩小到“轩逸且带 P0171”的内容干扰项大幅减少。但要注意一个边界不要为了缩小范围把所有字段都塞进过滤表达式。过滤字段如果没有索引每次 search 都会做全量扫描性能会崩。我的经验是只把检索价值最高的 23 个字段车型、故障码、文档类型设为可过滤字段其他元数据只做展示。如果数据量大到 100 万级再用 Partition 按车型分区把搜索打到具体 Partition 上。当前门店体量用过滤表达式就够Partition 反而增加了管理成本。4. FastAPI 服务层把检索、生成、工单串成一条业务链路4.1 工程结构不要把所有逻辑塞进 main.py后端选 FastAPI 不是跟风。汽修问答系统要同时接前端小程序、门店管理后台、工单系统API 的并发处理能力和接口文档质量直接影响对接效率。FastAPI 原生异步、自带 OpenAPI 文档、Pydantic 做数据校验这三件事在开发效率上是实打实的红利。工程结构上我坚持服务层拆分main.py 只做应用初始化和路由挂载app/ ├── main.py ├── config.py ├── api/ │ └── v1/ │ ├── chat.py │ └── orders.py ├── schemas/ │ ├── chat.py │ └── order.py ├── services/ │ ├── retriever.py │ ├── generator.py │ ├── rag_pipeline.py │ └── order_service.py └── store/ ├── milvus_client.py └── redis_client.pyRAGPipeline 是核心服务类它编排检索、融合、重排、提示词构建、LLM 调用五个步骤。路由层只做参数校验和响应封装不写业务逻辑。这样后面不管是换 Embedding 模型还是加一个重排服务都只是改 services 层API 层完全不用动。4.2 会话上下文与检索的配合汽修问答不是一锤子买卖用户会连续追问。“那跟氧传感器有关系吗”“需要换件吗”这种问题如果没有上下文检索到的内容会跑偏。我在 FastAPI 里用 Redis 存会话上下文key 是 session_idvalue 是最近 3 轮对话的压缩摘要接口层拿到问题后先做一次“查询重写”class ChatRequest(BaseModel): session_id: str question: str vehicle: VehicleInfo | None None async def rewrite_query(session, question: str) - str: if not session: return question prompt f根据历史对话将用户当前问题补全为可独立检索的问句。 历史 {session} 当前问题{question} 补全后 return await llm.complete(prompt)这个步骤很多人会忽略但它对多轮问答的召回质量影响巨大。把“那跟氧传感器有关系吗”改写成“P0171 故障与氧传感器是否相关”检索结果会完全不同。重写后的问句只用于检索生成回答时还是用原始问题和完整上下文避免信息丢失。4.3 工单抽取从自由文本到结构化状态机问答做完紧接着是工单生成。这里最大的难点不是技术而是“怎么让大模型输出结构化工单字段”。我的方案是用 function calling函数调用模式让模型在生成回答的同时把一个工单草稿对象完整输出出来{ fault_code: P0171, symptom: 怠速抖动原地加速无力, possible_causes: [真空泄漏, 进气歧管垫老化, 前氧传感器故障], suggested_repairs: [烟雾测漏, 检查进气岐管垫, 读数据流确认氧传感器], urgency: medium, estimate_hours: 2.5 }后端用 Pydantic 定义 OrderDraft schemaLLM 输出的 JSON 直接反序列化校验。校验不通过就要求模型重新生成最多重试两次。这样比让模型自由发挥再用正则硬抠字段可靠得多。工单生成后进入状态机流转。我用一张状态表管理工单生命周期状态含义可流转到DRAFT问答自动生成的草稿CONFIRMED, CANCELLEDCONFIRMED前台确认等待派工ASSIGNED, CANCELLEDASSIGNED已指派给技师IN_PROGRESSIN_PROGRESS维修中COMPLETED, REOPENEDCOMPLETED完工等待回访ARCHIVED, REOPENEDARCHIVED归档知识回流入库-“知识回流入库”这个动作放在完工之后技师把实际更换的配件和维修结论补录进工单系统把工单文本和结论打包成新的结构化 chunk切片后灌回 Milvus。这一步把闭环焊死了也是整个系统能和传统问答系统拉开差距的地方。5. 冷启动与上线前评测先把这三件事做完5.1 冷启动知识库先喂哪些内容很多团队做知识库冷启动时恨不得把所有资料一口气灌进去结果检索质量一塌糊涂。正确顺序是按“高频问题覆盖优先”来做。我拿门店三个月的咨询记录统计了一下问得最多的集中在几类故障灯含义、发动机抖动/异响、刹车异常、保养周期、个别车型的通病问题。冷启动阶段先把这些问题的答案语料喂进去覆盖率达到 70% 以上系统就具备可用性了。具体的喂数据顺序建议是故障码表精确且结构化→ 历史工单真实案例→ 官方手册的常见故障章节 → TSB 技术通报 → FAQ 和保养资料。前两类是冷启动的主力手册和通告是后续增量的弹药。5.2 组建评测集30 条种子问题就够了上线前最怕的就是“demo 跑得溜一上真实环境就拉胯”。要避免这个必须在项目一开始就建评测集。我第一次建评测集只花了半天时间整理出 40 条真实验收时可能会被问到的问题每条问题配三个字段期望召回的文档 ID、答案中必须出现的关键词、期望的故障码。比如eval_set [ { query: 轩逸怠速抖动读码P0171怎么查, relevant_docs: [c001, c027, c218], must_keywords: [真空泄漏, 进气歧管垫], fault_code: P0171 } ]评测集不用多3050 条就能抓住主要问题。关键是每条问题都要来自真实业务场景不要自己编自己答。我当时直接拿着客服部的历史聊天记录整理问题效果比我自己脑补出来的问题好太多。5.3 检索侧与生成侧的评估口径RAG 系统的评测要分两层看。检索侧看 hit_ratek 和 MRR。hit_rate5 衡量 Top 5 结果里有没有相关文档MRR 看第一个相关文档排得靠不靠前。这两个指标决定召回质量是 RAG 的命根子。生成侧看 faithfulness忠实度和 answer relevance答案相关性。忠实度就是这个答案里的每个结论是不是都能在检索到的上下文里找到依据我通常会让 LLM 把答案拆成声明句逐句核对上下文def check_faithfulness(answer, context): statements llm.split_statements(answer) supported 0 for stmt in statements: verdict llm.verify_supported(stmt, context) supported verdict return supported / len(statements)实际测试中我见过典型的“检索没问题但生成乱编”的情况——上下文明明只提到真空泄漏模型却在回答里加了一句“建议更换氧传感器”。这种幻觉在汽修领域是致命的技师要是照着做了就是徒增成本。所以我在 prompt 里强制写了一条规则资料里没有的内容直接回答“资料不足无法确认”不要试图推理补充。加入这条规则后faithfulness 明显回升。6. 部署与踩坑实录Milvus 内存、Attu 版本、异步线程池6.1 Docker Compose 搭建 Milvus StandaloneMilvus 部署最省心的是 Standalone 模式用官方 Docker Compose 文件一把拉起三个容器etcd元数据、MinIO存储、Milvus 主服务。离线内网环境也可以部署提前把镜像导出导入就行。启动后第一件事不是急着建集合而是确认服务健康状态然后装个可视化工具检查数据。Attu 是 Milvus 官方社区常用的 GUI 工具部署命令大概长这样docker run -d --name attu \ -p 8000:3000 \ -e MILVUS_URLlocalhost:19530 \ milvusdb/attu:latest这里就有一个版本匹配的坑Attu 的 latest 标签不一定兼容老版本 Milvus。我当时用的 Milvus 2.4.xAttu 却是 v2.5 的 latest结果连接后集合列表加载异常。后来网上查了下Attu 的大版本要和 Milvus 大版本对齐换成 v2.4 的版本后一切正常。类似这种工具版本问题部署前先去官方 release 页确认对应关系比浪费时间排查快得多。6.2 pymilvus 在 FastAPI 里的异步处理这是我在开发中踩过最隐晦的一个坑FastAPI 的 async 路由如果直接调用 pymilvus 的同步接口会阻塞事件循环。症状就是单用户请求一切正常并发一上来整个 API 响应时间线性恶化。因为 pymilvus 的 collection.search() 是同步阻塞操作放到 async 函数里执行等于把事件循环粘住了。解决方案有两种。一是把路由函数声明成普通 defFastAPI 会把它放进线程池运行不阻塞事件循环二是在 async 路由里用 anyio 的线程桥接显式丢到后台线程from anyio import to_thread async def search_milvus(collection, vector): result await to_thread.run_sync( collection.search, data[vector], anns_fieldembedding, param{metric_type: COSINE, params: {ef: 128}}, limit20 ) return result我最终是两种方式混用高频接口走线程池低频管理接口走线程桥接。上线后压测过一轮500 并发下 P99 稳定在 1.2 秒以内事件循环阻塞问题彻底消失。6.3 实战中让我印象最深的几个坑梳理一下整个项目里最典型的几个问题给后面做类似系统的朋友做个清单坑现象根因解决办法故障码被切断P0171 检索不到文本切分把代码从中间切开了切分前做敏感实体标记切完还原内存爆炸Milvus 持续吃掉多个 G数据量小但用了 HNSW 大参数换 IVF_SQ8降低索引参数Attu 不显示集合列表页转圈Attu 与 Milvus 版本不匹配对齐大版本号再连pymilvus 阻塞事件循环并发下接口变慢同步调用堵在 async 路由里用 def 路由或 run_sync 接线程池LLM 编造维修结论回答里出现资料没有的修法prompt 缺少约束加“资料不足拒绝回答”规则重写查询丢失关键字段多轮问答后故障码消失重写 prompt 没强调保留条件重写提示词强制保留故障码和车型最后一个值得单独说的坑多轮重写查询后P0171 这种关键条件被模型“优化”掉了导致检索引擎找不到精确匹配。我后来在重写提示词里加了一句“必须保留问题中出现的所有故障码、车型和发动机型号”再配合正则兜底校验问题就绝迹了。6.4 一个不太起眼但很关键的经验如果让我重新做一遍这个系统我会把“评测集”的建立提前到项目第一天而不是等到上线前一周。原因很简单没有评测集你每次改切分策略、换 Embedding、调索引参数都只能靠感觉判断效果有没有变好。有了评测集每次改动跑一遍recall 是升是降一目了然。这套习惯帮我省下的时间比写系统本身还多。另外一个小技巧汽修场景的检索结果一定要把“来源”和“页码”展示给用户。技师和客服对“这个是厂家手册 12 页说的”和“这个是我编的”之间信任度是天壤之别。给答案配上可信来源系统落地时的接受度会高不少。