做客服问答机器人这行,我前后经手过好几个项目。早期用关键词匹配,客户换个说法就答不上来;后来图省事直接让大模型硬答,又怕它一本正经地编政策。直到把检索增强生成这套思路用上,才觉得终于平衡了可控性和智能感。要说最容易讲清楚的场景,迪士尼客服很有代表性:游客天天问开园时间、门票改签、酒店入住、烟花演出、物品遗失怎么办,问题又杂又具体,答案还动不动就变。这篇就围绕这个场景,带你从零搭一套最小的RAG客服系统,不需要你懂什么高深算法,只要有一点点Python基础就能照做。适合正在做知识库问答、客服助手,或者单纯想入门RAG的人参考。
1. 先把RAG客服系统的需求拆明白
1.1 为什么客服场景离不开RAG
直接让大模型当客服,最大的问题不是“笨”,而是“不知道”。因为大模型的训练语料里根本不可能实时包含你今天最新的门票政策、烟花表演时间、酒店退房要求。你问它“下雨天花车巡游还办吗”,它大概率会给你一段听着很合理、实际没依据的推测。这种幻觉放在闲聊里无所谓,放在客服场景里就是投诉和损失。
传统的关键词检索倒是很精确,但它只能做“查字典”。游客问“几点开门”和“营业时间是多少”,在字面上差了十万八千里,关键词系统很容易漏掉真正相关的答案。你总不能让客服人员把每个问题的每一个说法都维护一遍,那成本谁扛得住?
RAG做的事情其实很朴素:先把你的知识库切好、存好,用户提问时先做一轮检索,把最相关的几段资料捞出来,再把这些资料连同问题一起交给大模型,让大模型“看着资料回答”。这就像给新来的客服发了一本手册,他不用背下来,但回答时必须翻到对应页面照着念。这样既保留了大模型的理解能力,又把答案牢牢锁在你给的知识范围里,幻觉问题被从源头压住。
1.2 一次完整的客服问答要经过哪几步
拆开看,一次RAG问答大致是这么走的:
- 用户提问,比如“明天几点开园”。
- 系统把这句话转成一组数字向量,拿这个向量去知识库里找语义最接近的内容。
- 检索模块返回Top 3到Top 5条候选资料,比如“运营时间”“早享卡说明”等文档片段。
- 系统把这些候选资料按顺序拼进一个提示模板,再连同用户问题发给大模型。
- 大模型阅读资料后生成一句简短回答,带语气、带上下文,而不是冷冰冰甩一篇文档链接。
这里面最关键的设计是:检索结果决定了大模型能看到什么,大模型只是“照着资料做摘要”。所以RAG系统的能力上限,基本等于检索模块的能力上限。你检索出来的东西不对,后面大模型再厉害也答不对。这也是为什么我后面讲实操时会把大量篇幅放在知识库整理和检索调试上,而不是让你研究怎么调大模型。
这套系统适合谁?我觉得是这样几类人:给企业搭内部知识库问答的小团队、博客或电商打算做智能客服的个人开发者、学校实验室做信息检索方向的同学。只要你手里有一堆文档,想把它变成一个能对话的机器人,RAG就是当前性价比最高的路。
2. 动手前必须做好的选型与数据准备
2.1 一套小白友好的技术栈组合
搭建RAG系统,核心是三块东西:Embedding模型做文本向量化、向量数据库做存储检索、大模型做答案生成。对小白来说,最痛苦的不是学概念,而是面对满屏的框架和工具不知道选哪个。我给你一套我自己经常向人推荐的组合,偏轻量、好调试,数据量在几万条以内都能扛住:
- Embedding模型:bge-small-zh-v1.5,中文效果能打,模型体积小,普通CPU都能跑。
- 向量数据库:Chroma,本地模式跑起来三分钟搞定,没有独立服务端的运维负担。
- 大模型:任选你手上能调用的API即可,本地部署的开源模型也行,后面代码里我留一个伪接口。
为什么不用那些重型方案?不是它们不好,而是项目刚开始时根本用不上。Milvus功能很强,但你需要维护一套分布式服务;Qdrant也很好,但为了跑通最小流程还得先启一个服务端。Chroma就是一个小文件,你装在本地,写到哪算哪,后面数据量真的大了再迁Qdrant也不迟。
| 方案 | 启动难度 | 适合数据量 | 运维成本 | 适合场景 |
|---|---|---|---|---|
| Chroma | 极低,pip安装即用 | 万条内 | 几乎为零 | 学习Demo、小型客服 |
| Qdrant | 中等,需要跑服务端 | 十万条级 | 低 | 生产级中小系统 |
| Milvus | 高,组件较多 | 百万条级 | 高 | 大规模搜索业务 |
| pgvector | 低,依赖已有PostgreSQL | 万条级 | 低 | 团队已有PostgreSQL环境 |
选型时可以记住一个朴素原则:你先要跑通,再要跑快,最后才追求跑大。很多人一上来就搭Kubernetes集群搞Milvus,结果连知识库都没整理好,浪费了两周时间。我自己最初做类似项目时就是从Chroma开始的,后来数据量涨到几万条才迁移,迁移成本其实没有想象中高。
2.2 知识库清洗与字段设计才是成败第一关
很多人以为RAG的效果靠模型,其实更靠数据。你喂进去的是一堆互相矛盾、格式混乱的文档,再好的检索也救不回来。迪士尼客服这个场景,知识来源一般有:官网FAQ、游玩攻略、酒店入住政策、餐饮预订须知、交通指引、安全须知。这些资料的格式五花八门,有HTML页面、有PDF、有客服手记,需要统一清洗。
我建议先把结构化问答对整理成一张表,字段至少包含这些:
| 字段名 | 示例 | 说明 |
|---|---|---|
| id | 10023 | 唯一编号 |
| category | 门票政策 | 用于检索后分类展示 |
| question | 儿童票能享受优惠吗 | 用户常见问法 |
| answer | 3岁以下儿童可免票入园 | 客服标准回答 |
| source | 官网票务说明 | 答案来源,便于追溯 |
| update_time | 2024-05-01 | 记录时效性 |
清洗时有几个坑要特别注意。第一个是内容冲突,比如一份资料写着“1米以下免票”,另一份写着“3岁以下免票”,实际可能是针对不同票种的,你得合并成一条完整口径,或者加上适用条件。第二个是营销话术夹杂,有些资料里写着“快来体验奇妙之旅”这种水词,检索时不加分还占空间,直接去掉。第三个是敏感信息,客服知识库里千万别放内部员工手册、未公开政策,RAG系统一旦上线,这些内容就可能被用户用特定问法套出来,合规风险极高。我们只往知识库放那些官方公开、确定可以对外回答的内容。
数据准备阶段可以慢一点,我个人习惯是先用半天做一轮清洗,再拿清洗后的数据跑几个测试问题,感受一下哪些知识缺、哪些格式不好用。数据标准化这件事做得越早,后面调试检索效果时越省心。
3. 三步实操:知识库切块、向量检索、LLM生成
3.1 第一步:知识切块与向量化入库
我先解释一个概念:Embedding。你可以把它理解成给文本拍一张“语义照片”,相似的句子在照片空间里的距离是近的。当我们把全部知识库都转成这种“照片”并存入向量库之后,用户问题一来,同样转成“照片”,就能快速找到最接近的那些历史“照片”。
实际操作中,数据有两种常见形态,处理方式不同。
第一种是高结构的FAQ问答对。这类数据每条就是问答,不需要再做切块,直接整条作为一条知识入库。代码可以这样写:
import pandas as pd from sentence_transformers import SentenceTransformer import chromadb # 1. 加载本地模型,做中文向量化 model = SentenceTransformer("BAAI/bge-small-zh-v1.5") # 2. 读取整理好的知识库表格 df = pd.read_csv("disney_faq.csv") # 3. 初始化本地向量库 client = chromadb.PersistentClient(path="./disney_kb") collection = client.get_or_create_collection( name="faq", metadata={"hnsw:space": "cosine"} # 用余弦距离衡量语义相近度 ) # 4. 逐条向量化并入库 ids = [] embeddings = [] documents = [] metadatas = [] for i, row in df.iterrows(): text = row["question"] + "\n" + row["answer"] ids.append(str(i)) documents.append(text) metadatas.append({ "category": row["category"], "source": row["source"], "answer": row["answer"] }) embeddings.append(model.encode(text).tolist()) collection.add( ids=ids, embeddings=embeddings, documents=documents, metadatas=metadatas ) print(f"入库完成,共 {len(ids)} 条知识")这里我故意把 question 和 answer 拼在一起向量化,而不是只编码 question。原因是检索时用户问题往往和原问题的问法接近,把答案一起编码进去,可以让向量更准确。而 metadatas 里单独存 answer 和 category,是为了后面给大模型拼资料时更方便。
第二种是长文档,比如游玩攻略PDF或政策页。这类就不能整篇入库了,一条几千字塞进去,检索时没法定位到具体那一句。需要先切块,再用递归切分器处理:
from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, # 每块约500字符 chunk_overlap=80, # 相邻块重叠80字符 separators=["\n\n", "\n", "。", ",", " ", ""] ) chunks = text_splitter.split_text(long_policy_text) for idx, chunk in enumerate(chunks): collection.add( ids=[f"doc_{idx}"], embeddings=[model.encode(chunk).tolist()], documents=[chunk], metadatas=[{"source": "长文档.pdf"}] )为什么切块要设置500字符和80重叠?因为块太小语义不完整,块太大又会有大量无关信息干扰检索。重叠的作用是让被切断的句子至少在一半边是完整的,避免“悲剧性切断”。比如一句“3岁以下儿童可凭有效证件免票入园”,正好被切在“3岁以下”和“儿童可凭”之间,两块都不完整,检索效果自然差。80字符的重叠能有效缓解这个问题。
3.2 第二步:检索召回,让候选资料排好队
知识入库之后,系统已经进入可用状态。这一步写检索逻辑:把用户的提问向量化,然后去向量库找最相近的Top N条。
def retrieve(query, top_k=5): query_vec = model.encode(query).tolist() results = collection.query( query_embeddings=[query_vec], n_results=top_k ) candidates = [] for doc, meta in zip(results["documents"][0], results["metadatas"][0]): candidates.append({ "text": doc, "answer": meta.get("answer", ""), "category": meta.get("category", ""), "source": meta.get("source", "") }) return candidates # 测试一下 query = "晚上几点放烟花?下雨还放吗?" for idx, c in enumerate(retrieve(query)): print(idx, c["category"], c["text"][:80])这里Top K我建议从5开始调。K太小容易漏答案,K太大则会把不相干的资料也塞给大模型,让它无法聚焦。客服场景的问题通常比较聚焦,5到8是一个不错的区间。
检索阶段最容易犯的错是只看向量检索。向量检索在中文语义上已经很强,但遇到精确信息还是会翻车。比如游客问“2024年3月迪士尼有没有举办春季活动”,这种带时间和精确条件的问题,向量检索可能被语义干扰。我的习惯是做一个简单的混合检索,向量检索跑一遍,再用关键词或BM25跑一遍,最后把两路结果合并去重。Chroma本身不带BM25,但你可以先用简单文本匹配过滤一遍,或者用Elasticsearch做关键词召回。实践下来,混合检索能把召回率从七十多分拉到九十分以上,这个提升非常可观。
3.3 第三步:用Prompt把资料生成客服答案
检索做完,最后一步是把候选资料交给大模型。这一环节的成败,基本上由提示词模板决定。我推荐用下面这种结构:
你是一名主题乐园客服人员,请只根据提供的资料回答游客的问题。 资料如下: [1] 乐园运营时间:上午9:00至晚上9:00 [2] 花车巡游通常于下午3点开始,雨天可能调整或取消 游客问题:明天几点开园? 要求: 1. 只依据资料回答,不要加入资料之外的信息。 2. 如果资料不足以回答,直接回复“抱歉,我需要为您转人工处理”。 3. 回答尽量简洁,用口语化表达。 4. 可在句末用类似【参考1】的方式标注依据来源。在代码里,我把检索到的候选资料编号,并拼进模板:
def build_prompt(query, candidates): context = "\n".join( f"[{idx + 1}] {c['text']}" for idx, c in enumerate(candidates) ) prompt = f"""你是一名主题乐园客服人员,请只根据提供的资料回答游客的问题。 资料如下: {context} 游客问题:{query} 要求:只依据资料回答,不得编造;资料不足时请直接说不可以回答。 """ return prompt def chat(query, candidates): prompt = build_prompt(query, candidates) # 这里调用你自己的大模型接口,传入 prompt # 示例:resp = llm.chat(prompt, temperature=0) return "系统生成回答"注意一个细节:temperature这个参数要固定为0或者接近0。因为客服场景要求稳定,不需要创造性。你也不希望同一个问题今天一个答案明天一个答案。
另外一个容易被忽略的环节是拒答兜底。RAG系统不是每问必答,当检索到的内容和问题完全不沾边时,强行生成只会给你幻觉。我惯用的做法是设定相似度阈值,比如余弦相似度低于0.45就默认没有找到相关内容,直接返回“很抱歉,这个问题我需要转人工帮您确认”。这个阈值要根据实测结果来调,不同Embedding模型的分布不一样。
整个链路串起来之后,你的系统已经能完成从问题到答案的完整闭环了:
用户提问 → 向量化 → Chroma检索Top5 → 拼接Prompt → 大模型生成 → 返回答案先不用考虑多轮对话、历史记忆、权限分级这些进阶功能,把这条主线跑通跑稳,就已经比很多直接拿大模型硬答的Demo强出一大截。
3.4 把三步串成一条完整链路
项目里我会把上面的功能拆成几个独立的函数:知识库加载入库、检索、Prompt组装、LLM调用,然后在主流程里按顺序调用。分文件写的好处是后面调试时不用翻一整片代码,改检索逻辑不动Prompt,改Prompt不动入库逻辑。
另外建议写一个简单的命令行交互脚本,方便你手动输入问题看效果。这一步不用做得花哨,能逐条把输入输出打出来就行。实测下来,交互式调试比写单元测试更能快速发现问题,因为你输入的自然语言永远比你准备的测试用例要刁钻得多。
4. 上线前必看的常见问题与效果评测
4.1 回答不准时先查检索还是先查生成
系统搭起来后,你一定会遇到回答不准的情况。别急着改提示词,我给你的排查顺序非常简单:先把每次召回的内容打印出来看。
在正式进入Prompt之前加一行调试日志,把Top 5候选记录的文本和来源打出来。然后看两种现象:
- 召回结果里根本没有正确答案:问题出在检索侧。可能是知识库没入库、切块太碎、Embedding模型效果不好,或者用户问法和知识库说法差太远。
- 召回结果里明明有正确答案,但大模型答得不对:问题出在生成侧。调整要点是看看Prompt有没有把每条资料和问题放在一起,有没有要求不得编造,是否因为上下文过长让模型迷失了重点。
我看到很多新手犯的错,就是一上来改系统提示词,改半天没有效果,结果发现是检索环节把冤枉了。你先确认“它到底有没有看到正确答案”,再决定下一步调哪里。这在整个调试过程中是最值钱的一条经验。
4.2 知识库维护与内容坑
客服知识库不是搭完就一劳永逸的,需要持续运营。最典型的问题是政策变更,比如票价调整、营业时间变更、烟花时间更新。这时候旧文档不删,新文档又进来,库里同时存在两条不一致的答案,检索就可能在两者之间摇摆。我建议对每个知识条目都带update_time,定期排查过期内容,或者建立定时重建索引的任务。
另一个坑是内容颗粒度不统一。有些知识很短,比如“停车费每小时10元”;有些知识很长,比如整个游玩攻略。长短混在一起时,检索往往会偏向长文本,因为长文本包含的语义关键词更多,容易把短而精确的答案挤掉。我的做法是把长文档继续细分,尽量让每段资料控制在200字以内,保持库内颗粒度大致均匀。
合规这块再补一句:客服知识库权限边界要想清楚。普通人问“员工通道在哪里”和内部员工问“今天排班表有没有变动”,这两类问题绝不能交给同一个系统回答。如果系统面向普通游客,那知识库里就只放游客公开信息,不要贪多把内部资料也塞进去。RAG系统效果好之后,投入生产的价值会越来越明显,但同时也要控制“它能说什么”这件事。
4.3 效果评测:用测试集给系统打分
没有评测,你就不知道系统是变好了还是变坏了。建一个测试集,不需要多复杂,收集100条真实客服问题,覆盖常见问答、边界情况、拒答情况就行。我用过的评测设计大致是这样:
| 问题 | 期望知识 | 检索是否召回 | 回答是否通过 |
|---|---|---|---|
| 明天几点开园? | 运营时间 | 是 | 是 |
| 孩子超过1米2要买票吗? | 儿童票政策 | 是 | 是 |
| 园区里有婴儿推车租吗? | 租借服务 | 否 | 否 |
| 迪士尼附近有什么好吃的 | 应在知识库外 | — | 应拒答 |
然后统计三个指标:召回率,即Top 5里包含正确知识的比例;答案准确率,即人工判断生成回答正确且完整的比例;拒答率,即超出范围问题有没有被正确拒答。
我第一次搭类似系统时,只跑了十来条问题验证流程,自我感觉良好。后来把测试集扩到100条,才发现召回率只有六成多。原因是一些用户问题说法太过口语化,和知识库的原问法差太远。后来加了同义改写和混合检索才拉上来。所以评测集一定要早点建,哪怕先建20条也好,它就是你系统改进的路标。
4.4 上线运营阶段的一些补充经验
系统上线后,日志比代码更重要。每条问答的检索结果、生成的答案、用户是否点了踩,都值得记录。一周之后翻一下这些日志,你就能找出高频答错的模式,比如“你们几点结束运营”总是答成“早上开园时间”,说明检索和生成之间需要加一个分类修正。
成本方面,Embedding这一步其实很便宜,Chroma放在同一台机器上也没有太大压力。最大的开销通常是大模型API调用。我的建议是加一层缓存,相同问题直接命中历史答案,不再重复调用大模型。客服问题本身重复度就很高,缓存命中率往往能到30%以上,成本省下来很可观。
最后再分享一个我踩过的坑:早期做这类系统时,我总想把所有文档一次性灌进系统,觉得知识越多越强。结果知识库大了之后噪音也大了,检索很容易捞回一堆关联度低的高频词文档,回答准确率反而下降。后来总结出“先精后广,逐步加料”的原则,把高频问题整理成结构化FAQ入库,长文档单独切好并做严格召回,效果会稳健很多。迪士尼客服这种场景,重运营、轻模型,知识库的干净程度就是系统的真实护城河。按这个思路一步步来,你搭出来的客服RAG系统就值得被放进生产环境,真正帮人省时间。