PHP在线客服接入AI知识库:RAG召回与流式输出实践
发布时间:2026/9/29 5:14:18来源:尧图网络
简介基于ThinkPHP框架开发的运营级在线客服系统源码已接入AI知识库能力面向需要为网站快速部署智能客服的PHP开发者。资源包为zip格式总计2010个文件、约68.72MB其中js/html/css前端资源共1239个用于搭建客服界面与交互php文件131个实现后端业务逻辑sql脚本可初始化数据库md/txt文档约292个包含安装配置说明解压后即可按教程部署。系统重点解决了两个常见环境问题安装fileinfo与redis扩展以及在php.ini中禁用pcntl_signal、pcntl_fork等进程控制函数配套教程对此有详细拆解基于ThinkPHP MVC框架代码分层清晰便于二次开发AI知识库利用NLP技术匹配客户问题可显著提升响应效率。资源内置Bootstrap、AmazeUI等前端组件界面组件丰富适合快速搭建面向业务运营的智能客服平台已有136人学习下载适合具备PHP基础、希望快速落地AI客服场景的开发者。1. PHP在线客服接入AI知识库先给机器人一块“只读记忆”访客深夜问“你们支持对公转账吗”坐席不在自动回复只会甩链接——这是大多数PHP在线客服系统的真实状态。把 PHP 在线客服接入 AI 知识库本质不是给聊天窗口换一套话术而是让机器人先读一遍你沉淀过的 FAQ、产品文档、工单结论再结合大模型组织语言回答。它能解决“机器人瞎编答案”和“知识散落各处没人维护”两个老问题适合已经有自研客服系统、手上有几十上百条真实问答数据、但不想把数据全交给外部平台的团队。先提醒一句不要一上来就上向量库先用最朴素的关键词检索跑通闭环后面再逐步加语义能力。2. 怎么接入才划算先定知识库形态再定 RAG 流水线通用大模型不知道你产品的具体参数、售后政策和工单处理口径直接让它回答等于打开一个黑匣子。所以要做的是“召回增强生成”RAG每次用户提问时先从知识库里检索相关片段再把片段作为参考资料塞给大模型让它基于资料作答。这条流水线才是接入方案的核心模型换谁都能跑知识库和召回策略才决定客服机器人答得准不准。2.1 三种知识库方案的取舍常见做法有三种我按部署成本和召回效果排序。SQLite FTS5 全文检索或 MySQL 全文索引零部署、零额外运维适合 FAQ 标准化程度高、问题描述相对固定的场景。缺点是语义泛化弱“对公转账”和“企业汇款”这种同义表达匹配不上。向量召回把文档和用户问题都转成向量用余弦相似度找相近语义。效果好但需要有 embedding 模型来源。数据量几百条时直接用 PHP 全量扫一遍算余弦完全可行数据量上万条后再考虑专用向量库。开源知识库平台企业里如果已经部署了 FastGPT、Dify 这类开源知识库PHP 侧就不需要自己处理分段、向量化、日志只需要把客服会话流转成对方 API 的输入输出。省事但引入一个重平台后续多租户权限、审计日志仍然要自己在外面包一层。方案部署成本召回效果维护量适合场景SQLite FTS5 / MySQL 全文低中关键词命中低FAQ 规范、问题固定自建向量召回中中上可同义改写中文档量大、口语化问题多开源知识库平台高高高已有平台、多团队共用我一般会建议从“关键词召回 向量召回并行、分数相加”开始。关键词负责精确命中向量负责兜底同义表达两个结果取并集后按融合分数排序比单独用任何一种都稳。数据不出内网的团队embedding 可以本地跑 Ollama 上的开源模型比如 Qwen显存和并发量要提前估算不要等到上线才测。2.2 一次完整请求要经过的五步不管选哪种知识库形态PHP 侧每次收到访客消息后的处理链路是固定的我通常拆成五个步骤拉取最近对话上下文做意图分流闲聊直接走通用回复售后问题才进知识库问答。对用户问题做轻量改写把口语转成检索句例如“怎么给你们打钱”改写成“对公转账 方式”。从知识库召回 Top K 片段这一步必须在 PHP 里做因为多租户过滤、权限校验、审计日志都在这一层控制。拼装 System 知识片段 最近对话 当前问题控制在模型可接受的 token 预算内。请求大模型并用流式方式返回给客服界面同时把本次问答写入日志表。为什么强调“召回放在 PHP 里”因为直接让大模型平台接管知识库等于把权限模型外包了。在线客服里常有“不同客户只能看不同产品线文档”的需求这个过滤条件只能在召回 SQL 里加模型侧管不了。这里顺带说一个 PHP 接口对接的常见坑json_decode 第二个参数不传 true 时拿到的是对象传了才是数组很多人数组和对象混用导致参数拼错。拼 prompt 之前先把返回结构统一成数组否则后面每一步都在和“stdClass 属性访问”较劲。3. 建库与召回用 PHP 把“能答的问题”变成可检索片段知识库的数据来源通常是历史 FAQ 表格、产品文档、工单解决记录。第一步是清洗把 HTML 标签、换行符、重复的“您好”问候语去掉再把一条完整知识拆成可检索的片段。切块原则我踩过几次坑后固定下来按自然段落切每段 200 到 500 字块与块之间重叠 50 字左右并且每块开头保留所属标题。因为召回到的片段是要直接拼进 prompt 的片段里没有标题大模型就会丢失“这段在回答哪个主题”的信息。3.1 建表与 FTS5 索引外部内容表加触发器FTS5 是 SQLite 自带的全文检索扩展PHP 的 SQLite3/PDO 直接可用不用装额外服务。为了让检索表和业务表解耦我用外部内容表的方式建索引。CREATE TABLE faq_documents ( id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT NOT NULL DEFAULT faq, title TEXT, question TEXT, answer TEXT NOT NULL, full_text TEXT NOT NULL, embedding_json TEXT, tenant_id INTEGER NOT NULL DEFAULT 1, created_at INTEGER ); CREATE VIRTUAL TABLE faq_documents_fts USING fts5( full_text, title, contentfaq_documents, content_rowidid, tokenizetrigram ); CREATE TRIGGER faq_documents_ai AFTER INSERT ON faq_documents BEGIN INSERT INTO faq_documents_fts(rowid, full_text, title) VALUES (new.id, new.full_text, new.title); END; CREATE TRIGGER faq_documents_ad AFTER DELETE ON faq_documents BEGIN INSERT INTO faq_documents_fts(faq_documents_fts, rowid, full_text, title) VALUES(delete, old.id, old.full_text, old.title); END;原因分两点第一trigram 分词器对中英文混合场景比 unicode61 更友好它按连续三个字符做索引“对公转账”这种词拆起来不会散架缺点是索引体积偏大几千条文档场景完全可接受第二外部内容表让 FTS 表不重复存正文触发器负责同步避免业务表和索引表内容不一致。SQLite 3.34 以上才支持 trigram老版本建议直接升 SQLite 或改用 unicode61不要在生产环境纠结兼容旧版本。插入文档时full_text 我一般拼成“标题 问题 答案”三段中间用换行分隔。目的是让检索时标题命中的文档排在前面因为 FTS5 的 bm25 排序会综合各列权重。3.2 关键词召回FTS5 MATCH 的转义与排序召回函数是整条链路里最容易翻车的地方翻车点不在 SQL而在用户输入。用户消息里随便带个双引号或括号直接拼进 MATCH 子句就会报语法错误。public function keywordSearch(PDO $pdo, string $query, int $topK 5): array { $match $this-buildFtsMatch($query); $sql SELECT f.rowid AS id, d.full_text, d.title, bm25(faq_documents_fts) AS score FROM faq_documents_fts f JOIN faq_documents d ON d.id f.rowid WHERE faq_documents_fts MATCH :q AND d.tenant_id :tenant_id ORDER BY score LIMIT :topK; $stmt $pdo-prepare($sql); $stmt-bindValue(:q, $match, PDO::PARAM_STR); $stmt-bindValue(:tenant_id, $this-tenantId, PDO::PARAM_INT); $stmt-bindValue(:topK, $topK, PDO::PARAM_INT); $stmt-execute(); return $stmt-fetchAll(PDO::FETCH_ASSOC); } private function buildFtsMatch(string $query): string { $clean preg_replace(/[^\p{L}\p{N}\s]/u, , $query); $terms array_values(array_filter(explode( , $clean))); $terms array_map(function (string $term): string { return . str_replace(, , trim($term)) . ; }, $terms); return implode( AND , $terms); } private function cosineSim(array $a, array $b): float { $dot 0.0; $normA 0.0; $normB 0.0; foreach ($a as $i $val) { $dot $val * $b[$i]; $normA $val * $val; $normB $b[$i] * $b[$i]; } if ($normA 0.0 || $normB 0.0) { return 0.0; } return $dot / (sqrt($normA) * sqrt($normB)); }buildFtsMatch 做了两件事把所有标点替换成空格再把每个词用双引号包起来形成词组匹配。这样“支持对公转账吗”会变成 “支持” AND “对公转账” “对公转账”中间的空格不会破坏整体。bm25 在 FTS5 中返回的是负数数值越大相关性越高所以 ORDER BY score不要倒序。LIMIT 的绑定值在 PDO 里必须用 bindValue 而不是 bindParam否则类型不匹配会导致 SQLite 报错。召回后还要设一个最低阈值低于阈值就认为知识库没有命中直接转人工。这个阈值不能拍脑袋我的做法是把知识库里所有文档跑一遍自问自答看得分分布取 10% 分位数作为兜底线并将这个阈值做成后台配置项。3.3 向量召回几百条数据用 PHP 全量算余弦就够了向量召回的常见做法是离线脚本把每篇文档转成向量存进 embedding_json 字段在线请求时只编码用户问题然后用 PHP 全量扫一遍算余弦相似度。这个方案在 5000 条以内完全够用毫秒级响应不要过早引入向量数据库。public function semanticSearch(PDO $pdo, array $queryVector, int $topK 5): array { $rows $pdo -query(SELECT id, full_text, title, embedding_json FROM faq_documents WHERE tenant_id . (int)$this-tenantId) -fetchAll(PDO::FETCH_ASSOC); $results []; foreach ($rows as $row) { if (empty($row[embedding_json])) { continue; } $docVector json_decode($row[embedding_json], true); if (!is_array($docVector)) { continue; } $sim $this-cosineSim($queryVector, $docVector); if ($sim $this-semanticThreshold) { continue; } $results[] [ id $row[id], title $row[title], full_text $row[full_text], score round($sim, 4), ]; } usort($results, fn($a, $b) $b[score] $a[score]); return array_slice($results, 0, $topK); }cosineSim 里默认两个向量维度相同如果 embedding 模型换了版本导致维度不一致json_decode 后要先校验 count否则返回的相似度没有任何意义。embedding 结果的生成要放在定时脚本里跑不要在客服请求路径里在线生成否则每次用户提问都要等一次模型推理体验直接崩盘。4. 把召回结果拼成提示词PHP 流式输出 AI 回答的完整链路召回只是上半场下半场是把结果组装成一次大模型调用并且用流式方式推送回前端。这里最核心的认知是PHP 不是不能做流式而是你必须关掉所有缓冲机制让数据边到边出。4.1 组装上下文与控制 token 预算提示词我固定用四段结构System 设定人设和约束、参考资料、最近对话、当前问题。控制预算比调模型参数更影响体验参数表我给一个参考值段落预算上限说明System400 token人设、禁止编造、转人工条件知识片段2000 token召回 Top 3 到 5 条每条截断 400 字最近对话1000 token只保留最近 6 轮超出丢弃当前问题200 token原文即可public function buildPrompt(array $docs, array $recentMessages, string $question): array { $context ; foreach ($docs as $i $doc) { $content mb_substr($doc[full_text], 0, 400, UTF-8); $context . [资料{$i}] {$content}\n\n; } $history []; $lastMessages array_slice($recentMessages, -6); foreach ($lastMessages as $msg) { $history[] [ role $msg[role], content mb_substr($msg[content], 0, 300, UTF-8), ]; } $system 你是商城在线客服。只依据参考资料回答 . 资料不足时直接说明并引导转人工 . 不要编造商品参数和售后政策。; return [ model your-model-name, messages array_merge( [[role system, content $system]], $history, [[role user, content $question . \n\n参考资料\n . $context]] ), stream true, temperature 0.3, ]; }mb_substr 截断是为了防止用户消息里粘贴了大段日志导致发送失败。temperature 设在 0.3 左右客服场景要的是稳定回答不是创意写作。把参考资料放在 user 消息末尾而不是塞进 system是兼容大多数模型的习惯也方便调试时肉眼检查到底传了哪些内容。4.2 用 cURL 的 CURLOPT_WRITEFUNCTION 做流式转发OpenAI 兼容接口和 Ollama 本地接口我都用同一套写法CURLOPT_RETURNTRANSFER 设为 false配合 CURLOPT_WRITEFUNCTION 回调每收到一块数据就立刻转发给前端。这样访客看到的是打字机输出而不是转圈十秒后一次性弹出来。public function streamChat(array $payload): void { header(Content-Type: text/event-stream; charsetutf-8); header(Cache-Control: no-cache); header(X-Accel-Buffering: no); ob_end_flush(); ob_implicit_flush(true); $ch curl_init($this-llmEndpoint); curl_setopt_array($ch, [ CURLOPT_POST true, CURLOPT_HTTPHEADER [Content-Type: application/json], CURLOPT_POSTFIELDS json_encode($payload, JSON_UNESCAPED_UNICODE), CURLOPT_RETURNTRANSFER false, CURLOPT_TIMEOUT 60, CURLOPT_WRITEFUNCTION function ($ch, $chunk): int { $lines explode(\n, $chunk); foreach ($lines as $line) { $line trim($line); if ($line || !str_starts_with($line, data:)) { continue; } $json json_decode(substr($line, 5), true); $content $json[choices][0][delta][content] ?? $json[message][content] ?? ; if ($content ! ) { echo $content; ob_flush(); flush(); } } return strlen($chunk); }, ]); $ok curl_exec($ch); if ($ok false) { error_log(stream curl error: . curl_error($ch)); echo 系统繁忙请稍后重试或转人工。; } curl_close($ch); }这段代码的关键是返回 strlen($chunk)告诉 cURL 这次回调完整处理了缓冲数据返回其他值会导致 cURL 中止传输。X-Accel-Buffering: no 是给 Nginx 看的让 Nginx 不要在后端之前缓冲响应这是 PHP 流式输出在 Nginx 后面能即时到达浏览器的关键。Ollama 的响应格式略有差异但兜底解析了 message.content 字段两边都能兼容。不想把那么长的 HTTP 连接挂在 PHP-FPM worker 上另一个常见做法是PHP 把任务推进 Redis 队列后台常驻进程负责请求大模型结果通过 WebSocket 推给客服界面。这个方案能彻底解决 PHP-FPM 占用问题但要额外维护一个长驻进程适合并发量上来之后再演进。4.3 机器人答不上来的转人工设计LLM 不是每次都能答对转人工要设计成明线而不是暗线。我的做法是召回阶段分数低于阈值就直接给前端返回“转人工”指令根本不需要请求大模型如果召回到资料但生成后坐席端觉得回答不可信界面提供“一键转人工并携带上下文”按钮把最近十轮对话和召回片段一并转给坐席。public function reportUnmatched(int $tenantId, string $question, string $category): void { $stmt $this-pdo-prepare( INSERT INTO unmatched_questions (tenant_id, question, category, created_at) VALUES (:tenant_id, :question, :category, :created_at) ); $stmt-execute([ :tenant_id $tenantId, :question mb_substr($question, 0, 200, UTF-8), :category $category, :created_at time(), ]); }reportUnmatched 在召回无命中或用户主动点“没解决”时调用。这张表是知识库闭环的入口坐席在后台补充答案后审核通过就可以直接入库成为新的知识片段下一次类似问题就能命中。没有这个闭环知识库永远靠人工维护跑一个月就过期了。5. 接入中的 5 个坑从 SSE 卡死到知识泄漏这章全是我在真实接入里踩过的坑基本覆盖了从联调到上线的排查路径每条都按现象、原因、解决三步写。5.1 现象SSE 半天不出字访客以为机器人死了原因出在两层缓冲PHP 的 output_buffering 开着内容积满 4096 字节才发送或者 Nginx 开了 proxy_buffering后端响应被 Nginx 攒住。日志里 curl 正常、但浏览器端就是没输出。解决PHP 侧在 streamChat 开头调用 ob_end_flush 和 ob_implicit_flush(true)同时确认 php.ini 里 output_buffering 是 OffNginx 站点配置里加 proxy_buffering off以及刚才代码里的 X-Accel-Buffering: no 响应头。三个条件缺一个流式效果都出不来。联调时建议先用 curl -N 直连 PHP 端口排除 Nginx 影响再往上查。5.2 现象上下文一长就超时或直接报 400原因把全部历史消息和所有召回片段都拼进 prompt超过模型单次输入上限。API 返回 400 还算好有些平台会静默丢弃超长内容导致回答驴唇不对马嘴。解决做一次 prompt 长度自检发送前统计字符数超过预算就按优先级丢弃先丢最旧的对话再丢资料片段中分数最低的。我通常在 buildPrompt 最后加一行日志记录本次发送的消息数组大小上线后拉日志看分布按 P95 值调整上限。5.3 现象检索结果答非所问术语变一个说法就搜不到原因FTS5 按字面匹配用户说“怎么给你们打钱”知识库写的是“对公转账”两个词不重合。这是关键词召回的天花板不是 bug。解决建一张同义词表把高频口语词映射到标准词。例如“打钱、汇款、公对公、转账”统一映射到“对公转账”查询前先做一次词组替换。这一步在 PHP 里实现很简单一张 map 就够了等映射表超过几百条再考虑接入向量召回做语义兜底。5.4 现象并发一高PHP-FPM 全被占满普通页面也跟着卡原因一次流式请求最长可能持续几十秒期间 worker 一直被占用。10 个并发请求就能吃掉默认的 10 个 worker其他接口全部排队。解决最直接的是用 Nginx 做并发限制limit_req 单独给流式接口限速更彻底的是把大模型请求改造成异步任务PHP 接收请求后推 Redis消费端是常驻 CLI 进程前端用 WebSocket 或轮询拿结果。第二套方案工程量多一天但对在线客服这种长连接场景是正解。5.5 现象知识库串租户客户 A 问到了客户 B 的信息原因召回 SQL 漏了 tenant_id 过滤或者知识库里混入了未脱敏的聊天记录、工单内容大模型把这些内容当资料输出了。解决召回 SQL 强制带租户条件参数绑定不要拼字符串知识库入库前只允许通过审核的 FAQ 和文档聊天记录必须脱敏后才能进知识库System 提示词里写明“只依据资料回答资料中不含相关内容时转人工”。这三层缺一层后面出了事都是安全事故不是普通 bug。6. 上线前用 30 条问题做回归比调参更有用调 temperature、调 topK、调阈值都不如先建一个小型回归集见效。我做知识库更新时必跑一遍 30 条真实客服问题的回归流程固定把问题逐条发给机器人接口记录每条问题的召回分数、机器人回答、是否建议转人工再用表格比对期望结果。表格字段就四个问题、期望答案是否在知识库中、实际回答质量评分、转人工判断是否合理。跑一轮后看两类数据一类是“知识库明明有答案但没召回”说明切块或索引有问题另一类是“召回正常但大模型答偏了”说明 prompt 结构或参考资料排序需要调整。一次回归大概花半小时比上线后翻聊天记录排查高效得多。进阶技巧是把第 4.3 节的 unmatched_questions 表做成自动任务每周统计未命中问题里出现频次最高的前二十条自动生成一条待办给坐席主管。坐席补充答案后知识库就有了新的弹药。我自己的教训是第一次接 AI 客服时跳过了回归集上线第一周就被访客截图“机器人答非所问”后来老老实实把 30 条问题跑成固定脚本每次更新知识库先重跑一遍再也没出过系统性翻车。这套做法不挑模型、不挑知识库形态值得在你自己的 PHP 客服系统里先落地一轮。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网