第06章 构建工单知识库

张开发
2026/6/6 12:37:29 15 分钟阅读

分享文章

第06章 构建工单知识库
第06章 构建工单知识库作者亢AIRTC| 源码地址https://github.com/kang-airtc/ollama-mini-book在前面的章节中笔者把文本切片、向量化、ChromaDB 写入查询的零件逐一调通。本章把这些零件组装到一起搭建本书贯穿案例的核心电商工单语义检索知识库。读者将看到一条完整工单从原始字典到向量库的全过程并理解 ChromaDB 中文档、元数据、向量三者协同的工程细节。完成本章后读者将拥有一个可被后续 Agent 工具直接调用的工单语义检索能力并具备把这一思路迁移到任意业务数据上的方法。6.1 工单数据的结构与建模工单是一种典型的半结构化数据它的核心字段是固定的工单号、客户、类型、状态但描述部分是自由文本。这种半结构化特征恰好对应 ChromaDB 的设计固定字段进入元数据用于过滤自由文本进入文档用于向量化。6.1.1 工单字段的职责划分配套源码中的工单包含 11 个字段分别承担不同职责。字段分类与作用“如表6-1”所示。表 6-1 工单字段的职责划分字段类型在向量库中的角色ticket_nostring唯一 IDcustomer_namestring元数据可用于按客户过滤customer_emailstring元数据联系方式记录issue_typestring元数据与文档拼接共用prioritystring元数据可用于优先级筛选statusstring元数据可用于状态过滤subjectstring文档主体的核心句descriptionstring文档主体的补充说明created_atstring元数据时间维度resolved_atstring 或空元数据处理时长计算satisfaction_scoreint 或空元数据质量指标读者注意 issue_type、priority、status 这类字段同时出现在元数据与文档文本中进入元数据是为了支持按条件过滤拼接到文档文本是为了让向量检索“知道”这些标签词的语义让查询包含“高优先级退款”这种语义时能在向量层面命中。6.1.2 元数据与文档的协同检索ChromaDB 支持向量检索与元数据过滤的组合。先用 where 条件缩小候选集再在候选集内做向量相似度排序是工单场景常用的检索模式。检索模式“如图6-1”所示。笔者建议把这条模式作为构建工单知识库的默认思路业务上的硬性约束如只查未解决工单走元数据过滤语义层面的相似如“物流相关投诉”走向量检索。两者分工清晰避免把所有判断都压到向量相似度上。注意ChromaDB 的 where 条件只能在已索引的元数据字段上做精确匹配或简单逻辑组合复杂查询能力远不如关系数据库业务上不要把它当全功能 SQL 用。6.2 工单数据的格式化与清洗把原始工单字典放进 ChromaDB 之前需要做两件事把多字段拼成一段语义连贯的文本作为文档主体把元数据中的空值清理为 ChromaDB 可接受的形式。本节实现这两步。6.2.1 把工单拼成可向量化的文本format_ticket_text 函数把工单字段按固定模板拼接成一段文本作为向量化的输入。defformat_ticket_text(ticket:dict)-str:return(f工单号:{ticket[ticket_no]}| f客户:{ticket[customer_name]}| f类型:{ticket[issue_type]}| f优先级:{ticket[priority]}| f状态:{ticket[status]}| f主题:{ticket[subject]}f{( | 描述:ticket[description])ifticket.get(description)else})拼接顺序经过权衡把工单号放最前便于人眼定位把类型、优先级、状态等结构标签放前段让嵌入模型在编码时优先考虑这些语义标签最后是主题与描述这两条自由文本承担最主要的语义负载。读者可以根据自己的业务调整字段顺序但同一个项目中应保持一致避免向量空间漂移。6.2.2 清理元数据中的空值ChromaDB 不接受 None 作为元数据值原始工单中 resolved_at 与 satisfaction_score 可能为空需要做一次清洗。defclean_metadata(ticket:dict)-dict:cleaned{}forkey,valueinticket.items():ifvalueisNone:cleaned[key]else:cleaned[key]valuereturncleaned把 None 统一替换为空字符串是一种简单的兜底策略。读者也可以按字段类型选择不同的占位值例如把空时间戳替换为远未来的字符串以便后续 SQL 风格的范围比较但要在元数据 Schema 文档中明确记录占位语义避免数据消费者误读。注意ChromaDB 元数据值仅支持 string、int、float、bool 四种类型把列表或嵌套字典塞进去会在写入时抛错需要先序列化为字符串。6.2.3 文档与元数据的对照读者可能会疑问既然字段已经塞进了元数据为什么还要在文档中拼一遍这是因为元数据只用于精确匹配向量检索完全不感知元数据内容。让 issue_type 这类标签词出现在文档文本里是为了让“高优先级退款投诉”这种自然语言查询能通过向量相似度命中“issue_type退款申请, priorityhigh”的工单。笔者将这种“双重存储”称为标签词的双通道暴露通过元数据通道接受精确过滤通过文档通道接受语义检索。代价是数据冗余收益是检索表达力。6.3 初始化样本数据并写入向量库理解了格式化与清洗逻辑之后整个知识库的初始化就是顺着工单列表逐条调用 Ollama 嵌入接口再写入 ChromaDB 集合。本节按 init_sample_data 的实现走一遍。6.3.1 集合的创建与幂等ChromaDB 持久化客户端在指定目录下管理集合集合不存在时创建、存在时取回。importchromadbfrompathlibimportPath CHROMA_DB_PATHPath(./chroma_db)CHROMA_DB_PATH.mkdir(exist_okTrue)chroma_clientchromadb.PersistentClient(pathstr(CHROMA_DB_PATH))try:tickets_collectionchroma_client.get_collection(tickets)exceptException:tickets_collectionchroma_client.create_collection(nametickets,metadata{description:电商工单数据集合支持向量检索},)代码使用 try 加 except 而非 get_or_create_collection是为了在第一次创建时附带 metadata 描述。读者也可以直接使用 get_or_create_collection 简化代码差别仅在元数据描述是否记录。6.3.2 初始化前的幂等检查如果集合中已有数据重复初始化会产生重复写入。init_sample_data 用一次 count 调用判断是否需要初始化。definit_sample_data():counttickets_collection.count()ifcount0:print(fChromaDB 中已有{count}条工单数据跳过初始化)return# ... 后续是定义 sample_tickets 并逐条写入这种简单的“非空就跳过”策略适合演示数据。生产场景下应按业务键判断是否需要更新并区分初次写入与增量更新两种路径避免演示数据被误覆盖。6.3.3 逐条向量化并写入对每条工单依次完成“拼文本、生成向量、清理元数据、写入集合”四个动作。forticketinsample_tickets:ticket_textformat_ticket_text(ticket)embeddingget_embedding(ticket_text)ifembedding:cleaned_metadataclean_metadata(ticket)tickets_collection.add(ids[ticket[ticket_no]],embeddings[embedding],documents[ticket_text],metadatas[cleaned_metadata],)print(f{ticket[ticket_no]}:{ticket[subject][:30]}...)else:print(f{ticket[ticket_no]}: 嵌入失败)读者从这段代码中可以提炼出一条通用模式向量化失败时不要让整批数据写入中断而是记录失败工单号等批次结束后单独重试。生产环境里 Ollama 偶发的连接抖动或模型未加载情况下这种宽容写入比一处出错全部回滚更实用。注意ChromaDB 的 add 在 id 已存在时会抛错需要更新数据时应使用 upsert 方法或者先 delete 再 add。6.4 检索接口的封装与返回结构数据进库后下一步是把检索能力封装成清晰的接口。本节实现的 search_tickets_semantic 既会出现在本章中作为业务函数也会在下一章作为 MCP Tool 暴露给 Agent。6.4.1 检索函数的核心逻辑检索函数接收自然语言查询与返回条数内部完成查询向量化、ChromaDB 查询、结果格式化。defsearch_tickets_semantic(query:str,n_results:int5)-str:query_embeddingget_embedding(query)ifnotquery_embedding:returnjson.dumps({error:生成查询向量失败},ensure_asciiFalse)resultstickets_collection.query(query_embeddings[query_embedding],n_resultsn_results,include[metadatas,documents,distances],)# ... 后续是格式化为统一结构include 字段显式指定需要返回哪几列省略时 ChromaDB 默认返回 metadatas、documents、distances但显式写出更利于维护避免库版本升级后默认值变化。6.4.2 距离到相似度的换算ChromaDB 返回的是距离值距离越小越相似。把它转换为习惯的相似度分数越接近 1 越相似便于上层业务理解。fori,ticket_idinenumerate(results[ids][0]):ticketresults[metadatas][0][i]distanceresults[distances][0][i]documentresults[documents][0][i]search_results.append({ticket_no:ticket_id,customer_name:ticket.get(customer_name,),issue_type:ticket.get(issue_type,),priority:ticket.get(priority,),status:ticket.get(status,),subject:ticket.get(subject,),description:ticket.get(description,),created_at:ticket.get(created_at,),similarity_score:round(1-distance,4),matched_text:document[:100]...iflen(document)100elsedocument,})similarity_score 使用 1 减距离的简单变换对归一化向量来说这是合理的近似但严格意义上距离与相似度并不互为简单反函数。读者如果需要把分数做精确门限判断应改用 numpy 直接计算余弦相似度或在写入时记录向量并在查询后二次计算。6.4.3 返回结构的设计原则检索接口返回 JSON 字符串而非 Python 字典是为了适配后续 MCP Tool 协议要求。设计返回结构时遵循三个原则包含原始查询用于调试、显式给出结果数量便于上层判空、每条结果包含足够信息让模型不必再次查表。返回 JSON 的字段结构“如表6-2”所示。表 6-2 工单检索返回 JSON 的字段结构字段类型作用querystring原始查询语句便于调试total_resultsint返回结果数量resultsarray工单列表每项为单条工单的完整摘要让 LLM 直接消费这个结构是设计上的关键模型看到一份带相似度分数、状态、优先级、主题描述的工单清单时可以直接做出“哪些与用户问题相关、应给出什么建议”的判断无需再调用其他工具补充信息。注意JSON 字符串中需要保证 ensure_asciiFalse否则中文会被转义为 \uXXXX 形式影响后续 prompt 的可读性与 token 占用。6.5 本章小结本章把工单数据从原始字典走到向量库再以一个语义检索接口呈现给上层。其中三个工程细节值得读者带走标签词在元数据与文档中双通道暴露、空值在写入前显式占位、检索返回结构包含足够信息让 LLM 自给自足。到此为止知识库已经具备完整能力。但是 search_tickets_semantic 当下还是一个普通 Python 函数只能被同进程的代码调用无法被 Agent 跨进程调用。下一章笔者将把它升级为一个 MCP Tool让外部的 Agent 或后端服务能通过标准协议调用这个检索能力。作者光谷老亢配套源码https://github.com/kang-airtc/ollama-mini-book

更多文章