标签:RAG LLM Embedding 向量数据库 Node.js AI 客服
为什么电商客服需要 RAG
直接用大模型做客服会遇到两个致命问题:
- 幻觉(Hallucination):大模型不知道你家的商品信息,会编造尺寸、价格、材质等关键参数
- 时效性:促销活动、库存状态、物流信息实时变化,模型训练数据跟不上
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-large | 3072 | 89.2% | 120ms | API 计费 |
| BGE-M3 (BAAI) | 1024 | 91.7% | 45ms | 自部署 A10 |
| Cohere embed-multilingual-v3 | 1024 | 87.3% | 95ms | API 计费 |
| Jina embeddings-v3 | 1024 | 88.1% | 80ms | API 计费 |
测试集:从真实客服对话中抽取的 2000 条 QA 对,Recall@5 表示 Top-5 检索结果中包含正确答案的比例
最终选了 BGE-M3,原因:
- 中文电商场景效果最好(BAAI 本身就在中文语料上做了大量优化)
- 支持自部署,没有数据外传的合规风险
- 推理速度最快,单条 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 优化的几个关键点
- 明确的拒答边界:告诉模型"不知道就说不知道",比什么防幻觉技巧都管用
- 回答长度约束:"3 句话以内"——电商客服场景下,用户要的是快速答案,不是小作文
- 动态上下文注入:
currentProduct、customerLevel这些实时信息让回答更个性化
五、多轮对话上下文管理
电商客服的多轮对话有一个特殊挑战:指代消解。
用户说"那个沙发多少钱?"——"那个"指什么?可能是上一轮提到的,也可能是页面上正在看的。
我们的处理方式是两层上下文:
// 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) | 23ms | Milvus IVF_FLAT,1M 向量 |
| 平均检索延迟 (P99) | 67ms | 包含网络开销 |
| 重排序延迟 | 85ms | Cross-Encoder,20 候选 |
| LLM 生成延迟 (P50) | 1.2s | DeepSeek-V3,流式输出 |
| 端到端延迟 (P50) | 1.5s | 从收到消息到首 token |
| 检索准确率 (Recall@5) | 93.4% | 生产环境评估 |
| 回答准确率 | 94.7% | 人工抽检 500 条 |
| 幻觉率 | 2.1% | 编造不存在的商品信息 |
| 日均对话量 | 3,000+ | 单实例承载 |
数据来源:CallFay 母语 AI 客服系统生产环境监控,统计周期 2026 年 1-3 月
性能优化关键措施
- Embedding 缓存:高频 query(如"包邮吗""多久到")缓存 Embedding 结果,命中率约 35%
- 异步预检索:用户开始打字时(通过 WebSocket 输入状态),提前检索当前商品的基础信息
- 流式输出:LLM 生成用 SSE 流式推送,体感延迟从 3s 降到 1.5s
- 冷热分离:近 30 天的知识在"热"集合(内存索引),历史数据在"冷"集合(磁盘索引)
总结
电商 RAG 客服系统的核心挑战不在于模型能力,而在于工程化落地的细节:
- 分块策略决定检索天花板——结构化数据和长文本需要不同的分块方式
- Rerank 是性价比最高的精度提升手段——粗检索 + 重排序的组合,比调大 top_k 靠谱
- Prompt 里的"拒答指令"是防幻觉的最后一道防线——技术手段再好,不如一句"不知道就别编"
- 多轮对话的指代消解是电商场景的独特难题——规则 + LLM 混合方案,兼顾速度和准确率
以上是我们在 CallFay 母语 AI 客服系统中的实践总结。如果你也在做类似的 RAG 系统,欢迎评论区交流。
了解更多:callfay.ai