电商 AI 客服系统的 RAG 实践:从 Embedding 到多轮对话

0 阅读1分钟

标签RAG LLM Embedding 向量数据库 Node.js AI 客服


为什么电商客服需要 RAG

直接用大模型做客服会遇到两个致命问题:

  1. 幻觉(Hallucination):大模型不知道你家的商品信息,会编造尺寸、价格、材质等关键参数
  2. 时效性:促销活动、库存状态、物流信息实时变化,模型训练数据跟不上

RAG 的核心思路是:先检索,再生成。用户提问 → 从商品知识库中检索相关信息 → 把检索结果作为上下文喂给 LLM → LLM 基于真实数据生成回答。

这不是什么新概念,但在电商场景下的工程落地有很多细节值得讨论。


整体架构

graph LR
    User[用户消息] --> Router{意图路由}
    Router -->|商品咨询| RAG[RAG Pipeline]
    Router -->|物流查询| LogisticsAPI[物流 API]
    Router -->|转人工| HumanAgent[人工客服]

    subgraph RAG Pipeline
        Embed[Query Embedding] --> Search[向量检索<br/>Milvus]
        Search --> Rerank[重排序<br/>Cross-Encoder]
        Rerank --> Context[上下文组装]
        Context --> LLM[LLM 生成<br/>GPT-4o / DeepSeek]
    end

    subgraph Knowledge Base
        Products[商品信息<br/>200+ SKU]
        FAQ[常见问答<br/>500+ 条]
        Policy[售后政策<br/>退换货/质保]
    end

    Knowledge Base -.->|离线索引| Search

下面按模块展开。


一、知识库构建与分块策略

数据源

电商客服知识库的数据源通常包括:

数据类型示例更新频率格式
商品基础信息名称、价格、尺寸、材质每日结构化 JSON
商品详情描述详情页文案、卖点描述上新时长文本
FAQ"这个能水洗吗?""包邮吗?"每周Q&A 对
售后政策退换货规则、质保条款每月文档
促销活动满减规则、优惠券使用条件每日结构化
用户评价摘要高频好评/差评关键词每周文本

分块策略(Chunking)

分块是 RAG 系统中最容易被低估的环节。分块太大,检索不精准;分块太小,上下文不完整。

我们最终采用的是混合分块策略

// rag/chunker.ts
interface Chunk {
  id: string;
  content: string;
  metadata: {
    source: 'product' | 'faq' | 'policy' | 'promo';
    productId?: string;
    category?: string;
    updateTime: string;
  };
}

class HybridChunker {
  // 策略 1:结构化数据 → 按实体分块(一个 SKU 一个 chunk)
  chunkProduct(product: Product): Chunk[] {
    // 商品基础信息作为一个独立 chunk
    const baseChunk: Chunk = {
      id: `product-base-${product.id}`,
      content: this.formatProductBase(product),
      metadata: {
        source: 'product',
        productId: product.id,
        category: product.category,
        updateTime: new Date().toISOString()
      }
    };

    // 商品详情按段落分块,每块 300-500 tokens
    const detailChunks = this.splitByParagraph(product.description, {
      minTokens: 300,
      maxTokens: 500,
      overlap: 50, // 50 tokens 重叠,保持上下文连贯
    }).map((text, i) => ({
      id: `product-detail-${product.id}-${i}`,
      content: `[${product.name}] ${text}`, // 前缀加商品名,提高检索相关性
      metadata: { source: 'product' as const, productId: product.id,
                  category: product.category, updateTime: new Date().toISOString() }
    }));

    return [baseChunk, ...detailChunks];
  }

  // 策略 2:FAQ → 问答对作为一个 chunk
  chunkFAQ(faq: FAQ): Chunk {
    return {
      id: `faq-${faq.id}`,
      // 问题和答案拼接,这样问题和答案都能被检索到
      content: `问题:${faq.question}\n回答:${faq.answer}`,
      metadata: { source: 'faq', productId: faq.productId,
                  category: faq.category, updateTime: new Date().toISOString() }
    };
  }

  // 策略 3:政策文档 → 按标题层级分块
  chunkPolicy(doc: string): Chunk[] {
    const sections = this.splitByHeading(doc);
    return sections.map((section, i) => ({
      id: `policy-${i}`,
      content: section.heading + '\n' + section.body,
      metadata: { source: 'policy' as const, category: section.heading,
                  updateTime: new Date().toISOString() }
    }));
  }

  private formatProductBase(p: Product): string {
    // 结构化信息转自然语言,提高语义检索效果
    return [
      `商品名称:${p.name}`,
      `品类:${p.category}`,
      `价格:${p.price} 元`,
      `规格:${p.specs.map(s => `${s.key}: ${s.value}`).join('、')}`,
      `卖点:${p.highlights.join('、')}`,
      p.stock > 0 ? '库存状态:有货' : '库存状态:暂时缺货',
    ].join('\n');
  }
}

关键经验

  • 结构化数据转自然语言:向量检索是语义匹配,"价格: 299" 这种 key-value 格式的检索效果远不如 "价格:299 元"
  • 商品名前缀:每个 chunk 加商品名前缀,避免检索到"A 商品详情"却回答了"B 商品的问题"
  • 重叠分块:长文本分块时保留 50 tokens 重叠,防止关键信息被截断在块边界

二、Embedding 模型选型

我们对比了 4 个 Embedding 模型在电商 QA 场景下的检索效果:

模型维度中文电商 QA Recall@5延迟(单条)部署成本
OpenAI text-embedding-3-large307289.2%120msAPI 计费
BGE-M3 (BAAI)102491.7%45ms自部署 A10
Cohere embed-multilingual-v3102487.3%95msAPI 计费
Jina embeddings-v3102488.1%80msAPI 计费

测试集:从真实客服对话中抽取的 2000 条 QA 对,Recall@5 表示 Top-5 检索结果中包含正确答案的比例

最终选了 BGE-M3,原因:

  1. 中文电商场景效果最好(BAAI 本身就在中文语料上做了大量优化)
  2. 支持自部署,没有数据外传的合规风险
  3. 推理速度最快,单条 45ms
// rag/embedder.ts
import { pipeline } from '@xenova/transformers';

class Embedder {
  private model: any;

  async init(): Promise<void> {
    // 使用 ONNX Runtime 加载量化后的 BGE-M3
    this.model = await pipeline('feature-extraction', 'BAAI/bge-m3', {
      quantized: true, // INT8 量化,内存占用降低 70%
    });
  }

  async embed(text: string): Promise<number[]> {
    // BGE-M3 建议的 query 前缀
    const input = `为这个句子生成表示以用于检索相关段落:${text}`;
    const output = await this.model(input, {
      pooling: 'cls',
      normalize: true
    });
    return Array.from(output.data);
  }

  async embedBatch(texts: string[]): Promise<number[][]> {
    // 批量处理,GPU 利用率更高
    const inputs = texts.map(t => `为这个句子生成表示以用于检索相关段落:${t}`);
    const outputs = await this.model(inputs, {
      pooling: 'cls',
      normalize: true,
      batch_size: 32
    });

    return outputs.map((o: any) => Array.from(o.data));
  }
}

三、向量检索与重排序

向量检索(Milvus)

// rag/retriever.ts
import { MilvusClient } from '@zilliz/milvus2-sdk-node';

class VectorRetriever {
  private client: MilvusClient;
  private collection = 'product_knowledge';

  async search(
    queryEmbedding: number[],
    filters?: SearchFilters
  ): Promise<SearchResult[]> {
    // 构建过滤条件
    const filterExpr = this.buildFilter(filters);

    const results = await this.client.search({
      collection_name: this.collection,
      vector: queryEmbedding,
      limit: 20, // 先粗检索 20 条,再 rerank 取 Top-5
      filter: filterExpr,
      output_fields: ['content', 'metadata', 'chunk_id'],
      params: {
        nprobe: 16,  // IVF_FLAT 搜索精度参数
        ef: 64       // HNSW 搜索精度参数
      }
    });

    return results.results.map(r => ({
      content: r.content,
      metadata: JSON.parse(r.metadata),
      score: r.score,
      chunkId: r.chunk_id,
    }));
  }

  private buildFilter(filters?: SearchFilters): string {
    const conditions: string[] = [];

    // 如果用户已经在某个商品的对话中,限定检索范围
    if (filters?.productId) {
      conditions.push(`metadata["productId"] == "${filters.productId}"`);
    }

    // 只检索最近更新的知识(避免过期信息)
    if (filters?.maxAge) {
      const cutoff = new Date(Date.now() - filters.maxAge).toISOString();
      conditions.push(`metadata["updateTime"] >= "${cutoff}"`);
    }

    return conditions.length > 0 ? conditions.join(' && ') : '';
  }
}

重排序(Cross-Encoder)

向量检索的 Top-20 结果,通过 Cross-Encoder 重排序取 Top-5。为什么不直接用向量检索的 Top-5?

因为向量检索是 Bi-Encoder,query 和 document 独立编码,无法捕捉交叉注意力。Cross-Encoder 把 query 和 document 拼接后一起输入模型,精度显著更高,但速度慢(只适合对少量候选重排序)。

// rag/reranker.ts
class CrossEncoderReranker {
  private model: any;

  async rerank(
    query: string,
    candidates: SearchResult[],
    topK: number = 5
  ): Promise<SearchResult[]> {
    // 构造 [query, document] 对
    const pairs = candidates.map(c => [query, c.content]);

    const scores = await this.model(pairs, {
      pooling: 'none',
      // bge-reranker-v2-m3 输出的是相关性分数
    });

    // 按重排序分数排序
    const ranked = candidates
      .map((c, i) => ({ ...c, rerankScore: scores[i] }))
      .sort((a, b) => b.rerankScore - a.rerankScore);

    return ranked.slice(0, topK);
  }
}

四、Prompt 工程

这块直接贴我们生产环境用的 System Prompt 框架(脱敏版):

// rag/prompt-builder.ts
class PromptBuilder {
  buildSystemPrompt(context: ConversationContext): string {
    return `你是${context.storeName}的专业客服助手。

## 你的职责
- 基于下方【参考信息】准确回答客户关于商品、物流、售后的问题
- 如果参考信息中没有相关内容,诚实说"这个问题我需要确认一下",并自动转接人工客服
- 绝对不要编造商品参数、价格、促销信息

## 回答规范
- 语气亲切专业,像一个资深导购而非机器人
- 回答控制在 3 句话以内,除非客户要求详细说明
- 涉及价格时必须标注"以实际下单价格为准"
- 推荐商品时最多推荐 2 个,避免选择困难

## 参考信息
${context.retrievedChunks.map(c => c.content).join('\n---\n')}

## 当前对话上下文
- 客户正在浏览的商品:${context.currentProduct || '未知'}
- 客户历史订单数:${context.orderCount}
- 客户等级:${context.customerLevel}`;
  }

  buildMessages(
    history: Message[],
    currentQuery: string
  ): ChatMessage[] {
    // 只保留最近 6 轮对话,避免 token 超限
    const recentHistory = history.slice(-12); // 6 轮 = 12 条消息

    return [
      ...recentHistory.map(m => ({
        role: m.role as 'user' | 'assistant',
        content: m.content,
      })),
      { role: 'user', content: currentQuery },
    ];
  }
}

Prompt 优化的几个关键点

  1. 明确的拒答边界:告诉模型"不知道就说不知道",比什么防幻觉技巧都管用
  2. 回答长度约束:"3 句话以内"——电商客服场景下,用户要的是快速答案,不是小作文
  3. 动态上下文注入currentProductcustomerLevel 这些实时信息让回答更个性化

五、多轮对话上下文管理

电商客服的多轮对话有一个特殊挑战:指代消解

用户说"那个沙发多少钱?"——"那个"指什么?可能是上一轮提到的,也可能是页面上正在看的。

我们的处理方式是两层上下文

// rag/context-manager.ts
class ConversationContextManager {
  // 短期记忆:最近 6 轮对话
  private shortTermMemory: Map<string, Message[]> = new Map();

  // 会话状态:当前关注的商品、已确认的偏好
  private sessionState: Map<string, SessionState> = new Map();

  async resolveQuery(
    sessionId: string,
    rawQuery: string
  ): Promise<ResolvedQuery> {
    const history = this.shortTermMemory.get(sessionId) || [];
    const state = this.sessionState.get(sessionId) || {};

    // 用 LLM 做指代消解
    const resolved = await this.resolveReference(rawQuery, history, state);

    // 更新会话状态
    if (resolved.detectedProduct) {
      state.currentProduct = resolved.detectedProduct;
    }
    if (resolved.detectedIntent) {
      state.currentIntent = resolved.detectedIntent;
    }
    this.sessionState.set(sessionId, state);

    return resolved;
  }

  private async resolveReference(
    query: string,
    history: Message[],
    state: SessionState
  ): Promise<ResolvedQuery> {
    // 简单规则 + LLM 兜底
    // 规则:如果包含"这个""那个""它"等指示代词,替换为上文实体
    const pronounPattern = /这个|那个|它|这款|那款/;

    if (pronounPattern.test(query) && state.currentProduct) {
      // 快速路径:用规则替换
      const resolved = query.replace(
        pronounPattern,
        state.currentProduct.name
      );
      return {
        originalQuery: query,
        resolvedQuery: resolved,
        detectedProduct: state.currentProduct.id,
        detectedIntent: this.classifyIntent(resolved)
      };
    }

    // 复杂情况:用 LLM 理解
    // ... 省略 LLM 调用代码
    return { originalQuery: query, resolvedQuery: query,
             detectedIntent: this.classifyIntent(query) };
  }
}

六、生产环境性能数据

系统上线 3 个月后的性能指标:

指标数值说明
知识库规模200 SKU / 3,200 chunks含商品、FAQ、政策
平均检索延迟 (P50)23msMilvus IVF_FLAT,1M 向量
平均检索延迟 (P99)67ms包含网络开销
重排序延迟85msCross-Encoder,20 候选
LLM 生成延迟 (P50)1.2sDeepSeek-V3,流式输出
端到端延迟 (P50)1.5s从收到消息到首 token
检索准确率 (Recall@5)93.4%生产环境评估
回答准确率94.7%人工抽检 500 条
幻觉率2.1%编造不存在的商品信息
日均对话量3,000+单实例承载

数据来源:CallFay 母语 AI 客服系统生产环境监控,统计周期 2026 年 1-3 月

性能优化关键措施

  1. Embedding 缓存:高频 query(如"包邮吗""多久到")缓存 Embedding 结果,命中率约 35%
  2. 异步预检索:用户开始打字时(通过 WebSocket 输入状态),提前检索当前商品的基础信息
  3. 流式输出:LLM 生成用 SSE 流式推送,体感延迟从 3s 降到 1.5s
  4. 冷热分离:近 30 天的知识在"热"集合(内存索引),历史数据在"冷"集合(磁盘索引)

总结

电商 RAG 客服系统的核心挑战不在于模型能力,而在于工程化落地的细节

  1. 分块策略决定检索天花板——结构化数据和长文本需要不同的分块方式
  2. Rerank 是性价比最高的精度提升手段——粗检索 + 重排序的组合,比调大 top_k 靠谱
  3. Prompt 里的"拒答指令"是防幻觉的最后一道防线——技术手段再好,不如一句"不知道就别编"
  4. 多轮对话的指代消解是电商场景的独特难题——规则 + LLM 混合方案,兼顾速度和准确率

以上是我们在 CallFay 母语 AI 客服系统中的实践总结。如果你也在做类似的 RAG 系统,欢迎评论区交流。


了解更多:callfay.ai