LangChain智能客服实战:如何基于直属库构建高效问答系统
背景痛点:传统客服的“慢”与“乱”
去年我在一家 SaaS 公司做客服中台,高峰期每天 3 万条工单。老系统用 MySQL 全文索引 + 正则匹配,平均响应 4.8 秒,命中率只有 42%。痛点集中在这几点:
- 关键词检索只能做字面匹配,用户口语化提问直接翻车
- 知识库 18 万条 FAQ,每次 like '%xxx%' 把 CPU 打满
- 大模型直接回答,幻觉严重,还慢——一次 GPT-4 调用 8 秒,用户早走了
老板一句话:能不能“秒回”且“不说错”?于是我们把目光投向 LangChain + 直属库(公司内部的 PostgreSQL 集群,延迟 < 5 ms)的 RAG 方案。
技术选型:直接 LLM vs RAG
| 维度 | 直接调用大模型 | RAG + 直属库 |
|---|---|---|
| 延迟 | 2–8 s | 200–600 ms |
| 幻觉 | 高 | 低(可控) |
| 数据新鲜度 | 训练截止日 | 实时同步 |
| 成本 | 按 1k tokens 计费 | 自建向量索引,几乎 0 增量 |
| 可解释 | 黑盒 | 返回出处,可审计 |
结论:客服场景要“快、准、省”,RAG 完胜。
核心实现:三步搞定“直属库 + LangChain”
1. 数据预处理:把 18 万条 FAQ 变成“向量块”
先写个并行清洗脚本,把数据库里 title、content 两列捞出来,清洗掉 HTML、连续换行,再按 512 token 滑动窗口切分,overlap 10% 防止截断语义。
# etl/chunk_worker.py import re, os, json from sqlalchemy import create_engine from langchain.text_splitter import RecursiveCharacterTextSplitter DSN = "postgresql+psycopg2://user:pwd@直属库:5432/crm" engine = create_engine(D,SN, pool_pre_ping=True) def clean(txt): txt = re.sub(r'<.*?>', '', txt) # 去 HTML txt = re.sub(r'\s+', ' ', txt) return txt.strip() def yield_rows(): sql = "SELECT id, title, content FROM faq WHERE status='ONLINE'" for chunk in pd.read_sql(sql, engine, chunksize=5000): for _, row in chunk.iterrows(): text = f"{row['title']}\n{row['content']}" yield row['id'], clean(text) def build_chunks(): splitter = RecursiveCharacterTextSplitter( chunk_size=512, chunk_overlap=51, length_function=len, separators=["\n", "。", ";"] ) for pk, text in yield_rows(): for idx, sub in enumerate(splitter.split_text(text)): yield {"faq_id": pk, "chunk_id": idx, "text": sub} if __name__ == "__main__": with open("chunks.jsonl", "w", encoding="utf8") as f: for c in build_chunks(): f.write(json.dumps(c, ensure_ascii=False) + "\n")用 GNU Parallel 把 18 万条拆 20 进程跑,10 分钟搞定。
2. 向量化与索引:直属库 pgvector 一步到位
直属库已装 pgvector 插件,直接建表:
CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE faq_emb ( id SERIAL PRIMARY KEY, faq_id INT, chunk_id INT, text TEXT, emb VECTOR(1536) );Python 灌库:
# emb/insert.py import openai, json, psycopg2 from pgvector.psycopg2 import register_vector openai.api_key = os.getenv("OPENAI_KEY") conn = psycopg2.connect(dbname="crm", user="user", password="pwd", host="直属库") register_vector(conn) def get_embedding(text): resp = openai.Embedding.create(input=text, model="text-embedding-ada-002") return resp['data'][0]['embedding'] cur = conn.cursor() with open("chunks.jsonl", encoding="utf8") as f: for line in f: c = json.loads(line) emb = get_embedding(c["text"]) cur.execute( "INSERT INTO faq_emb(faq_id,chunk_id,text,emb) VALUES %s", [(c["faq_id"], c["chunk_id"], c["text"], emb)] ) conn.commit()建 IVFFlat 索引加速:
CREATE INDEX ON faq_emb USING ivfflat (emb vector_cosine_ops) WITH (lists = 100);3. LangChain RetrievalQA:200 毫秒完成“检索 + 生成”
# bot/chain.py from langchain.vectorstores.pgvector import PGVector from langchain.chat_models import ChatOpenAI from langchain.chains import RetrievalQA from langchain.prompts import PromptTemplate CONNECTION_STRING = "postgresql+psycopg2://user:pwd@直属库:5432/crm" COLLECTION = "faq_emb" vectorstore = PGVector( connection_string=CONNECTION_STRING, collection_name=COLLECTION, embedding_function=OpenAIEmbeddings(model="text-embedding-ada-002") ) prompt = PromptTemplate( input_variables=["context", "question"], template=""" 你是公司客服机器人,只能使用以下上下文回答问题,禁止编造: {context} 问题:{question} """ ) qa = RetrievalQA.from_chain_type( llm=ChatOpenAI(model="gpt-3.5-turbo", temperature=0), chain_type="stuff", retriever=vectorstore.as_retriever(search_kwargs={"k": 3}), chain_type_kwargs={"prompt": prompt}, return_source_documents=True )FastAPI 包一层:
# bot/api.py from fastapi import FastAPI app = FastAPI() @app.post("/ask") def ask(q: str): ans = qa({"query": q}) return {"answer": ans["result"], "source": ans["source_documents"]}压测结果:P99 580 ms,命中率 87%,老板终于笑了。
性能优化:让“秒回”更稳
1. 并行建索引
上面 ETL 脚本开 20 进程,IO 打满但 CPU 还有余量,就把 embedding 请求改成异步:
import asyncio, aiohttp, openai async def embed_many(texts): tasks = [openai.Embedding.acreate(input=t, model="ada-002") for t in texts] return await asyncio.gather(*tasks)一次批量 100 条,QPS 提升 6 倍。
2. 缓存热问
客服 80% 问题集中在 200 个高频 FAQ。用 Redis 把“向量哈希 → 答案”缓存 5 分钟,命中后 20 ms 返回。哈希取法:
import hashlib def qhash(q): return hashlib.blake2b(q.encode('utf8'), digest_size=8).hexdigest()3. 超时重试
OpenAI 接口偶发 429,用 tenacity 装饰器:
from tenacity import retry, stop_after_attempt, wait_random_exponential @retry(wait=wait_random_exponential(min=1, max=20), stop=stop_after_attempt(5)) def get_embedding(text): ...避坑指南:中文场景的小地雷
分词器选错,句子被拦腰斩断
用RecursiveCharacterTextSplitter时把 separators 放中文标点:“\n。;,” 实测召回 +5%。向量相似度阈值
cosine < 0.78 基本胡言乱语,> 0.82 又太保守。用验证集画 PR 曲线,选 0.8 最平衡。对话状态管理
多轮场景要保留上文。把历史问答拼成“伪文档”再检索,否则用户追问“那怎么办”会断片。LangChain 的ConversationalRetrievalQA可接盘。
生产建议:监控 + 容灾
| 指标 | 采集方式 | 告警阈值 |
|---|---|---|
| P99 延迟 | FastAPI middleware | > 1 s |
| 检索命中率 | 日志统计 | < 80% |
| 幻觉率 | 随机 100 条人工抽检 | > 5% |
| 向量索引延迟 | pg_stat_statements | > 100 ms |
容灾:直属库做主从 + 延迟从,向量表每日逻辑备份到对象存储;LLM 侧配置 fallback 到 Azure OpenAI,DNS 秒级切换。
进阶思考
- 查询改写:先用 LLM 把口语问句改写成关键词组合,再向量检索,能否再提 10% 命中率?
- 分级召回:先走倒排索引粗排 1k 候选,再走向量精排 10,延迟能否压到 200 ms 以内?
- 私有化 embedding:用中文 BGE-large-en-v1.5 替代 OpenAI,成本归零,效果会不会掉?
把这三点跑通,也许就能从“秒回”进化到“毫秒回”了。祝你玩得开心,有问题评论区一起踩坑。