![[07cf09d432524716b483015d9a24cc6a.png]]

Milvus x 智能客服 :从“找对商品”到“答对细节”的检索体系升级

我们熠坤AI初创公司推出的基于⼤模型RPA 和 RAG 技术的智能客服系统,⽀持淘宝、京东、拼多多等 5+ 电商平台,并已接入头部领域店铺,旨在帮助客服自动回复客户的各种问题。

在早期版本中,我们的系统完全采用 Ragflow 架构,通过将商品的全部内容直接进行粗放式切片(Chunk)处理。但随着接入店铺数据量的增长和咨询场景的复杂化,该架构暴露出了严重的工程与业务痛点: 一方面,检索粒度完全不可控,导致大量无关噪音混入上下文;另一方面,Ragflow系统本身日益臃肿,维护难度大,系统脆弱性增加。

于是在经过大量的调研与测试以后,我们将语义搜索的向量数据库迁移到了milvus,与postgre传统数据库的精确匹配形成互补。


1. 核心需求拆解

我们在落地智能客服 RAG 的过程中,最核心的挑战集中在五类:

  1. 对象定位难
    用户大量问题是“这个怎么样/耗电吗/怎么安装”,如果没先定位“这个”到底指哪一个商品,后面一切都无意义。只要商品检索错了,后面生成再好也没用。
  2. 语义理解与词法匹配的冲突
    商品检索里既有强语义(“制热快不快”),也有强词法(型号、系列名、SKU、简称)。单靠一种检索策略,很容易要么“语义对了商品错了”,要么“商品对了细节不相关”。
  3. 知识源多且形态不一
    既有通用 QA(售后、物流、保修),也有商品知识(参数、卖点、说明书内容),还可能有结构化表格(活动价、规格清单、库存/渠道数据)。如果进同一个知识库,后期很难治理、也难迭代。
  4. 存在完全无法通过语义检索的场景
    用户可能会查询@暖风机表格中价格小于100的商品,依托于相似度的语义检索无法精确解决类似问题。
  5. 时延、成本与稳定性约束
  6. 客服是高并发、强实时,必须在可控的 token 开销与可预测的时延下运行。能规则化解决的问题要前置拦截;需要检索的问题要尽量并行;检索结果要尽量“少而准”。

基于这些挑战,我们把检索与数据链路拆成可演进的模块,并采用Milvus 这个开源的向量检索中间件来解决这些问题。

2. 整体方案:一个面向客服的“分层检索 + 证据生成”链路

我们把一次对话请求拆成四段:

  1. Query 理解:把口语化对话句转成可检索表达(后面会讲“问题重写 + 焦点商品提取”)
  2. 分层并行检索:通用知识库(QA)与商品知识库(Product)并行召回并使用rerank重排
  3. 两级回填拼装:Milvus 做召回与排序,Postgres 做权威字段与内容拼装
  4. 基于证据生成:把检索结果分层注入 prompt,约束 LLM 在证据范围内回答

如果对话中出现语义检索无法完成的任务,我们用Text2SQL技术进行表格检索:让模型能查结构化数据,并保持确定性。

同时我们还在探索agent检索用户自己维护的任意表格,也打算利用milvus来实现
![[Pasted image 20251224185136.png]]

3.核心技术实现设计

3.1. 数据收集与预处理:单商品单记录策略

构建知识库首先要有数据。与业界通用的“将文档切分为碎片化 Chunks 分散存储”的方案不同,我们发现商品数据具有极强的内聚性。如果把一个商品的参数、描述、评价拆得太散,检索时很难拼凑出全貌。

因此,我们采取了“单商品单向量记录”的策略 。 我们在同步数据到 Milvus 时,将一个商品下的所有内容块预先拼接合并,存为一条 Milvus 记录,ChunkID 统一格式为 product_{productId}

这种设计让 Milvus 中的记录数从 $N \times M$ 降低到了 $N$($N$=商品数),不仅显著降低了存储成本,更简化了后续的召回拼装逻辑——一次命中,即可获得该商品的完整上下文。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public void syncProductToMilvus(ProductEntity product, List<ProductChunk> chunks) {
      // 拼接所有chunkContent,使用双换行符分隔
      String combinedContent =
          chunks.stream()
              .map(ProductChunk::getChunkContent)
              .filter(content -> content != null && !content.trim().isEmpty())
              .collect(Collectors.joining("\n\n"));
     
      // 生成商品名称向量
      List<Float> goodNameVector = embeddingClient.generateProductNameVector(product.getProductTitle());
      // 生成拼接后的内容向量
      List<Float> describeVector = embeddingClient.generateContentVector(combinedContent);
      // 使用productId生成固定的chunkId(格式:product_{productId})
      String milvusChunkId = "product_" + product.getId();
      // 先删除该商品在Milvus中的旧记录(如果存在)
      String deleteExpr = String.format("productid == %d", product.getId());
      milvusClient.deleteVectors(deleteExpr);
      // 创建向量数据
      MilvusClient.VectorData vectorData =
          new MilvusClient.VectorData(
              milvusChunkId,
              product.getId(),
              product.getAiSolutionId().longValue(),
              product.getUid(),
              goodNameVector,
              describeVector);
      // 插入向量数据
    milvusClient.insertVectors(List.of(vectorData));
 
    }
  }

3.2. Embedding 与索引架构:两级存储设计

有了清洗好的数据,接下来是构建索引。我们采用了一套“Milvus + PostgreSQL”的两级检索架构,以此来兼顾检索速度与数据的权威性。

  • 向量库(Milvus)负责“找得准”: 我们将数据向量化后存入 Milvus,作为“索引指针”。这里并不存储冗余的业务字段,只存储用于计算相似度的向量和主键 ID(product_{productId})。使用IVF_FLAT索引,平衡检索速度和存储成本。

    1
    2
    3
    4
    5
    6
    7
    8
    CreateIndexParam goodNameIndexParam =
              CreateIndexParam.newBuilder()
                  .withCollectionName(collectionName)
                  .withFieldName("goodnamevector")
                  .withIndexType(IndexType.IVF_FLAT)
                  .withMetricType(MetricType.COSINE)
                  .withExtraParam("{\"nlist\":1024}")
                  .build();
  • 关系库(PostgreSQL)负责“信息全”: 商品的实时价格、高清图片 URL、上下架状态等结构化信息存储在 Postgres 中。

3.3. 查询前置处理:问题重写与焦点提取

如前面困境所说,用户的 Query 实际是多种多样的,有简单的 Query,也有指代不明的 Query。比如用户进线看了“美的取暖器”,然后问:“这个制热快吗?”。

如果直接拿“这个制热快吗”去检索,召回率几乎为零。为此,我们在检索前引入了 Query 转换模块 。 通过 QuestionRewriteUtil,利用 LLM 对用户问题进行重写,提取出“焦点商品” (focusProduct),并将问题转化为“最新问题” (latestQuestion) 。

1
2
3
4
conversationText.append(
          "\n\n 任务:请从以上内容中提取出当前客户咨询的焦点商品是什么,最新咨询问题是什么(也要把商品补充到问题中),直接以json格式输出结果.(如果记录中有出现商品完整名称,尽量使用完整名称)(进线商品为用户从该商品的详情页进入咨询界面,如果后续对话的焦点商品有所转移,要正确判断)"

              + "case 1:客户从暖风机商品进线,并咨询了相关信息。(此时焦点商品应为暖风机);咨询完暖风机之后转移话题到推荐一款静音的风扇。那么此时的焦点商品应该为静音的风扇。");

例如:

  • 原始问题:“这个制热快吗?”
  • 重写后:“美的取暖器制热效果如何?”

这一步把“对话语句”转成了“检索语句”,对后续的召回准确率起到了决定性作用。

3.4. 向量查询

这是整个系统的核心。为了解决用户千奇百怪的提问方式,我们设计了多维度的检索策略。根据用户输入情况,自动选择最合适的检索策略。

3.4.1 双层知识库并行检索+postgre回填

客服场景对响应速度极其敏感。我们的知识库分为两层:

  1. QA 通用库:存储通用的售后政策、退换货流程 。
  2. 商品专属库:存储具体的商品参数、规格 。

Workflow.run() 中,我们利用线程池进行并行执行

  • QA 检索:使用原始问题。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // QA Milvus检索任务
    if (args.getUid() != null && args.getAiSolutionId() != null)
                            // 直接使用QaMilvusClient进行QA向量检索(使用原始问题)
                            List<org.com.aicaresupport.entity.QaSearchResult> qaResults =
                                qaMilvusClient.searchQaVectors(
                                    args.getUid(),
                                    args.getAiSolutionId(),
                                    finalOriginalQuestion,
                                    qaTopK);
                           
                             
  • 商品混合检索:使用重写后的“焦点商品+问题”。

    1
    2
    3
    4
    5
    6
    7
    8
    if (args.getUid() != null && args.getAiSolutionId() != null)
    List<MilvusClient.SearchResult> searchResults =
                                milvusService.hybridSearch(
                                    args.getUid(),
                                    args.getAiSolutionId(),
                                    finalFocusProduct,
                                    finalLatestQuestion,
                                    goodsTopK);

    其中在milvus的商品混合检索中我们面临一个经典冲突:用户搜“HP21-K6”时需要精确的对象定位,搜“适合老人的取暖器”时需要模糊的语义理解。单一的向量索引很难同时满足。 为此,我们在 Milvus 中为每条记录维护了两个向量字段,并赋予不同权重:

  • **商品名称向量 (goodnamevector)**:权重 **70%**。侧重对象定位,保证搜出来的是“正确的那个商品”。

  • **描述向量 (describevector)**:权重 **30%**。侧重语义理解,提升回答细节的相关性。 系统并行检索这两个字段,确保了“先找对商品,再匹配细节”。

系统并行检索这两个字段,合并得分后进行加权融合重排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 合并结果并重排序(简单实现:商品名称权重70%,描述权重30%)
      Map<String, SearchResult> combinedResults = new HashMap<>()
      // 添加商品名称搜索结果(权重0.7)
      for (SearchResult result : goodNameResults) {
        result.setScore(result.getScore() * 0.7f);
        combinedResults.put(result.getChunkId(), result);
      }
      // 添加描述搜索结果(权重0.3)
      for (SearchResult result : describeResults) {
        String chunkId = result.getChunkId();
        if (combinedResults.containsKey(chunkId)) {
          // 如果已存在,累加分数
          SearchResult existing = combinedResults.get(chunkId);
          existing.setScore(existing.getScore() + result.getScore() * 0.3f);
        } else {
          // 如果不存在,添加新结果
          result.setScore(result.getScore() * 0.3f);
          combinedResults.put(chunkId, result);
        }
      }

返回的主键 ID(product_{productId}),会到PostgreSQL进一步查询,返回最精准的商品数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 批量从PostgreSQL获取完整内容
                        List<String> qaChunks = new ArrayList<>();
                        if (!qaResults.isEmpty()) {
                          // 收集所有 chunkId
                          List<String> chunkIds =
                              qaResults.stream()
                               .map(org.com.aicaresupport.entity.QaSearchResult::getChunkId)
                                  .collect(Collectors.toList());
                          // 批量查询
                          List<QaChunk> chunks = qaChunkRepository.findByChunkIdIn(chunkIds);
                          Map<String, QaChunk> chunkMap =
                              chunks.stream()
                                  .collect(
                                      Collectors.toMap(
                                          QaChunk::getChunkId, chunk -> chunk, (a, b) -> a));

最后,大模型会根据QA检索以及商品检索返回的topK 个数据生成回复。

3.4.2 Text2SQL表格检索 + 语义补充

上面所说的算是一个比较标准的 RAG 流程,但是企业内部有大量的结构化数据(如 Excel 价格表、参数对比表)。对于“查询价格小于 100 的商品”这类涉及数值比较的问题,纯向量检索几乎不可用。

为此,我们在 Agent 模式下引入了 search_table_data 工具,采用了 Text2SQL + 语义补充 的方案 :

  1. Text2SQL:利用 LLM 将自然语言转译为 SQL 语句(如 WHERE price < 100)去postgreSQL检索,保证数值查询的绝对精确 。
  2. 语义补充:同时在milvus中进行向量检索,防止 SQL 生成失败或用户意图模糊 。
  3. 结果融合:优先采信 SQL 的结构化结果,用向量检索结果做兜底补充。

最后,大模型会根据Text2SQL表格检索 + 语义补充返回的数据生成回复。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public TableSearchResult searchTableData(
    String uid, Integer aiSolutionId, String tableId, String query) {
  // 1. 获取表结构和示例数据(用于 Text2SQL)
  String schemaAndSample = getTableSchemaAndSample(tableId);
  // 2. Text2SQL: 将自然语言查询转换为 SQL
  String sql = textToSql(query, schemaAndSample, tableName, tableId);
  // 3. 执行 SQL 查询
  List<TableRowData> sqlResults = executeSqlSearch(tableId, sql, topK);
  // 4. Milvus 语义检索(作为补充)
  List<TableRowData> semanticResults =
      executeSemanticSearch(uid, aiSolutionId, tableId, query, topK);
  // 5. 合并结果(去重,SQL结果优先)
  List<TableRowData> combinedResults = combineTableResults(sqlResults, semanticResults, topK);
}

4.实施效果与验证

2025年1月-5月:我们完成Milvus商品搜索功能调研、技术验证与方案设计;2025年6月:完成精确匹配功能开发、测试并部署上线。

比起单纯使用RAGflow的技术框架,我们在Milvus基础上设计了更加灵活集中功能组件的方案,让商品在多种业务场景下检索的召回率大大提升。milvus对检索精度极高的掌控力以及原生支持的混合检索的能力,以及极高的性能扩展性好社群活力,挖掘出了我们产品更大的潜力。

5.未来规划