庫(kù):語(yǔ)義檢索與向量化落地實(shí)戰(zhàn))
簡(jiǎn)介這份資源是一套基于PHP與ThinkPHP框架的運(yùn)營(yíng)級(jí)在線客服系統(tǒng)源碼重點(diǎn)解決客服系統(tǒng)接入AI知識(shí)庫(kù)的問題適合有一定PHP基礎(chǔ)、希望搭建智能客服平臺(tái)的開發(fā)者與運(yùn)維人員。壓縮包共2010個(gè)文件約68.72MB涵蓋131個(gè)php核心邏輯文件、613個(gè)js與258個(gè)css前端資源、368個(gè)html模板頁(yè)面以及sql建表腳本、sh部署腳本和md說明文檔結(jié)構(gòu)完整便于二次開發(fā)。資源附帶安裝教程明確要求安裝fileinfo與redis插件并在php.ini中禁用pcntl_signal、pcntl_fork等進(jìn)程控制函數(shù)以保證運(yùn)行環(huán)境的穩(wěn)定與安全。借助ThinkPHP的MVC能力與AI知識(shí)庫(kù)的自然語(yǔ)言處理匹配系統(tǒng)可輔助客服快速定位答案、提升響應(yīng)效率。目前已有137人學(xué)習(xí)下載適合需要完整客服系統(tǒng)源碼與部署排錯(cuò)思路的讀者參考。1. PHP在線客服接入AI知識(shí)庫(kù)從關(guān)鍵詞匹配到語(yǔ)義檢索的落地路徑很多 PHP 在線客服系統(tǒng)上線半年后都會(huì)遇到同一個(gè)尷尬知識(shí)庫(kù)文檔從 20 篇漲到 300 篇命中率反而從 70% 掉到 40% 以下。用戶問「訂單退款要幾天到賬」老系統(tǒng)靠關(guān)鍵詞匹配命中的卻是「退款政策總覽」這種大而全的條目答非所問。這不是客服系統(tǒng)本身的問題而是檢索層還停留在字符串匹配階段。把 AI 知識(shí)庫(kù)接進(jìn) PHP 在線客服本質(zhì)上是給客服系統(tǒng)換一套「語(yǔ)義檢索 生成回答」的引擎用戶提問先被向量化再去知識(shí)庫(kù)里找語(yǔ)義最近的片段最后交給大模型組織成人話。適合誰(shuí)做手里有 PHP 客服系統(tǒng)、知識(shí)庫(kù)文檔超過 50 篇、希望不重寫前端就能提升首答準(zhǔn)確率的團(tuán)隊(duì)。下面按「先跑通最小鏈路再調(diào)參數(shù)最后避坑」的順序講透。2. 為什么 PHP 客服系統(tǒng)需要語(yǔ)義檢索而不是關(guān)鍵詞匹配2.1 關(guān)鍵詞匹配在真實(shí)對(duì)話里的三個(gè)失效場(chǎng)景先看一個(gè)真實(shí)客服日志里的例子。用戶輸入「我買的東西不想要了怎么退錢」關(guān)鍵詞方案會(huì)提取「退錢」去匹配知識(shí)庫(kù)但知識(shí)庫(kù)里寫的是「退款流程」「售后申請(qǐng)」「原路退回」沒有一個(gè)詞和「退錢」字面重合。結(jié)果就是轉(zhuǎn)人工或者返回一條無關(guān)的 FAQ。第一個(gè)失效場(chǎng)景是同義表達(dá)。中文里「退錢 / 退款 / 返款 / 原路退回」是一回事但字符串匹配只認(rèn)字面。第二個(gè)失效場(chǎng)景是長(zhǎng)句里的意圖漂移。用戶說「昨天下的單今天還沒發(fā)貨能取消嗎」關(guān)鍵詞會(huì)同時(shí)命中「發(fā)貨」和「取消」系統(tǒng)不知道該走哪個(gè)分支。第三個(gè)失效場(chǎng)景是多輪上下文丟失。用戶先問「會(huì)員怎么開」再問「多少錢」關(guān)鍵詞方案把「多少錢」當(dāng)成獨(dú)立問題完全不知道在問會(huì)員價(jià)格。這三個(gè)場(chǎng)景的共同點(diǎn)是匹配發(fā)生在字面層而用戶意圖在語(yǔ)義層。語(yǔ)義檢索要解決的就是把「退錢」和「退款流程」映射到同一個(gè)向量空間里讓距離足夠近。2.2 向量檢索 大模型生成的兩段式架構(gòu)常見做法是把整個(gè)鏈路拆成兩段。第一段是檢索把知識(shí)庫(kù)每條文檔切片、向量化、存進(jìn)向量庫(kù)用戶提問也向量化算余弦相似度取 Top-K 片段。第二段是生成把 Top-K 片段拼進(jìn)提示詞交給大模型生成最終回答。為什么不讓大模型直接讀全部知識(shí)庫(kù)因?yàn)槌杀竞脱舆t都不可控。300 篇文檔全塞進(jìn)上下文一次請(qǐng)求可能幾萬(wàn) token響應(yīng)慢且貴。兩段式的好處是檢索層負(fù)責(zé)「找得準(zhǔn)」生成層負(fù)責(zé)「說得好」各司其職。PHP 在這個(gè)架構(gòu)里的角色是編排層接收用戶消息、調(diào)用向量化接口、查詢向量庫(kù)、拼提示詞、調(diào)生成接口、把結(jié)果寫回客服會(huì)話。PHP 本身不擅長(zhǎng)做向量計(jì)算但做 HTTP 編排和會(huì)話管理是它的強(qiáng)項(xiàng)。所以選型上向量庫(kù)和大模型都走外部服務(wù)PHP 只做膠水。2.3 知識(shí)庫(kù)切片粒度按段落還是按問答對(duì)切片粒度直接決定檢索質(zhì)量。按整篇文檔切一個(gè)片段可能 2000 字向量化后語(yǔ)義被稀釋檢索時(shí)區(qū)分度低。按單句切片段太短缺少上下文生成時(shí)模型拼不出完整回答。我一般會(huì)按「語(yǔ)義段落」切每段 200 到 500 字同時(shí)保留所屬文檔標(biāo)題作為元數(shù)據(jù)。如果知識(shí)庫(kù)本身是問答對(duì)格式那就按問答對(duì)切一問一答作為一個(gè)片段效果最好。切片時(shí)還要注意重疊相鄰片段保留 50 字左右重疊避免答案正好被切在邊界上。提示切片長(zhǎng)度沒有萬(wàn)能值。FAQ 類知識(shí)庫(kù)用 200 字左右操作手冊(cè)類用 400 到 500 字。上線前用 20 條真實(shí)用戶問題測(cè)一遍召回率比拍腦袋定參數(shù)靠譜。3. 用 PHP 跑通知識(shí)庫(kù)向量化與檢索的最小鏈路3.1 知識(shí)庫(kù)文檔切片與向量化的 PHP 腳本先寫一個(gè)把 Markdown 知識(shí)庫(kù)切片并向量化的腳本。這里假設(shè)向量化走外部接口PHP 只負(fù)責(zé)讀文件、切片、發(fā)請(qǐng)求、存結(jié)果。?php // slice_and_embed.php // 讀取知識(shí)庫(kù)目錄下的 md 文件按段落切片并調(diào)用向量化接口 function splitByParagraph(string $content, int $maxLen 400, int $overlap 50): array { // 先按空行拆成自然段 $paragraphs preg_split(/\n\s*\n/, trim($content)); $chunks []; $buffer ; foreach ($paragraphs as $p) { $p trim($p); if ($p ) continue; // 如果當(dāng)前緩沖加上新段落沒超長(zhǎng)就合并 if (mb_strlen($buffer . \n . $p) $maxLen) { $buffer $buffer ? $p : $buffer . \n . $p; } else { // 超長(zhǎng)就把緩沖存下來新段落作為下一塊起點(diǎn) if ($buffer ! ) { $chunks[] $buffer; // 保留尾部 overlap 字作為重疊 $buffer mb_substr($buffer, -$overlap) . \n . $p; } else { $buffer $p; } } } if ($buffer ! ) $chunks[] $buffer; return $chunks; } function embedText(string $text, string $apiUrl, string $apiKey): array { $payload json_encode([input $text, model embedding-model]); $ch curl_init($apiUrl); curl_setopt_array($ch, [ CURLOPT_POST true, CURLOPT_POSTFIELDS $payload, CURLOPT_HTTPHEADER [ Content-Type: application/json, Authorization: Bearer . $apiKey, ], CURLOPT_RETURNTRANSFER true, CURLOPT_TIMEOUT 15, ]); $resp curl_exec($ch); if ($resp false) { throw new RuntimeException(embed request failed: . curl_error($ch)); } curl_close($ch); $data json_decode($resp, true); return $data[data][0][embedding] ?? []; } // 主流程 $kbDir __DIR__ . /knowledge; $apiUrl https://your-embedding-endpoint/v1/embeddings; $apiKey getenv(EMBED_API_KEY); $output []; foreach (glob($kbDir . /*.md) as $file) { $title basename($file, .md); $content file_get_contents($file); $chunks splitByParagraph($content); foreach ($chunks as $i $chunk) { $vector embedText($chunk, $apiUrl, $apiKey); $output[] [ doc $title, chunk_index $i, text $chunk, vector $vector, ]; // 簡(jiǎn)單限速避免打爆接口 usleep(200000); } } file_put_contents(__DIR__ . /kb_vectors.json, json_encode($output, JSON_UNESCAPED_UNICODE)); echo done, chunks: . count($output) . PHP_EOL;邏輯說明splitByParagraph先按空行拆自然段再按maxLen合并超長(zhǎng)時(shí)保留尾部overlap字做重疊避免答案被切在邊界。embedText是標(biāo)準(zhǔn)的 HTTP 調(diào)用注意CURLOPT_TIMEOUT設(shè) 15 秒向量化接口偶爾會(huì)慢。主流程遍歷目錄下所有 md 文件逐塊向量化后寫入kb_vectors.json。參數(shù)說明maxLen控制切片長(zhǎng)度FAQ 類知識(shí)庫(kù)建議 200手冊(cè)類 400 到 500。overlap建議是maxLen的 10% 到 15%。usleep(200000)是 200 毫秒限速如果接口有 QPS 限制就調(diào)大。3.2 用戶提問的向量化與余弦相似度檢索向量存好后檢索就是算相似度。PHP 里用純數(shù)組算余弦相似度幾百條片段完全夠用。?php // search.php // 加載向量庫(kù)對(duì)用戶提問做檢索 function cosineSimilarity(array $a, array $b): float { $dot 0.0; $normA 0.0; $normB 0.0; $len count($a); for ($i 0; $i $len; $i) { $dot $a[$i] * $b[$i]; $normA $a[$i] * $a[$i]; $normB $b[$i] * $b[$i]; } if ($normA 0 || $normB 0) return 0.0; return $dot / (sqrt($normA) * sqrt($normB)); } function searchKnowledge(string $question, array $kb, string $apiUrl, string $apiKey, int $topK 3): array { $qVector embedText($question, $apiUrl, $apiKey); if (empty($qVector)) return []; $scored []; foreach ($kb as $item) { $score cosineSimilarity($qVector, $item[vector]); $scored[] [score $score, item $item]; } // 按分?jǐn)?shù)降序 usort($scored, fn($x, $y) $y[score] $x[score]); return array_slice($scored, 0, $topK); } // 使用示例 $kb json_decode(file_get_contents(__DIR__ . /kb_vectors.json), true); $question 我買的東西不想要了怎么退錢; $results searchKnowledge($question, $kb, $apiUrl, $apiKey, 3); foreach ($results as $r) { echo sprintf([%.4f] %s\n%s\n\n, $r[score], $r[item][doc], mb_substr($r[item][text], 0, 80)); }邏輯說明cosineSimilarity是標(biāo)準(zhǔn)實(shí)現(xiàn)注意先判零向量避免除零。searchKnowledge把提問向量化和打分分開方便單獨(dú)測(cè)試。usort用飛船運(yùn)算符降序排列取 Top-K。參數(shù)說明topK一般取 3 到 5。取太少可能漏掉正確片段取太多會(huì)把無關(guān)內(nèi)容塞進(jìn)提示詞反而干擾生成。如果知識(shí)庫(kù)超過 5000 條片段純 PHP 遍歷會(huì)變慢這時(shí)候要換成向量數(shù)據(jù)庫(kù)常見做法是用外部向量庫(kù)的 PHP SDK 或直接走 HTTP 接口。3.3 把檢索結(jié)果拼進(jìn)提示詞并調(diào)用生成接口檢索到片段后拼提示詞是關(guān)鍵一步。提示詞要明確告訴模型「只根據(jù)以下資料回答資料里沒有就說不知道」。?php // answer.php // 把檢索結(jié)果拼成提示詞調(diào)用生成接口 function buildPrompt(string $question, array $results): string { $context ; foreach ($results as $i $r) { $context . 【資料 . ($i 1) . 】 . $r[item][text] . \n\n; } return 你是一個(gè)客服助手。請(qǐng)只根據(jù)下面的資料回答用戶問題資料里沒有的信息不要編造直接說需要轉(zhuǎn)人工。\n\n . $context . 用戶問題 . $question . \n . 回答; } function generateAnswer(string $prompt, string $apiUrl, string $apiKey): string { $payload json_encode([ model chat-model, messages [[role user, content $prompt]], temperature 0.3, ]); $ch curl_init($apiUrl); curl_setopt_array($ch, [ CURLOPT_POST true, CURLOPT_POSTFIELDS $payload, CURLOPT_HTTPHEADER [ Content-Type: application/json, Authorization: Bearer . $apiKey, ], CURLOPT_RETURNTRANSFER true, CURLOPT_TIMEOUT 30, ]); $resp curl_exec($ch); curl_close($ch); $data json_decode($resp, true); return $data[choices][0][message][content] ?? 抱歉暫時(shí)無法回答。; } $prompt buildPrompt($question, $results); echo generateAnswer($prompt, $genUrl, $genKey);邏輯說明buildPrompt把每個(gè)片段編號(hào)后拼進(jìn)上下文編號(hào)是為了讓模型在回答時(shí)能引用來源。提示詞里明確「資料里沒有就說轉(zhuǎn)人工」這是防止幻覺的關(guān)鍵。generateAnswer里temperature設(shè) 0.3客服場(chǎng)景要穩(wěn)定不要發(fā)揮。參數(shù)說明temperature建議 0.2 到 0.4太高會(huì)編造太低會(huì)死板。CURLOPT_TIMEOUT設(shè) 30 秒生成接口比向量化慢。如果客服系統(tǒng)要求首答 3 秒內(nèi)要考慮流式輸出或緩存高頻問題。4. 接入在線客服系統(tǒng)時(shí)的避坑與排查4.1 檢索分?jǐn)?shù)很高但回答跑偏檢查切片是否混入無關(guān)內(nèi)容現(xiàn)象用戶問退款檢索返回的片段分?jǐn)?shù) 0.85但生成回答里混進(jìn)了「會(huì)員積分規(guī)則」。原因通常是切片時(shí)把兩個(gè)不相關(guān)段落合并進(jìn)了一個(gè)片段向量被平均后語(yǔ)義模糊反而和多個(gè)話題都「有點(diǎn)像」。解決方法是把maxLen調(diào)小或者切片時(shí)按標(biāo)題層級(jí)強(qiáng)制斷開不讓不同小節(jié)的內(nèi)容混在一塊。4.2 中文提問召回率低確認(rèn)向量模型是否支持中文現(xiàn)象英文知識(shí)庫(kù)檢索正常中文提問總是返回?zé)o關(guān)片段。原因是用了只針對(duì)英文訓(xùn)練的向量模型中文被拆成單字后語(yǔ)義丟失。解決方法是換成明確支持中文的向量模型上線前用 20 條中文問題測(cè)召回率。這個(gè)坑很隱蔽因?yàn)榻涌诓粓?bào)錯(cuò)只是效果差。4.3 客服會(huì)話超時(shí)向量化和生成接口的耗時(shí)疊加現(xiàn)象用戶發(fā)消息后 8 秒才收到回復(fù)客服系統(tǒng)提示超時(shí)。原因是向量化 1 秒 檢索 0.5 秒 生成 5 秒串行疊加。解決方法是把向量化結(jié)果緩存相同問題直接命中緩存生成接口改流式輸出先返回前幾個(gè)字讓用戶看到響應(yīng)。另外CURLOPT_TIMEOUT不要設(shè)太大超時(shí)就降級(jí)到關(guān)鍵詞匹配兜底。4.4 知識(shí)庫(kù)更新后檢索失效向量庫(kù)沒有同步重建現(xiàn)象運(yùn)營(yíng)在后臺(tái)改了知識(shí)庫(kù)文檔但客服回答還是舊內(nèi)容。原因是向量庫(kù)是離線生成的文檔改了但kb_vectors.json沒重新跑。解決方法是在知識(shí)庫(kù)保存時(shí)觸發(fā)重建任務(wù)或者加一個(gè)定時(shí)任務(wù)每小時(shí)增量更新。增量更新時(shí)要注意刪除舊片段否則同一篇文檔會(huì)有新舊兩份向量。4.5 生成回答帶出敏感信息提示詞沒有做邊界約束現(xiàn)象用戶問「你們公司內(nèi)部怎么處理投訴」模型把知識(shí)庫(kù)里一段內(nèi)部流程原樣答出來了。原因是提示詞只說了「根據(jù)資料回答」沒說哪些資料不能外泄。解決方法是在知識(shí)庫(kù)切片時(shí)給敏感文檔打標(biāo)簽檢索階段直接過濾掉提示詞里加一句「如果資料涉及內(nèi)部流程只回答對(duì)外口徑」。這個(gè)坑一旦踩了就是事故上線前必須測(cè)。5. 用緩存和降級(jí)把首答壓進(jìn) 2 秒的實(shí)戰(zhàn)技巧前面跑通了鏈路但真實(shí)客服場(chǎng)景對(duì)延遲很敏感。我自己的習(xí)慣是加兩層緩存加一層降級(jí)把首答壓到 2 秒內(nèi)。第一層是問題緩存。用戶問題向量化后先在一個(gè) Redis 里查有沒有相同或高度相似的問題余弦相似度大于 0.95有就直接返回上次的答案??头?chǎng)景里重復(fù)問題占比很高「怎么退款」「發(fā)貨時(shí)間」這類問題一天可能問幾十遍。緩存命中率做到 30% 以上平均延遲直接砍掉一大截。?php // cache.php // 用 Redis 緩存高頻問題的答案 function getCachedAnswer(string $question, array $kb, $redis, string $apiUrl, string $apiKey): ?string { $qVector embedText($question, $apiUrl, $apiKey); // 從 Redis 里取所有緩存問題的向量做比對(duì) $cachedKeys $redis-keys(qa:vector:*); foreach ($cachedKeys as $key) { $cached json_decode($redis-get($key), true); $sim cosineSimilarity($qVector, $cached[vector]); if ($sim 0.95) { return $cached[answer]; } } return null; } function saveAnswer(string $question, string $answer, array $qVector, $redis): void { $key qa:vector: . md5($question); $redis-setex($key, 86400, json_encode([ question $question, vector $qVector, answer $answer, ])); }邏輯說明getCachedAnswer把緩存里的向量逐個(gè)和當(dāng)前提問比對(duì)相似度超過 0.95 就認(rèn)為同一個(gè)問題。saveAnswer用setex設(shè) 24 小時(shí)過期避免緩存無限增長(zhǎng)。注意keys命令在緩存量大時(shí)會(huì)影響 Redis 性能生產(chǎn)環(huán)境建議用向量庫(kù)自帶的相似查詢或者維護(hù)一個(gè)獨(dú)立的緩存索引。參數(shù)說明相似度閾值 0.95 是經(jīng)驗(yàn)值調(diào)低到 0.9 會(huì)命中更多但可能答非所問調(diào)到 0.98 命中率下降。緩存過期時(shí)間 86400 秒是一天高頻問題可以設(shè)更長(zhǎng)。第二層是檢索結(jié)果緩存。同一個(gè)問題即使沒命中問題緩存檢索到的 Top-K 片段也可以緩存省掉向量化和檢索的時(shí)間。降級(jí)策略是最后一道保險(xiǎn)。如果向量化接口或生成接口超時(shí)直接走原來的關(guān)鍵詞匹配返回一條預(yù)設(shè)的兜底話術(shù)同時(shí)把這次失敗記進(jìn)日志??头到y(tǒng)最怕的是用戶發(fā)了消息沒反應(yīng)寧可返回一條不完美的回答也不能讓會(huì)話卡住。驗(yàn)證方法很簡(jiǎn)單拿 50 條真實(shí)用戶問題跑一遍記錄每條的首答延遲和準(zhǔn)確率。準(zhǔn)確率靠人工判斷延遲靠日志。我一般會(huì)盯兩個(gè)指標(biāo)P95 延遲95% 的請(qǐng)求在多少秒內(nèi)完成和轉(zhuǎn)人工率。P95 壓到 2 秒以內(nèi)、轉(zhuǎn)人工率下降 20% 以上這個(gè)方案就值得繼續(xù)投入。最后說個(gè)血淚經(jīng)驗(yàn)上線前一定要用真實(shí)客服日志測(cè)不要用自己編的問題測(cè)。自己編的問題往往太規(guī)范真實(shí)用戶會(huì)打錯(cuò)字、會(huì)夾帶情緒、會(huì)一句話問三件事。我踩過一次坑測(cè)試集準(zhǔn)確率 90%上線后真實(shí)日志準(zhǔn)確率只有 55%就是因?yàn)闇y(cè)試問題太干凈。后來改成從歷史日志里隨機(jī)抽 100 條準(zhǔn)確率才反映真實(shí)水平。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取