资讯动态

PHP在线客服接入AI知识库:ThinkPHP+向量检索实战

发布时间:2026/10/17 9:24:49 来源:尧图企业网站定制
简介这份资源是一套基于PHP与ThinkPHP框架的运营级在线客服系统源码核心亮点在于接入AI知识库面向需要为企业搭建智能客服平台的中高级PHP开发者与运维人员。压缩包共2010个文件约68.72MB以613个js、368个html、258个css等前端资源为主配合131个php业务代码、3个sql数据库脚本及151个md说明文档整体结构完整便于二次开发与部署。资源附带安装教程涵盖fileinfo、redis插件安装以及pcntl_signal、pcntl_fork等进程控制函数的禁用配置帮助读者快速跑通运行环境。系统借助AI知识库与自然语言处理能力为客服人员提供智能问答支持提升响应效率与用户满意度。目前已有137人学习下载适合希望研究客服系统架构、AI知识库接入方案或ThinkPHP工程实践的开发者参考借鉴。1. 从一条“答非所问”的工单说起PHP在线客服接入AI知识库能解决什么去年帮一个做SaaS工具的朋友看后台客服系统里堆了三百多条未回复工单用户问的全是“怎么导出报表”“API调用频率限制是多少”这类问题。三个客服轮班倒白天回微信、晚上回邮件还是漏。更头疼的是同一个问题这周答了八遍下周换个人来问答案又不一样。后来我们决定给这套跑了三年的PHP客服系统接一个AI知识库让机器先扛住80%的重复问题人工只处理复杂case。这套方案的核心就是用ThinkPHP做后端调度把企业已有的文档、FAQ、历史工单向量化后存进知识库用户提问时先走语义检索命中就直接返回答案没命中再转人工。适合谁手里已经有PHP客服系统、想低成本加AI能力的中小团队或者正在选型阶段、想看看PHP能不能扛住AI问答场景的开发者。下面我把这套东西从架构到代码拆一遍能跑通也能改。2. 架构拆解ThinkPHP怎么把AI知识库塞进现有客服系统2.1 整体分层与数据流向这套系统不复杂核心就四层接入层、调度层、检索层、生成层。接入层负责接用户消息可以是网页Widget、微信公众号、或者API调度层用ThinkPHP的控制器和中间件做路由分发、会话管理、限流检索层是重点把用户问题转成向量去知识库里找最相似的Top-K片段生成层拿到检索结果后拼Prompt调大模型API返回自然语言答案。数据流向是这样的用户发消息 → ThinkPHP接收 → 预处理去噪、纠错→ 向量化 → 知识库检索 → 判断相似度阈值 → 高于阈值走生成低于阈值转人工 → 生成答案 → 返回用户 → 异步记录日志和未命中问题。这里有个关键决策为什么用ThinkPHP而不是换Java或Python因为现有客服系统就是ThinkPHP写的换语言成本太高。ThinkPHP 6.x的中间件和依赖注入机制足够支撑这种调度场景性能瓶颈不在框架在向量检索和模型API的响应速度。常见做法是把向量检索单独拆一个服务用FAISS或者MilvusThinkPHP只做HTTP客户端调用。2.2 知识库的存储选型与表结构知识库存储分两块原始文档和向量索引。原始文档存MySQL向量索引存Redis或专用向量库。如果知识库规模在十万条以内用Redis的RediSearch模块就够部署简单ThinkPHP直接用predis扩展操作。超过十万条建议上Milvus或者Qdrant检索延迟能控制在50ms以内。表结构设计上我一般会建三张表kb_documents存文档元信息kb_chunks存切分后的片段和向量IDkb_qa_logs存问答日志用于后续优化。kb_chunks里有个vector_id字段对应向量库里的主键检索时先拿vector_id再回MySQL查原文。CREATE TABLE kb_documents ( id int unsigned NOT NULL AUTO_INCREMENT, title varchar(255) NOT NULL COMMENT 文档标题, source_type tinyint NOT NULL DEFAULT 1 COMMENT 1-手动录入 2-文件导入 3-工单同步, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE kb_chunks ( id bigint unsigned NOT NULL AUTO_INCREMENT, doc_id int unsigned NOT NULL COMMENT 关联文档ID, content text NOT NULL COMMENT 片段原文, vector_id varchar(64) NOT NULL COMMENT 向量库主键, token_count int DEFAULT 0 COMMENT 片段token数, PRIMARY KEY (id), KEY idx_doc_id (doc_id), UNIQUE KEY uk_vector_id (vector_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;参数说明source_type区分知识来源方便后续按来源调权重token_count用于控制Prompt长度避免超模型上下文vector_id用UUID保证分布式环境下不冲突。2.3 向量化与检索的代码实现向量化用OpenAI的text-embedding-3-small或者国产的BGE模型看预算和合规要求。ThinkPHP里封装一个EmbeddingService统一处理文本转向量。?php namespace app\service; use think\facade\Cache; use GuzzleHttp\Client; class EmbeddingService { private $client; private $apiKey; private $model text-embedding-3-small; public function __construct() { $this-client new Client([timeout 10]); $this-apiKey env(EMBEDDING_API_KEY); } /** * 批量文本向量化 * param array $texts 文本数组 * return array 向量数组 */ public function embedBatch(array $texts): array { // 缓存命中直接返回减少API调用 $cacheKey emb_ . md5(implode(|, $texts)); $cached Cache::get($cacheKey); if ($cached) { return $cached; } $response $this-client-post(https://api.example.com/v1/embeddings, [ headers [ Authorization Bearer . $this-apiKey, Content-Type application/json, ], json [ model $this-model, input $texts, ], ]); $data json_decode($response-getBody(), true); $vectors array_column($data[data], embedding); Cache::set($cacheKey, $vectors, 3600); return $vectors; } }逻辑说明embedBatch支持批量处理减少网络往返缓存层用ThinkPHP的Cache门面默认文件缓存生产环境换Redistimeout设10秒超时直接抛异常避免拖死整个请求。参数上model可以换成bge-large-zh维度从1536变1024检索时注意索引维度要一致。检索部分用Redis的向量相似度搜索ThinkPHP里通过predis的rawCommand调RediSearch命令。?php namespace app\service; use Redis; class VectorSearchService { private $redis; public function __construct() { $this-redis new Redis(); $this-redis-connect(env(REDIS_HOST, 127.0.0.1), 6379); } /** * 向量相似度检索 * param array $queryVector 查询向量 * param int $topK 返回条数 * return array 匹配结果 */ public function search(array $queryVector, int $topK 5): array { $vectorBlob pack(f*, ...$queryVector); $cmd [ FT.SEARCH, kb_idx, *[KNN . $topK . vector $BLOB AS score], PARAMS, 2, BLOB, $vectorBlob, SORTBY, score, RETURN, 3, vector_id, doc_id, score, DIALECT, 2, ]; $raw $this-redis-rawCommand(...$cmd); return $this-parseResult($raw); } private function parseResult(array $raw): array { $results []; for ($i 1; $i count($raw); $i 2) { $fields $raw[$i 1]; $item []; for ($j 0; $j count($fields); $j 2) { $item[$fields[$j]] $fields[$j 1]; } $results[] $item; } return $results; } }逻辑说明pack(f*, ...$queryVector)把浮点数组转成二进制BlobRediSearch要求这种格式KNN是K近邻检索score是距离越小越相似DIALECT 2启用新版查询语法。参数上topK一般设3到5太多会稀释Prompt质量kb_idx是提前建好的向量索引建索引时维度必须和Embedding模型一致。3. 跑起来从零搭建PHP客服AI接入环境3.1 环境依赖与ThinkPHP项目初始化先确认服务器环境PHP 8.0以上ThinkPHP 6.1Redis 7.0带RediSearch模块MySQL 5.7以上。Composer装依赖Guzzle做HTTP客户端predis做Redis操作。composer create-project topthink/think kb-customer-service cd kb-customer-service composer require guzzlehttp/guzzle:^7.0 predis/predis:^2.0Redis要装RediSearch模块Docker环境下直接拉redis/redis-stack镜像自带RediSearch和RedisJSON。配置文件.env里加几个关键项APP_DEBUG false [REDIS] HOST 127.0.0.1 PORT 6379 [EMBEDDING] API_KEY sk-xxxxxxxx MODEL text-embedding-3-small [LLM] API_KEY sk-yyyyyyyy MODEL gpt-4o-mini参数说明APP_DEBUG生产环境必须关避免泄露堆栈Embedding和LLM的Key分开配方便单独换供应商模型名按实际用的填国产模型改base_url就行。3.2 知识库初始化与索引创建建RediSearch索引的代码放在命令行工具里用ThinkPHP的think命令跑一次就行。?php namespace app\command; use think\console\Command; use think\console\Input; use think\console\Output; use Redis; class InitKbIndex extends Command { protected function configure() { $this-setName(kb:init-index) -setDescription(初始化知识库向量索引); } protected function execute(Input $input, Output $output) { $redis new Redis(); $redis-connect(env(REDIS_HOST, 127.0.0.1), 6379); // 删除旧索引避免维度冲突 try { $redis-rawCommand(FT.DROPINDEX, kb_idx); } catch (\Exception $e) { // 索引不存在时忽略 } // 创建索引维度1536对应text-embedding-3-small $redis-rawCommand( FT.CREATE, kb_idx, ON, HASH, PREFIX, 1, chunk:, SCHEMA, vector, VECTOR, FLAT, 6, TYPE, FLOAT32, DIM, 1536, DISTANCE_METRIC, COSINE, vector_id, TAG, doc_id, NUMERIC ); $output-writeln(索引 kb_idx 创建完成); } }逻辑说明FT.DROPINDEX先删旧索引防止维度不一致报错PREFIX chunk:表示只索引以chunk:开头的KeyFLAT是暴力检索十万条以内够用超过换HNSWDIM 1536必须和Embedding模型输出维度一致换模型时这里要改。跑命令php think kb:init-index。3.3 问答接口的完整调用链接口层用一个控制器收消息串联预处理、检索、生成三步。?php namespace app\controller; use app\service\EmbeddingService; use app\service\VectorSearchService; use app\service\LlmService; use think\facade\Db; use think\Request; class ChatController { public function ask(Request $request) { $question $request-post(q, ); if (empty($question)) { return json([code 400, msg 问题不能为空]); } // 1. 向量化 $embedding new EmbeddingService(); $vector $embedding-embedBatch([$question])[0]; // 2. 检索 $search new VectorSearchService(); $hits $search-search($vector, 3); // 3. 阈值判断低于0.75转人工 if (empty($hits) || $hits[0][score] 0.25) { Db::name(kb_qa_logs)-insert([ question $question, hit 0, created_at date(Y-m-d H:i:s), ]); return json([code 200, data [answer 这个问题我暂时答不上来已转人工客服, transfer true]]); } // 4. 拼Prompt调LLM $context $this-buildContext($hits); $llm new LlmService(); $answer $llm-generate($question, $context); // 5. 记录日志 Db::name(kb_qa_logs)-insert([ question $question, answer $answer, hit 1, created_at date(Y-m-d H:i:s), ]); return json([code 200, data [answer $answer, transfer false]]); } private function buildContext(array $hits): string { $chunks Db::name(kb_chunks) -whereIn(vector_id, array_column($hits, vector_id)) -column(content); return implode(\n---\n, $chunks); } }逻辑说明score是余弦距离0表示完全相似1表示完全不相似阈值0.25对应相似度约0.75这个值要按业务调buildContext把检索到的片段拼成上下文用---分隔方便模型区分日志表记录命中情况后续可以分析哪些问题没覆盖到。参数上topK设3是平衡质量和速度score阈值设0.25偏保守宁可转人工也别瞎答。4. 避坑指南PHP接AI知识库常翻车的五个点4.1 向量维度不一致导致检索报错现象调检索接口返回Vector dimension mismatch或者RediSearch直接报错。原因建索引时用的维度是1536后来换了Embedding模型输出1024维但索引没重建。解决换模型必须重建索引先FT.DROPINDEX再FT.CREATE同时把kb_chunks里的vector_id全部重新生成。我一般会在EmbeddingService里加个getDimension()方法建索引前先校验。4.2 相似度阈值设太低AI开始胡说现象用户问“怎么退款”AI答“退款需要联系财务部王经理”但知识库里根本没这条。原因阈值设了0.5检索到一些弱相关片段模型强行编答案。解决阈值调到0.2到0.3之间同时Prompt里加一句“如果上下文没有相关信息直接说不知道”。另外可以在返回前做一次答案校验用规则匹配关键实体对不上就转人工。4.3 Redis内存暴涨检索变慢现象知识库才两万条Redis内存占了8G检索延迟从20ms涨到200ms。原因向量以FLOAT32存储1536维一条就是6KB两万条120MB但RediSearch的索引结构会额外占内存加上没设过期策略日志数据也堆在Redis里。解决向量索引和业务缓存分开实例部署kb_qa_logs写MySQL不写Redis定期跑FT.COMPACT整理索引碎片。4.4 大模型API超时拖死PHP进程现象用户提问后页面转圈十几秒最后502。原因LLM API响应慢PHP-FPM进程被占满新请求排队。解决把LLM调用改成异步用ThinkPHP的队列think-queue丢后台前端先返回“正在思考”用轮询或WebSocket推结果。如果必须同步timeout设5秒超时直接转人工别硬等。4.5 知识库切分太粗检索命中率低现象用户问“API频率限制”检索到的片段是一整篇五千字的接口文档模型从中摘答案经常摘错。原因文档切分按固定长度500字没考虑语义边界。解决按段落切每段不超过300字段间重叠50字表格和代码块单独成段切分后用token_count过滤超过模型上下文一半的片段再切一刀。我一般会写个ChunkService按\n\n切分后再合并短段。5. 二次开发把通用问答改成业务专属客服5.1 用历史工单微调检索权重通用知识库检索对所有片段一视同仁但业务场景下历史工单里的答案往往比文档更准。改造思路在kb_chunks表加一个source_weight字段工单来源设1.2文档来源设1.0手动录入设1.5。检索时把score乘以权重再排序。// 在VectorSearchService的search方法后加一层重排 private function rerank(array $hits): array { $weights Db::name(kb_chunks) -whereIn(vector_id, array_column($hits, vector_id)) -column(source_weight, vector_id); foreach ($hits as $hit) { $w $weights[$hit[vector_id]] ?? 1.0; $hit[final_score] $hit[score] / $w; } usort($hits, function ($a, $b) { return $a[final_score] $b[final_score]; }); return $hits; }逻辑说明score是距离越小越好除以权重后权重大的片段final_score更小排更前。参数上权重值别设太大1.0到1.5之间够了否则会压制其他来源。5.2 多轮对话的上下文管理单轮问答够用但用户经常追问“那这个限制能改吗”需要带上下文。改造方案用Redis存会话历史key是session:{id}value是最近三轮的问答对。每次请求先取历史拼进Prompt。$history Cache::get(session: . $sessionId, []); $history[] [role user, content $question]; // 只保留最近三轮避免Prompt过长 $history array_slice($history, -6); $prompt $this-buildPromptWithHistory($history, $context);参数说明-6表示最近三轮每轮一问一答超过就丢弃最早的会话过期时间设30分钟太长占内存太短用户觉得断片。5.3 灰度发布与效果验证改完别直接全量先切10%流量到新逻辑对比命中率和转人工率。在ChatController里加个开关$useNew rand(1, 100) 10; // 10%灰度 if ($useNew) { $hits $this-rerank($hits); }验证指标看三个检索命中率hit1占比、转人工率、用户追问率同一session内连续提问超过3次算追问。跑一周命中率涨了5个点以上再全量。从那以后我每次改检索逻辑都强制走一遍灰度哪怕只改一个权重值因为线上流量太杂本地测不出来。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑