简介:面向企业内网环境下的智能客服与知识问答场景,这套方案基于开源大模型与检索增强生成(RAG)技术,并融合企业内部知识图谱,让回答能够引用特定业务知识,同时通过内网部署保障数据安全与隐私,适合AI工程师、运维人员及技术决策者在私有化场景中参考落地。压缩包共5个文件,包括4个Python脚本和1份技术文档,整体仅4KB。脚本覆盖Embeddings计算、自定义LLM封装、Rag主流程等核心模块,技术文档则说明系统架构与关键实现思路,便于快速上手改造。目前已有348人学习,读者可借此获得一套可修改、可扩展的RAG客服代码骨架,理解从开源模型接入到知识图谱引用的完整链路,有效缩短企业内网智能客服的搭建周期。
1. 检索增强生成不是摆设:内网智能客服从立项到落地的全链路拆解
你可能不信,一个只有五千条制度问答的企业知识库,裸跑大模型给出的答案,居然有四成是从别的问题里“借”来的。这个结果不是我编的,是某次给某企业做内部客服系统验证时实测出来的。那之后我就明白,检索增强生成不是大模型开箱即用的附属品,而是一整套决定答案质量的管线:问句怎么处理、知识怎么切、上下文怎么拼、答案怎么验,每一环都在影响最终输出。这篇要拆的,就是一条基于开源大模型、企业内部知识图谱、可完全内网运行的智能客服落地链路——从模型选型、文档切分、向量检索,到图谱构建与答案组装,全部按可复现的方式讲。适合要私有化部署、手头有制度文档或运维知识库、想少走弯路的从业者。
2. 开源大模型选型与内网推理部署:先让模型能在内网跑起来
2.1 选型:为什么最终锁定的是本地可部署的对话模型
做内网客服,第一步不是急着写代码,而是把模型选型这件看似简单的事想透。很多人一上来就追参数最大的那个模型,觉得“模型越大越聪明”,但在 RAG 场景里,模型扮演的角色其实是一个“阅读理解器”——真正的事实性知识来自检索回来的文档片段,模型负责把这些片段组织成通顺、有据可依的回答。换句话说,选型的第一优先级不是知识容量,而是中文指令跟随能力、长上下文处理能力和部署生态成熟度。
我一般会给客户画这样一张选型对照表:
| 模型规模 | 量化方式 | 显存占用(约) | 单机可行性 | 适用定位 |
|---|---|---|---|---|
| 7B-14B | FP16 / INT8 | 16GB-32GB | 单卡即可 | 快速原型验证,复杂指令易漂移 |
| 70B 级 | INT8 / INT4 | 40GB-80GB | 单机多卡或大显存单卡 | 生产环境首选 |
| 百B 以上 | 多机多卡 | 128GB+ | 成本陡增 | 一般场景不需要 |
企业内部客服的问题,答案重事实不重创造。比如“报销审批时限是几天”“服务器申请要走哪个流程”,这类问题需要的是准确召回和忠实复述,而不是模型自己发挥。用 7B 级模型做原型很快,但一旦问题里带多跳关系,比如“技术部的服务器申请要经过哪两层审批”,小模型的指令跟随会明显吃力——它容易把“技术部”“服务器”“审批”这几个词拆开处理,答得似是而非。所以我建议生产环境至少考虑 70B 级模型,量化到 INT8 或 INT4 之后,单机双卡基本能跑起来,成本可控,效果也稳。
选型还有一个常被忽略的点:生态。优先选那些有成熟推理框架支持、社区资料多、切分和微调工具齐全的开源对话模型,这直接决定后面踩坑时你能不能快速找到后悔药。我在某次项目里就吃过亏,选了个性能指标很好看但部署资料几乎为零的模型,结果一个量化参数调了三天,最后只能换掉重来。从那以后,我选模型先看“有多少人在用”,再看“指标有多好”。
2.2 启动推理服务:量化、上下文长度与并发线程的取舍
模型权重准备好之后,下一步是把模型跑成一个可以被检索链路调用的服务。常见做法是直接用开源的推理框架起一个兼容 OpenAI 接口的服务,检索代码那边只需要发 HTTP 请求就能拿到生成结果。下面是一个我在项目中常用的启动命令,路径和参数按实际环境调整:
# 启动推理服务(常见做法,实际路径按你本地环境调整) python -m llm_server.entrypoints.openai.api_server \ --model /data/models/chat-model-70b \ --quantization awq \ --max-model-len 8192 \ --tensor-parallel-size 2 \ --gpu-memory-utilization 0.9 \ --port 8000 \ --served-model-name chat-model-70b这个命令里有几个参数直接影响客服系统的稳定性。--model指向本地权重目录,保证模型文件全部落在内网,不经过外网。--quantization awq表示用 AWQ 这种权重量化方式把模型压到 4bit 左右,显存占用能比 FP16 少一半以上,代价是生成质量轻微下降,但对客服场景几乎无感。--max-model-len 8192必须认真设,它决定了模型能一次性处理多长的输入——在 RAG 链路里,检索回来的文档片段会被拼进 prompt,如果这个值设小了,长文档一进来就直接报错;设太大,又会显著增加显存占用和首字延迟。我一般先按 8192 起步,实测过程中如果发现显存吃紧再往下调。--tensor-parallel-size 2表示用双卡并行推理,单卡显存不够时的常见解法。最后--gpu-memory-utilization 0.9是告诉推理框架可以占用单卡 90% 的显存,别留太多浪费空间,但也别拉满到 0.99,否则后续偶发显存峰值容易 OOM。
启动完成后,服务会监听在 8000 端口。这时候别急着接业务,先做一个最小请求验证链路通不通。
2.3 打通生成链路:最小的健康检查请求
# 向推理服务发送一个最小生成请求,验证服务状态 curl http://127.0.0.1:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "chat-model-70b", "messages": [{"role": "user", "content": "你好,请用一句话说明你的能力。"}], "temperature": 0.3, "max_tokens": 200 }'如果返回的 JSON 里有正常的choices内容,说明模型服务已经就绪。这个最小请求也顺便验证了三个事情:模型名是否和启动时一致、端口是否能从检索服务所在机器访问、temperature 参数是否生效。我记得有次部署,启动参数里忘了写--served-model-name,结果服务默认用目录名作为模型名,检索代码那边一直报“model not found”,排查了半天才发现是名字对不上。所以这里建议:模型名在启动时固定下来,写进配置文件,后面所有调用都从配置读,不要手敲。
需要注意的是,temperature在客服场景下一般要压低。我通常设 0.1 到 0.3,因为客服答案要的是确定性,不是创造力。如果设成 0.7 以上,同一个问题问两次,答案可能飘到完全不同的方向上,这在知识库问答里是不可接受的。
3. 文档切分与向量化:把企业知识变成机器能检索的形态
3.1 中文切分策略:为什么不能拿英文切分逻辑直接套
模型服务就绪后,真正的知识工程才刚开始。企业内部的制度文档,常见格式是 Word、PDF 或 Markdown,内容结构通常是“章节—条款—细则”的多级嵌套。要做检索,第一步是把这些长文档切成一个个能独立表达语义的段落,再转成向量。切分这件事,看起来简单,实际上是最容易翻车的地方。
很多开源工具默认按英文标点(句号、换行)切分,拿来切中文制度文档,会出现两个典型问题:一是把“第一条 目的与适用范围”这种标题和下面的正文内容硬生生切开,导致后续向量检索时,光切出来的标题片段没有实际语义;二是把表格里的内容拆得七零八落,比如审批流程表里“部门—角色—时限”三列被拆成三段,检索时根本拼不回一张完整的表。所以我做中文切分的习惯是:先用 Markdown 解析器把文档转成带层级结构的树,再沿着“一级标题—二级标题—正文段落—表格”的路径切,保证每个 chunk 携带上下文标题信息。
下面是我常用的切分代码段,依赖关系省略,只留核心逻辑:
def split_document(markdown_text, chunk_size=512, overlap=60): """ 按标题层级切分后的逻辑块做滑动窗口切分 """ from md_parser import parse_blocks # 解析 markdown 为层级块 blocks = parse_blocks(markdown_text) # 返回带 level 和 text 的块列表 chunks = [] for block in blocks: text = block["text"] # 对长文本做固定窗口切分,overlap 保留上下文衔接 start = 0 while start < len(text): end = min(start + chunk_size, len(text)) # 只在最近的换行符处截断,避免把一句话切成两半 if end < len(text): cut = text.rfind("\n", start, end) if cut != -1: end = cut chunk = text[start:end] # 把层级标题拼到 chunk 头部,帮助模型理解上下文 chunks.append({ "content": f"{block['heading']}\n{chunk}", "source": block["source"], "block_id": block["block_id"] }) if end >= len(text): break start = end - overlap return chunks这个逻辑里有两个参数值得单独说明。chunk_size我建议设在 256 到 512 之间,企业制度文档的语义粒度通常是一段话或一个条款,切太大,一个 chunk 里掺入多个主题,检索时容易召回“语义漂移”的片段;切太小,语义表达不完整,模型拿到的上下文零碎。overlap一般取 50 到 80,目的很简单——让相邻 chunk 之间有重叠区域,这样如果某个知识恰好被切在边界上,至少有一个 chunk 能保留完整语义。代码里还用rfind("\n")做了边界回退,避免在句子中间硬切断。这个点看着小,实际对召回率影响很大,我测试过同一份文档,硬切比先找换行符再切,召回准确率能差出将近 10 个百分点。
3.2 Embedding 模型选择与向量库参数
文档切完之后,下一步就是把每个 chunk 编码成向量。选 Embedding 模型,我只看两点:中文效果好不好、向量维度是否主流。常见选择是开源的中文向量编码模型,一般是把句子映射成 1024 维左右的向量;维度太低,信息表达受限;维度太高,向量库的存储和检索开销会明显上升。做内网部署还得多留个心眼——有些向量模型虽然是开源的,但推理时有一些在线更新行为,内网环境必须确认模型权重已经完全落盘,推理时不走任何外部请求。
向量写完,要存进向量数据库。我在内网项目里更倾向选支持本地文件存储、不带外部依赖的向量库。下面是建库和写入的伪代码示例:
from vector_db import Collection, DistanceMetric, IndexType # 创建集合:指定向量维度和相似度度量方式 collection = Collection( name="corp_knowledge", dimension=1024, # 与 embedding 模型输出维度一致 metric=DistanceMetric.COSINE, # 余弦相似度,适合文本检索 index_type=IndexType.HNSW, # HNSW 索引,检索速度与精度均衡 index_params={"M": 32, "efConstruction": 200} ) # 写入文档块 collection.add( ids=[f"chunk_{i}" for i in range(len(chunks))], vectors=[encode(chunk["content"]) for chunk in chunks], payloads=chunks, # 存原始文本、来源、块编号 )这里重点说metric和index_type。文本检索的相似度度量,常见有內积(IP)和余弦(COSINE)两种。如果 Embedding 模型输出的向量已经做过归一化,内积和余弦在数学上等价;但如果模型没归一化,用余弦更稳妥,因为它只看方向不看模长,能避免长文档因为字数多导致向量模长大、分数虚高的问题。HNSW索引是当前向量库的主流选择,检索速度快、召回也不错,参数上M控制每个节点的连接数,影响内存和检索精度,一般取 16 到 32;efConstruction是建索引时的候选队列大小,越大索引质量越高,但建库时间越长。生产环境不要偷懒用默认值,先按这两个参数起步,数据量大了再调。
3.3 检索参数:top_k、score 阈值与重排的必要性
向量库写完之后,就到了检索参数标定阶段。这一步容易让人产生“差不多就行”的心态,但它其实决定了最终答案的质量上限。我收到过不少类似的反馈:向量库数据也写进去了,模型也通了,为什么答得还是不对?十有八九是检索参数没调好。
先看top_k。肯定不能只取 3 条就完事,因为向量检索返回的前几条不一定都准,先多取一些候选回来再筛,比一开始就收窄要好。我一般把初筛top_k设在 20 到 30,然后用一个轻量级的中文重排序模型对候选做二次打分,最后只把得分最高的 3 到 5 条拼进 prompt。重排序模型不便宜,但对答案质量的提升非常明显,它能综合“语义相关度”和“问题意图匹配度”重新排序,把向量召回阶段混进来的“看起来像但其实不相关”的片段压下去。
score阈值的话,得看实测数据说话。不同的 Embedding 模型,相似度分数的分布差异很大,有人喜欢设 0.7 以上的“高阈值”,结果召回率低得吓人,一堆真知识被判成噪声。就我用过的模型来看,余弦相似度低于 0.35 的片段基本可以当作噪声丢弃,0.5 以上可靠性明显上升,但稳妥起见,阈值要配合你抽样标注的 30 到 50 个“问题-正例段落”来标定,不要拍脑袋。如果你发现阈值调高后召回质量下降,优先怀疑切分粒度,而不是换个 Embedding 模型重来——切分造成的信息丢失,换什么模型都救不回来。
4. 从普通 RAG 到 GraphRAG:把知识图谱接进客服问答的实战路径
4.1 纯向量检索为什么在客服场景失灵
先讲一个某次项目里的真实场景。知识库里有一条制度写着“员工因公出差需提前三天提交出差申请”,另一条写着“出差申请经部门负责人审批后,由财务部预支差旅费用”。用户问“我要出差,找谁批、钱怎么拿”,纯向量检索的返回结果往往是两条都召回,但模型拼接的时候,容易把“找财务部预支费用”和“找部门负责人审批”的顺序搞混,甚至答成“找财务部审批”。
这就是纯向量检索的结构性缺陷——它理解“相似”,但不理解“关系”。用户问的是业务流程,背后是“员工—提交→出差申请—审批→部门负责人—预支→财务部”这样一条关系链,而向量检索只是在语义空间里找最接近的文本片段,它看不见节点之间的连边。要解决这个问题,就需要把检索增强生成升级为基于知识图谱的检索:在向量检索之外,再加一层关系补全,让模型拿到的不仅是“相似的文本”,还有“准确的关系”。
4.2 知识图谱建库:把制度文档变成实体、关系、属性的三元组
构建企业内部知识图谱,没有想象中那么玄。企业制度文档的结构化程度其实比互联网文本高得多,常见实体是“部门”“角色”“流程节点”“系统名称”,常见关系是“提交”“审批”“驳回”“关联”,常见属性是“时限”“金额”“条件”。我一般先用大模型辅助抽取三元组,再人工复核,而不是一开始就追求自动化的完美抽取。下面是一个抽取提示词的示例,你可以换成自己常用的措辞:
请从以下制度文本中抽取业务知识三元组。 输出 JSON 数组,每个元素格式: {"head": "实体或角色", "relation": "关系", "tail": "实体或角色", "attr": "属性描述(可为空)"} 只抽与业务流程、审批权限、时限金额相关的信息。不要抽常识性内容。 文本: 员工出差申请需提前三天提交,由部门负责人审批;审批通过后财务部在1个工作日内完成差旅预支。模型输出类似这样的结果:
[ {"head": "员工", "relation": "提交", "tail": "出差申请", "attr": "提前三天"}, {"head": "出差申请", "relation": "审批", "tail": "部门负责人", "attr": ""}, {"head": "出差申请", "relation": "预支差旅费用", "tail": "财务部", "attr": "审批通过后1个工作日内"} ]拿到三元组之后,把实体和关系写进图数据库。写入逻辑不复杂,关键是保证同类实体名称一致,否则后面查询时关系链会断。我自己习惯在写入之前做一步“实体归一化”——比如“部门负责人”和“部门领导”统一成“部门负责人”,“财务部”和“财务处”统一成“财务部”。常见做法是维护一张同义词映射表,写入前先查表替换。这一步看似额外,但少了它,图查询会出现莫名其妙为空的情况,后面第五章会专门讲这个坑。
写入图数据库的 Cypher 语句示例:
// 创建实体节点 MERGE (e:Entity {name: '员工'}) MERGE (d:Entity {name: '出差申请'}) // 创建关系,带属性 MERGE (e)-[r:提交 {attr: '提前三天'}]->(d)4.3 混合检索主流程:向量召回、图谱补全与答案组装
图谱建好后,整个客服系统的主流程就不再是单纯的“向量检索—拼 prompt—生成”三段式,而是多了一层图谱查询。我常用的是这样一个混合链路:
第一步,用户问题进来,同时做两件事:一边用向量检索去知识库拿相似文本片段,一边用问题里的实体去图数据库查关系链。第二步,把两路结果合并:向量检索给出“事实出处”,图谱查询给出“关系链条”。第三步,按“先关系、后事实”的顺序拼进 prompt,让模型先看懂流程,再读取文本细节。第四步,生成最终答案。
这一步用 Python 伪代码来描述会比较清晰:
def hybrid_retrieve(question, top_k=20, max_graph_hops=2): # 1. 向量召回:问题编码后查向量库 query_vec = embed(question) vector_hits = vector_db.search(query_vec, top_k) # 2. 图谱补全:从问题中提取实体,沿关系扩展 entities = extract_entities(question) # 基于词典或小模型 graph_chains = [] for ent in entities: chains = graph_db.query( f"MATCH (a {{name: '{ent}'}})-[r*1..{max_graph_hops}]-(b) " f"RETURN a, r, b LIMIT 20" ) graph_chains.extend(chains) # 3. 合并与排序:向量结果按分数降序,图谱结果按路径长度升序 merged = merge_results(vector_hits, graph_chains) # 4. 组装 prompt:先放图谱关系链,再放向量检索文本 evidence = format_evidence(merged) return evidence这个流程里,max_graph_hops值得单独说一下。企业内部流程关系通常不会特别深,两跳以内基本能覆盖“员工—提交—出差申请—审批—部门负责人—预支—财务部”这类典型链。设太深,会查回来一堆弱相关的关系,反而把 prompt 撑爆;设太浅,关系链不完整。从我的经验看,先按 2 跳起步,如果发现某些问题需要更深的链路,再针对具体问题类型加深度,不要全局一刀切。
另外一个容易被忽略的细节是merge_results后的排序逻辑。向量结果按相似度降序,图谱结果按路径长度升序,两类结果最好不要简单地线性拼接——否则图谱的路径会稀释向量召回的语义相关性。我一般会把图谱关系链作为“前置证据”放在 prompt 最前面,把向量文本作为“细节支撑”放在后面,让模型阅读顺序符合“先明白流程关系,再看具体条款”的逻辑。这一点在后面的调优环节还会再展开。
5. 内网 RAG 客服避坑与排查:五个最常见的翻车点修复记录
5.1 现象:答案内容看着很流畅,但答非所问
某个测试问题“服务器坏了找谁修”,系统答了一长串“服务器申请流程”。原因是向量检索召回时,“服务器坏了”和“服务器申请”在语义上相似度很高,模型拿到错误证据后照样生成流畅答案。解决方法是给检索层加一个“问题意图分类”前置:先判断问题是“故障处理类”还是“流程咨询类”,再决定向量检索的语料子集。另外,把重排序阈值调严一点,对低于阈值的召回片段直接丢弃,不要塞给模型。
5.2 现象:图谱查询经常返回空结果
明明知识库里存在“财务部负责差旅预支”的关系,图查询却查不到。排查发现原因有两个:一是抽取出来的实体名不统一,文档里时而写“财务部”时而写“财务处”,入库时没做归一化,导致查询“财务部”时匹配不到“财务处”的节点;二是关系方向写反了,抽取时把“提交”和“被提交”混用。解决方法是写一个实体归一化检查脚本,跑全量数据输出“同名不同节点”的清单,统一后再查;同时给 Cypher 查询加WITH a, r, b WHERE type(r) IN ['提交','审批','预支']这类关系类型过滤,避免方向混乱导致结果错位。
5.3 现象:长对话场景下显存占用持续增长,最终 OOM
原因不是模型本身泄漏,而是检索回来的文档过多,prompt 越来越长,max-model-len又设得大,推理框架每次都要维护完整的 KV cache。解决方法是给 prompt 加预算:单次问答最多放 3 段检索文本,超过部分截断;上下文长度从 8192 降到 4096 实测,确定长文档场景够用就行;同时打开推理框架的“自动批处理”特性,避免单请求独占整卡显存。
5.4 现象:生成的答案像是在“朗读”检索文本,不像客服
模型把检索回来的条款原文直接吐出来,夹杂大量“第一条”“第 2.3 款”之类的格式。原因是 prompt 里没有强制约束输出形式。解决方法是把系统提示词改成明确指令,例如“你是企业内部客服,只能依据给定资料回答,用简洁口语表达,不得输出原文格式、不得追加资料以外的内容”。这是投入最小但见效最快的一处优化,改完基本能消除“朗读感”。
5.5 现象:内网环境部署时第三方依赖装不上,服务起不来
离线服务器没有外网源,pip install直接超时。解决方法是提前在能联网的机器上准备好全部依赖的 wheel 包,连同约束文件一起拷进内网,用pip install --no-index --find-links=./wheels -r requirements.txt安装。除此之外,还要确认模型权重、Embedding 模型、重排序模型三类文件全部预置在本地目录,启动参数里关闭任何联网检查行为。我第一次做离线部署时没确认这一点,模型加载卡了二十分钟才意识到它在尝试访问外部地址。
6. 检索质量评估与最终调优:给客服答案加上证据链
6.1 用召回证据做答案置信度校验
把客服系统当成一个黑匣子,是很多项目翻车的起点。我习惯生产上线前做一轮“证据一致性抽查”:准备 50 条覆盖典型客服场景的问题,跑完系统后不看答案,先看模型引用的检索证据,再判断答案是否忠于证据。两条标准:证据与问题是否相关、答案里的关键事实是否都能在证据里找到。任何一条不满足,直接标记为“不合格”,回到检索层调参。这一步像给答案加了证据链,能让“看似流畅但实际是幻觉”的输出暴露出来。我见过不少案例,答案读起来毫无破绽,但关键数值是从另一个问题里借来的,不查证据根本发现不了。
6.2 温度、top_p 与重复惩罚的组合技巧
生成参数的组合,没有万能配方,但有一条我踩坑踩出来的经验:温度设低不设高。客服场景里,temperature=0.1、top_p=0.9、presence_penalty=0.5是一套我常用的起点组合。温度压得低,输出稳定;top_p保留一定采样空间,避免回答过于呆板;presence_penalty适度调高,能减少模型反复重复同一句话。如果某类问题需要列举多个选项,可以把温度提到 0.3,但绝不超过 0.5——超过之后,客服答案会开始“发散”,这是做知识库问答最忌讳的。
每次调参,我都建议留下一份“参数-测试集评分”记录表,同一个问题集,只改一个参数,记录答案合格率的变化。不要同时改两个以上的生成参数,否则你永远说不清是哪个改动起了作用。这套工作流看起来慢,但它是把玄学变成工程手段的唯一路径。那一次上线前,我强制自己把 50 条测试问题跑了五个版本,每一版只动一个参数,最后定位到“答案漂移”问题出在presence_penalty设成 0 导致重复,而不是模型本身不行。从那以后,我每次上线客服系统都强制走一遍证据一致性抽查和单参数调优,先信证据,再信手感。希望帮到你。
本文还有配套的精品资源,点击获取