用RAG做了个智能客服,上线第一天就被用户骂了------我的7天实战复盘
开场:需求来得比外卖还快
周一早上九点,我刚坐下还没来得及打开工位的加湿器,产品经理老王就搬着笔记本坐到了我旁边。
"badhope,老板要个智能客服系统,两周内上线,目标是替代掉现在3个客服的人力成本。"
我差点把咖啡喷到键盘上。3个客服,每天处理大概800条咨询,涵盖退换货、物流、支付、会员等十几个业务线。现在两周之内要用AI把这事儿干了?
"不就是RAG嘛,检索+生成,我已经看过不少demo了。"我当时心里是这么想的。LangChain拉一把,向量库存一下文档,调一下GPT的接口,一个下午就能跑起来。
事实证明,我太天真了。Demo和生产之间,隔着的不是一道墙,是一片海。
第一版我花了两天就搭完了------FAISS存了200条FAQ文档,ada-002做embedding,GPT-4做生成。自己测了几条觉得还行,兴冲冲部署上去给产品体验。
结果老王问了句"我买的耳机能不能退货",系统返回了一段关于"耳机保修政策"的内容,完全答非所问。他又问"怎么申请退款",系统回了句"您可以联系客服处理"。一个智能客服让用户去找人工客服,这就很尴尬了。
那天下午,我开始了真正的RAG实战。下面是我这一周踩过的所有坑,和每个坑对应的解决方案。
文档预处理:分块策略踩坑
第一个大坑就在文档分块上。
我们的知识库有产品手册、FAQ、退换货政策、物流说明等,格式杂得很------有Word、有PDF、还有飞书文档导出的Markdown。我把所有文档拼成一堆纯文本,然后用RecursiveCharacterTextSplitter按512 token固定切,chunk overlap设了50。
看起来没什么问题对吧?但用户问"你们的退换货政策是什么",系统返回了这样一段话:
...商品签收后7日内,如商品完好无损且包装完整,可申请无理由退换。以下商品不支持退换:定制类商品、贴身衣物、鲜活易腐...
注意到了吗?返回的是半截回答。"以下商品不支持退换"后面直接断了,用户看到这个只会更困惑。
问题就出在固定长度分块上------它不管语义边界,硬生生在一个列表中间把文本切断了。政策文档里"以下商品不支持退换"后面跟着6条具体商品,结果前3条在这个chunk,后3条在下一个chunk,向量检索只召回了前一个。
我开始研究不同的分块策略,对比了三种主流方案:
| 分块策略 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 固定长度分块 | 按token数硬切 | 实现简单,速度快 | 可能切断语义,丢失上下文 | 纯文本预览、快速原型 |
| 语义分块 | 按句子/段落语义相似度切分 | 保持语义完整 | 计算开销大,chunk长度不可控 | 长文档、学术论文 |
| 递归分块 | 按分隔符优先级递归切分 | 兼顾语义和长度控制 | 需要调分隔符和参数 | 企业知识库(推荐) |
最后我用了RecursiveCharacterTextSplitter,但自定义了分隔符优先级------先按markdown标题切,再按双换行(段落),再按句号,最后才是字符数。同时把chunk_size从512调到了800,overlap设为200,确保列表和表格不会被切断。
python
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 自定义分隔符:按语义层级递归切分
separators = [
"\n## ", # markdown二级标题
"\n### ", # markdown三级标题
"\n\n", # 段落
"\n", # 单行换行
"。", # 中文句号
"!", # 感叹号
"?", # 问号
";", # 分号
" ", # 空格
"" # 兜底:逐字符
]
splitter = RecursiveCharacterTextSplitter(
separators=separators,
chunk_size=800,
chunk_overlap=200,
length_function=lambda x: len(x) # 按字符数而非token
)
# 分块并附带元数据
chunks = splitter.split_documents(documents)
print(f"原始文档数: {len(documents)}")
print(f"分块后chunk数: {len(chunks)}")
print(f"平均chunk长度: {sum(len(c.page_content) for c in chunks) / len(chunks):.0f} 字符")
改完之后,"退换货政策"这个问题终于返回了完整的政策说明。但新的问题来了------文档量一大,FAISS开始撑不住了。
向量数据库选型:别选花眼
一开始我用FAISS在本地跑,200条FAQ没问题,响应很快。但当我把完整知识库(大概5000条文档chunk)灌进去之后,内存直接飙到8G,然后OOM了。
而且FAISS有个致命问题------它是纯内存的,每次重启都要重新加载全部向量。我的服务每次启动要等40秒加载索引,这在生产环境根本没法接受。
我又去调研了一圈主流的向量数据库:
| 向量数据库 | 是否需运维 | 支持文档量 | 查询延迟 | 成本 | 适用阶段 |
|---|---|---|---|---|---|
| FAISS | 否(纯内存) | <100万 | <10ms | 免费 | 原型开发 |
| Chroma | 低(嵌入式) | <100万 | 5-20ms | 免费 | 开发/测试 |
| Pinecone | 否(SaaS) | 10亿+ | 10-50ms | 按量付费 | 生产(不想运维) |
| Milvus | 高(需部署) | 10亿+ | 5-30ms | 服务器成本 | 生产(大规模) |
说实话,如果团队没有专职运维,Pinecone是最省心的选择------完全托管,按用量付费。但我们的数据涉及用户隐私,不能上云,所以最终选了Chroma做开发环境,Milvus做生产环境。
Chroma的好处是嵌入式运行,不需要单独部署服务,Python直接import就能用,开发调试非常方便。Milvus虽然部署麻烦点,但支持持久化存储、分布式扩展,生产环境扛得住。
python
# === 开发环境:Chroma ===
import chromadb
from chromadb.config import Settings
chroma_client = chromadb.PersistentClient(path="./vector_db_dev")
collection = chroma_client.get_or_create_collection(
name="customer_service_kb",
metadata={"hnsw:space": "cosine"} # 使用余弦相似度
)
# 批量写入向量
collection.add(
ids=[f"chunk_{i}" for i in range(len(chunks))],
documents=[c.page_content for c in chunks],
embeddings=embeddings, # 预计算好的向量
metadatas=[c.metadata for c in chunks]
)
# === 生产环境:Milvus ===
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType
connections.connect(
alias="default",
host="milvus.internal",
port="19530"
)
# 定义Collection Schema
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1024),
FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=4096),
FieldSchema(name="source", dtype=DataType.VARCHAR, max_length=256),
]
schema = CollectionSchema(fields, description="客服知识库")
collection = Collection("customer_service_kb", schema)
# 创建IVF索引,平衡速度和召回率
collection.create_index(
field_name="embedding",
index_params={
"index_type": "IVF_FLAT",
"metric_type": "COSINE",
"params": {"nlist": 1024}
}
)
向量库解决了,但召回率还是上不去。问题出在了Embedding模型上。
Embedding模型:中文场景的大坑
这是整个项目里我踩得最狠的一个坑。
一开始我用的是OpenAI的text-embedding-ada-002,英文场景下效果确实不错。但我们的知识库全是中文,用户query也是中文。ada-002对中文的理解能力很弱------它本质上是把中文token化之后做embedding,对中文语义的捕捉远不如专门的中文模型。
具体表现就是:用户问"怎么退款",系统匹配到的是"退款进度查询",而不是"退款申请流程"。这两个在语义上很近,但ada-002算出来的余弦相似度差了一截。
我测了三个主流的中文Embedding模型:
| 模型 | 维度 | 中文效果 | 部署方式 | 召回率(我们的数据集) |
|---|---|---|---|---|
| text-embedding-ada-002 | 1536 | 一般 | OpenAI API | 62% |
| text-embedding-3-small | 1536 | 较好 | OpenAI API | 71% |
| m3e-base | 768 | 好 | 本地部署 | 78% |
| bge-large-zh-v1.5 | 1024 | 很好 | 本地部署 | 89% |
换上bge-large-zh-v1.5之后,召回率直接从62%飙到了89%。"怎么退款"终于能正确匹配到"退款申请流程"了。bge模型是智源研究院开源的中文embedding模型,在C-MTEB榜单上表现一直很能打。
python
from FlagEmbedding import FlagModel
# 本地部署bge-large-zh
# 模型大小约1.3G,首次加载需要下载
model = FlagModel(
'BAAI/bge-large-zh-v1.5',
query_instruction_for_retrieval="为这个句子生成表示用于检索相关文章:",
use_fp16=True # 半精度推理,省显存
)
# 生成query向量
query_embedding = model.encode_queries(["怎么申请退款"])
# 生成文档向量(批量)
doc_embeddings = model.encode_corpus([chunk.page_content for chunk in chunks])
# 写入向量库
collection.add(
embeddings=doc_embeddings.tolist(),
documents=[chunk.page_content for chunk in chunks],
ids=[f"chunk_{i}" for i in range(len(chunks))]
)
经验教训:中文场景千万别用英文通用embedding模型。bge-large-zh和m3e-base都是专门针对中文优化的,效果差距不是一点半点。如果在意延迟,m3e-base更轻量(768维);如果在意效果,bge-large-zh是首选。
检索质量优化:召回率从62%到95%
换了好embedding模型,召回率到了89%。但还差一口气------有些query就是匹配不准。
最典型的场景:用户问"怎么退款",知识库里相关文档的标题是"退货流程"。向量检索能算出"退款"和"退货"在语义上接近,但有时候top-1返回的却是一条完全不相关的"账户安全"文档。原因很简单------纯向量检索对关键词的精确匹配能力很弱。
用户问"怎么退款"时,他其实是想知道退款的具体步骤。但向量检索可能觉得"退款"和"提现"的语义距离更近(都涉及"把钱拿出来"),反而把"提现规则"排在了前面。
解决方案是混合检索------BM25做关键词检索 + 向量检索做语义检索,两路结果做融合排序。BM25擅长精确关键词匹配,"退款"这个关键词会直接命中包含"退款"的文档;向量检索擅长语义理解,"怎么退款"和"退款申请流程"的语义距离很近。两者互补。
然后再加一层重排序------用cross-encoder模型对召回的top-20候选做精排。向量检索用的是bi-encoder(query和doc分别编码再算相似度),速度快但精度有限;cross-encoder把query和doc拼在一起做编码,精度高但慢,所以只对top-20做重排。
python
from rank_bm25 import BM25Okapi
from FlagEmbedding import FlagReranker
import numpy as np
class HybridRetriever:
def __init__(self, documents, embedding_model):
# 初始化BM25
self.bm25 = BM25Okapi([list(doc.page_content) for doc in documents])
self.documents = documents
self.embedding_model = embedding_model
# 初始化重排序模型
self.reranker = FlagReranker('BAAI/bge-reranker-large', use_fp16=True)
def search(self, query, top_k=5):
# --- 第一路:BM25关键词检索 ---
bm25_scores = self.bm25.get_scores(list(query))
bm25_top_indices = np.argsort(bm25_scores)[-20:] # 取top-20
# --- 第二路:向量语义检索 ---
query_vec = self.embedding_model.encode_queries([query])
vec_scores = self._compute_similarity(query_vec, top_k=20)
vec_top_indices = np.argsort(vec_scores)[-20:]
# --- 融合:RRF(Reciprocal Rank Fusion)---
candidate_indices = set(bm25_top_indices) | set(vec_top_indices)
rrf_scores = {}
for idx in candidate_indices:
bm25_rank = np.where(bm25_top_indices == idx)[0]
vec_rank = np.where(vec_top_indices == idx)[0]
# RRF公式:1/(60+rank)
rrf_scores[idx] = (
1 / (60 + len(bm25_top_indices) - bm25_rank[0]) if len(bm25_rank) > 0 else 0
) + (
1 / (60 + len(vec_top_indices) - vec_rank[0]) if len(vec_rank) > 0 else 0
)
# 取RRF top-20进入重排序
rrf_top = sorted(rrf_scores, key=rrf_scores.get, reverse=True)[:20]
# --- 第三层:Cross-Encoder重排序 ---
pairs = [[query, self.documents[i].page_content] for i in rrf_top]
rerank_scores = self.reranker.compute_score(pairs, normalize=True)
# 最终排序
final_ranking = sorted(zip(rrf_top, rerank_scores), key=lambda x: x[1], reverse=True)
return [self.documents[idx] for idx, _ in final_ranking[:top_k]]
def _compute_similarity(self, query_vec, top_k=20):
# 简化:实际从向量库查询
all_vecs = np.array(self.doc_embeddings)
return np.dot(all_vecs, query_vec.T).flatten()
混合检索 + 重排序之后,我在测试集上跑了一遍,召回率从89%提到了95%。尤其是那种"用户用词和文档用词不一样"的case,比如用户说"东西坏了想换",文档写的是"商品质量问题退换货",现在都能正确召回了。
检索优化的核心思路:向量检索管"语义相近",BM25管"关键词命中",RRF做融合,cross-encoder做精排。四层下来,召回率能稳定在90%以上。别指望单一检索方案解决所有问题。
Prompt工程:让AI说话像客服
检索质量上去了,但用户的吐槽并没有停。主要有两个问题:
第一,回答太啰嗦。用户问"退货流程是什么",GPT-4洋洋洒洒写了三段话,从退换货政策背景讲到注意事项再到操作步骤。用户只想知道"点哪个按钮、填什么信息"。
第二,偶尔出现幻觉。知识库里没有"会员等级折扣"的文档,但GPT-4有时候会自己编一个出来------"根据您的会员等级,可享受9折优惠"。用户照着操作发现根本没有这个功能,投诉就来了。
这两个问题的根源都在Prompt上。我开始设计专门的客服Prompt模板,核心思路是:角色设定 + 检索结果注入 + 硬约束条件。
python
CUSTOMER_SERVICE_PROMPT = """你是一名专业的电商客服助手,请严格按照以下规则回答用户问题。
## 角色设定
- 你是XX商城的智能客服,负责处理售前咨询、售后问题、物流查询等。
- 语气亲切专业,像一位经验丰富的客服小姐姐。
- 回答简洁直接,优先给出操作步骤,不要铺垫太多。
## 检索到的知识库内容
以下是从知识库中检索到的相关信息,请基于这些内容回答用户问题:
---
{context}
---
## 硬性约束(必须遵守)
1. 只能基于上方检索到的内容回答,绝不能编造知识库中不存在的信息。
2. 如果检索到的内容无法回答用户问题,直接说"这个问题我需要转给人工客服处理",不要猜测。
3. 涉及退款、赔偿等敏感操作时,必须提醒用户确认后再操作。
4. 回答控制在150字以内,用编号列表给出操作步骤。
## 用户问题
{question}
## 回答"""
# 构建完整的Prompt
from langchain.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_template(CUSTOMER_SERVICE_PROMPT)
# 调用LLM生成回答
chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
response = chain.invoke("怎么申请退款?")
这个Prompt模板里有几个关键设计。首先,context部分注入了检索到的文档内容,明确告诉模型"只能基于这些内容回答"。其次,硬性约束第1条和第2条直接堵死了幻觉的口子------检索不到就说转人工,不许编。最后,回答长度限制在150字以内,逼模型给干货不给废话。
防幻觉三条铁律:①检索结果为空时,必须回答"无法处理"而非自由发挥;②所有事实性信息必须能在context中找到原文出处;③涉及金钱、权限的操作类回答,必须加确认提示。这三条写进Prompt的硬约束里,幻觉率能降到5%以下。
改完Prompt之后,回答风格明显正常了。用户问"怎么退款",系统回复变成:"您可以通过以下步骤申请退款:1. 进入「我的订单」页面;2. 找到需要退款的订单,点击「申请退款」;3. 选择退款原因并提交。退款将在3-5个工作日内原路返回。"简洁、准确、有操作步骤。
上线实战:从Demo到生产
Demo能跑了,但离生产还差十万八千里。最大的问题是稳定性和成本。
首先是延迟。用户问一个问题,完整的链路是:query embedding → 混合检索 → 重排序 → LLM生成。这整个链路跑下来,平均要4-6秒。用户等不了这么久,超过3秒就会觉得"卡了"。
其次是LLM的可用性。我用的GPT-4,有一天OpenAI接口抽风,响应时间飙到30秒,大量请求超时。智能客服直接瘫痪了,用户全部涌向人工客服------我们本来就是来减轻人工客服压力的,结果反而给他们添了乱。
最后是成本。GPT-4的API不便宜,每天800条query跑下来,光LLM调用费就不少。而且其中很多问题是重复的------"怎么退款""退货流程""物流查询"这几个问题占了总query的40%以上。
针对这三个问题,我做了以下架构改造:
python
import hashlib
import time
from functools import lru_cache
class CustomerServiceRAG:
def __init__(self):
self.retriever = HybridRetriever(...)
self.llm_primary = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
self.llm_fallback = RuleEngine() # 规则引擎兜底
self.cache = RedisCache(ttl=3600) # 1小时缓存
self.call_count = 0
self.fallback_count = 0
def answer(self, query: str) -> dict:
start_time = time.time()
# 第一层:缓存命中检查
cache_key = hashlib.md5(query.encode()).hexdigest()
cached = self.cache.get(cache_key)
if cached:
return {
"answer": cached,
"source": "cache",
"latency_ms": int((time.time() - start_time) * 1000)
}
# 第二层:检索
docs = self.retriever.search(query, top_k=3)
# 检索结果为空 → 直接转人工
if not docs or docs[0].score < 0.5:
return {
"answer": "这个问题我需要转给人工客服处理,请稍等。",
"source": "fallback_empty",
"latency_ms": int((time.time() - start_time) * 1000)
}
# 第三层:LLM生成(带超时和降级)
try:
context = "\n".join([d.page_content for d in docs])
response = self.llm_primary.invoke(
CUSTOMER_SERVICE_PROMPT.format(context=context, question=query),
timeout=5 # 5秒超时
)
answer = response.content
self.cache.set(cache_key, answer) # 写入缓存
self.call_count += 1
return {
"answer": answer,
"source": "llm",
"latency_ms": int((time.time() - start_time) * 1000)
}
except (TimeoutError, Exception) as e:
# LLM挂了 → 规则引擎兜底
self.fallback_count += 1
answer = self.llm_fallback.match(query, docs)
return {
"answer": answer,
"source": "fallback_rule",
"latency_ms": int((time.time() - start_time) * 1000)
}
这套架构里有三层保障。缓存层用Redis存历史query的答案,命中缓存直接返回,延迟降到50ms以内。LLM调用加了5秒超时,超时后自动降级到规则引擎------规则引擎就是一套基于关键词匹配的if-else逻辑,虽然不如LLM灵活,但至少不会挂。检索结果置信度太低时(score < 0.5),直接转人工,不强行回答。
上线前后的关键指标对比如下:
| 指标 | Demo版本 | 生产版本 | 提升幅度 |
|---|---|---|---|
| 平均响应延迟 | 4.8s | 1.2s | 75%↓ |
| P99延迟 | 12s | 3.5s | 71%↓ |
| 缓存命中率 | 0% | 38% | --- |
| LLM调用次数/天 | 800 | 496 | 38%↓ |
| 可用性(SLA) | 92% | 99.5% | 7.5%↑ |
| 幻觉率 | 15% | 4% | 73%↓ |
加了缓存之后,38%的重复query直接命中缓存返回,LLM调用量下降了三分之一,成本也跟着降了。延迟从4.8秒降到1.2秒,用户体感好了很多。
成本与性能:老板最关心的
系统上线后第一周,老板问了我一个问题:"每天跑这个AI客服要花多少钱?"
这个问题不回答清楚,这个项目就保不住。我算了一笔账。
每个query的完整链路消耗的token大约是:检索到的context(约1500 token)+ Prompt模板(约300 token)+ 用户query(约20 token)+ LLM输出(约150 token)= 总计约2000 token/query。每天800条query,大约消耗160万token。
用GPT-4的话,按input 30/M、output60/M算,每天成本大概 50,一个月1500。用GPT-3.5的话,成本只有GPT-4的十分之一。但GPT-3.5在复杂问题上的理解能力确实差一截。
最后的方案是分级路由------简单问题用GPT-3.5,复杂问题才升级到GPT-4:
| LLM模型 | 单次成本 | 回答质量 | 延迟 | 适用场景 |
|---|---|---|---|---|
| GPT-4 Turbo | ~$0.06 | 优秀 | 3-5s | 复杂售后、纠纷处理 |
| GPT-3.5 Turbo | ~$0.006 | 良好 | 1-2s | 常见问题、操作指引 |
| 规则引擎 | $0 | 一般 | <100ms | LLM不可用时的兜底 |
| 缓存 | $0 | --- | <50ms | 重复query |
python
class LLMRouter:
"""分级路由:根据问题复杂度选择LLM"""
# 复杂问题关键词(升级到GPT-4)
COMPLEX_KEYWORDS = [
"投诉", "纠纷", "差评", "赔偿", "法律",
"异常", "错误", "被骗", "退款失败", "多次"
]
def __init__(self):
self.llm_fast = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
self.llm_smart = ChatOpenAI(model="gpt-4-turbo", temperature=0)
def route(self, query: str, retrieved_docs: list) -> object:
# 复杂度判断:关键词匹配 + 检索结果数量
is_complex = any(kw in query for kw in self.COMPLEX_KEYWORDS)
is_complex = is_complex or len(retrieved_docs) > 3 # 多个相关文档说明问题复杂
if is_complex:
return self.llm_smart # GPT-4处理
else:
return self.llm_fast # GPT-3.5处理
# 使用
router = LLMRouter()
llm = router.route(query, docs)
response = llm.invoke(prompt.format(context=context, question=query))
分级路由上线后,大约75%的query走GPT-3.5,20%走GPT-4,5%走规则引擎兜底 。月成本从预估的 1500降到了∗∗420左右**,效果几乎没有下降------因为大多数客服问题本来就是重复的标准化问题,GPT-3.5完全能处理。
结尾复盘:RAG做客服,够用但别神化
上线一周后,我拉了一份数据。
| 指标 | 数据 |
|---|---|
| 日均处理query | 812条 |
| AI自动解决率 | 72% |
| 转人工率 | 28% |
| 用户满意度评分 | 4.1/5.0 |
| 人工客服工作量减少 | 约60% |
72%的自动解决率,虽然没达到产品经理最初设想的"替代全部人工",但确实把人工客服的工作量砍掉了60%。剩下28%转人工的,大多是涉及金额纠纷、个性化投诉这种需要人工判断的case------这些本来就不该指望AI处理。
回头看这一周的踩坑历程,RAG客服系统的技术优先级其实很明确:
分块策略 > Embedding模型 > 检索方法 > Prompt工程。前两个决定了你的知识库质量上限,后两个决定了你能逼近这个上限到什么程度。很多人一上来就调Prompt、换LLM,但如果不把分块和Embedding搞好,上层怎么调都是白搭。
还有一点想说------RAG不是万能的。它能处理的是"知识库里有的、可检索的、标准化的问题"。对于需要推理、需要多轮交互、需要理解用户情绪的复杂场景,光靠RAG是不够的。但做客服?足够了。至少足够帮你的客服团队挡掉70%的重复劳动,让他们把精力留给真正需要人工判断的问题。
最后给准备做RAG客服的同学一个建议:别急着写代码,先把你的知识库整理好。文档质量决定了RAG的天花板,技术方案只是在逼近这个天花板。文档乱七八糟,再牛的检索算法也救不回来。
这一周踩的坑比过去半年都多,但系统跑起来的那一刻,看着日志里一条条被自动解决的query,还是有点小爽的。毕竟,用技术解决真实问题,不就是我们写代码的初衷嘛。