{"id":3523,"date":"2026-10-18T09:28:23","date_gmt":"2026-10-18T14:28:23","guid":{"rendered":"https:\/\/xlinesoft.com\/blog\/?p=3523"},"modified":"2026-10-18T10:29:56","modified_gmt":"2026-10-18T15:29:56","slug":"add-semantic-search-to-a-phprunner-application","status":"publish","type":"post","link":"https:\/\/xlinesoft.com\/blog\/2026\/10\/18\/add-semantic-search-to-a-phprunner-application\/","title":{"rendered":"Add Semantic Search to a PHPRunner Application"},"content":{"rendered":"<p>Keyword search can miss records that describe the same problem using different words. In this tutorial, we will add semantic search to a PHPRunner support-ticket application. A search for &#8216;customers having access or login problems&#8217; can find tickets about rejected credentials, unauthorized sessions, or account access even when they do not contain the exact search phrase.<\/p>\n<p>The application uses the OpenAI Embeddings API to convert ticket text and each search phrase into vectors. PHPRunner then compares those vectors locally with cosine similarity and returns the 30 closest tickets. This approach is intended for relatively small collections. For tens of thousands of records or more, use a vector database instead of comparing every vector in PHP. The cosine similarity scores in the diagram measure how closely the vectors are related. They are not probabilities or percentages of correctness.<\/p>\n<p><a href=\"https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/Support-Ticket-Similarity-Network.png\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/Support-Ticket-Similarity-Network-1024x683.png\" alt=\"\" width=\"810\" height=\"540\" class=\"alignnone size-large wp-image-3524\" srcset=\"https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/Support-Ticket-Similarity-Network-1024x683.png 1024w, https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/Support-Ticket-Similarity-Network-600x400.png 600w, https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/Support-Ticket-Similarity-Network-768x512.png 768w, https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/Support-Ticket-Similarity-Network.png 1536w\" sizes=\"auto, (max-width: 810px) 100vw, 810px\" \/><\/a><br \/>\n<!--more--><\/p>\n<p><strong>Semantic Search in Action<\/strong><\/p>\n<p>Let&#8217;s try searching for <strong>&#8220;Employees are being denied entry to the company portal.&#8221;<\/strong><\/p>\n<p>Notice that the results include tickets about failed logins, incorrect credentials, authentication errors, and users being redirected back to the sign-in page.<\/p>\n<p>These tickets describe similar problems using different terminology. A traditional keyword search might miss some of them because the search phrase doesn&#8217;t contain words such as &#8220;password,&#8221; &#8220;login,&#8221; or &#8220;authentication.&#8221;<\/p>\n<p>Semantic search recognizes the relationship between these descriptions and returns relevant tickets based on their meaning, not just matching words.<\/p>\n<p>The application uses cosine similarity to rank the results, displaying the most relevant tickets first. Click the screenshot to see the exact search phrase and the results it returns. <\/p>\n<p><a href=\"https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/scr_semantic_search.png\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/scr_semantic_search-1024x550.png\" alt=\"\" width=\"810\" height=\"435\" class=\"alignnone size-large wp-image-3531\" srcset=\"https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/scr_semantic_search-1024x550.png 1024w, https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/scr_semantic_search-600x323.png 600w, https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/scr_semantic_search-768x413.png 768w, https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/scr_semantic_search-1536x826.png 1536w, https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/scr_semantic_search.png 1920w\" sizes=\"auto, (max-width: 810px) 100vw, 810px\" \/><\/a><\/p>\n<p><strong>How the search works:<\/strong><\/p>\n<ol>\n<li>Each ticket&#8217;s subject and description are converted into an embedding vector.<\/li>\n<li>The vector is stored in the database with the ticket.<\/li>\n<li>The user&#8217;s search phrase is converted into a vector with the same embedding model.<\/li>\n<li>The generated PHP application calculates cosine similarity between the query vector and the stored ticket vectors.<\/li>\n<li>The most relevant tickets are returned in similarity order.<\/li>\n<\/ol>\n<p>When a ticket is added or its searchable text changes, its subject and description are sent to the OpenAI Embeddings API. During a normal search, only the user&#8217;s search phrase is sent. Existing ticket vectors remain in the local database and are reused. OpenAI creates the vectors but does not search the ticket table; the similarity calculations happen locally in the generated PHP application.<\/p>\n<h3>1. Creating the support tickets table<\/h3>\n<p>Create the following MySQL table and add it to the PHPRunner project.<\/p>\n<div class=\"my-syntax-highlighter\">\n<pre><textarea id=\"mshighlighter\" class=\"mshighlighter\" language=\"sql\" name=\"mshighlighter\" >\r\nCREATE TABLE `support_tickets` (\r\n    `ticket_id` int NOT NULL AUTO_INCREMENT,\r\n    `ticket_code` varchar(16) NOT NULL,\r\n    `subject` varchar(255) NOT NULL,\r\n    `description` longtext NOT NULL,\r\n    `customer_name` varchar(100) NOT NULL,\r\n    `customer_email` varchar(150) NOT NULL,\r\n    `category` varchar(60) NOT NULL,\r\n    `topic` varchar(80) NOT NULL,\r\n    `status` varchar(30) NOT NULL DEFAULT 'Open',\r\n    `priority` varchar(20) NOT NULL DEFAULT 'Normal',\r\n    `channel` varchar(20) NOT NULL DEFAULT 'Web Portal',\r\n    `created_at` datetime NOT NULL,\r\n    `updated_at` datetime NOT NULL,\r\n    `embedding_json` json NULL,\r\n    `embedding_model` varchar(64) NULL,\r\n    `embedding_sha256` varchar(64) NULL,\r\n    `embedded_at` datetime NULL,\r\n    PRIMARY KEY (`ticket_id`)\r\n);<\/textarea><\/pre>\n<\/div>\n<p>The optional sample SQL download creates this table and adds 500 support tickets with their precomputed embeddings.<\/p>\n<p><a href=\"https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/support_tickets_500_with_embeddings.zip\">support_tickets_500_with_embeddings<\/a><\/p>\n<p>Open the table&#8217;s <strong>SQL Query<\/strong> screen and add a calculated field named <strong>semantic_query<\/strong>:<\/p>\n<div class=\"my-syntax-highlighter\">\n<pre><textarea id=\"mshighlighter\" class=\"mshighlighter\" language=\"sql\" name=\"mshighlighter\" >\r\nSELECT\r\n    ticket_id,\r\n    ticket_code,\r\n    subject,\r\n    description,\r\n    customer_name,\r\n    customer_email,\r\n    category,\r\n    topic,\r\n    status,\r\n    priority,\r\n    channel,\r\n    created_at,\r\n    updated_at,\r\n    embedding_json,\r\n    embedding_model,\r\n    embedding_sha256,\r\n    embedded_at,\r\n    '' AS semantic_query\r\nFROM support_tickets<\/textarea><\/pre>\n<\/div>\n<p>The calculated field gives PHPRunner a search condition that we can replace with the IDs returned by semantic search. Do not display <strong>semantic_query<\/strong> or the four embedding fields on the generated pages. On the List page, keep fields such as <strong>subject<\/strong> and <strong>description<\/strong> visible so readers can evaluate the results.<\/p>\n<p>Turn off &#8216;Highlight search results&#8217;. Semantic matches often use different words, so ordinary keyword highlighting is not useful here.<\/p>\n<h3>2. Adding the embedding functions<\/h3>\n<p>Create an API key at <a href=\"https:\/\/platform.openai.com\/api-keys\" target=\"_blank\" rel=\"noopener\">platform.openai.com\/api-keys<\/a>. In the code below, replace <strong>YOUR_OPENAI_API_KEY<\/strong> with that key. The key is stored in a server-side session variable and is never sent to browser-side JavaScript.<\/p>\n<p>Open <strong>Events &#8211; Global events &#8211; After App Init<\/strong> and add the following code:<\/p>\n<div class=\"my-syntax-highlighter\">\n<pre><textarea id=\"mshighlighter\" class=\"mshighlighter\" language=\"php\" name=\"mshighlighter\" >\r\nif (!defined(\"DC_EMBEDDING_MODEL\")) {\r\n    define(\"DC_EMBEDDING_MODEL\", \"text-embedding-3-small\");\r\n    define(\"DC_EMBEDDING_DIMENSIONS\", 1536);\r\n}\r\n\r\n$_SESSION[\"DEVCLUB_OPENAI_API_KEY\"] = \"YOUR_OPENAI_API_KEY\";\r\n\r\nif (!function_exists(\"dcTicketText\")) {\r\n    function dcTicketText($subject, $description)\r\n    {\r\n        return \"Subject: \" . (string)$subject .\r\n            \"\\nDescription: \" . (string)$description;\r\n    }\r\n}\r\n\r\nif (!function_exists(\"dcEmbeddingVector\")) {\r\n    function dcEmbeddingVector($text)\r\n    {\r\n        $apiKey = trim((string)($_SESSION[\"DEVCLUB_OPENAI_API_KEY\"] ?? \"\"));\r\n\r\n        if ($apiKey === \"\" || $apiKey === \"YOUR_OPENAI_API_KEY\") {\r\n            throw new RuntimeException(\r\n                \"OpenAI API key is missing from the session.\"\r\n            );\r\n        }\r\n\r\n        if (!function_exists(\"curl_init\")) {\r\n            throw new RuntimeException(\"The PHP cURL extension is required.\");\r\n        }\r\n\r\n        $request = json_encode(array(\r\n            \"model\" => DC_EMBEDDING_MODEL,\r\n            \"input\" => (string)$text,\r\n            \"encoding_format\" => \"float\"\r\n        ), JSON_INVALID_UTF8_SUBSTITUTE);\r\n\r\n        if ($request === false) {\r\n            throw new RuntimeException(\"Cannot create the OpenAI request.\");\r\n        }\r\n\r\n        $ch = curl_init(\"https:\/\/api.openai.com\/v1\/embeddings\");\r\n\r\n        if ($ch === false) {\r\n            throw new RuntimeException(\"Cannot initialize the OpenAI request.\");\r\n        }\r\n\r\n        curl_setopt_array($ch, array(\r\n            CURLOPT_POST => true,\r\n            CURLOPT_POSTFIELDS => $request,\r\n            CURLOPT_HTTPHEADER => array(\r\n                \"Authorization: Bearer \" . $apiKey,\r\n                \"Content-Type: application\/json\",\r\n                \"Accept: application\/json\"\r\n            ),\r\n            CURLOPT_RETURNTRANSFER => true,\r\n            CURLOPT_CONNECTTIMEOUT => 15,\r\n            CURLOPT_TIMEOUT => 45,\r\n            CURLOPT_SSL_VERIFYPEER => true\r\n        ));\r\n\r\n        $response = curl_exec($ch);\r\n        $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);\r\n        $curlError = curl_error($ch);\r\n        curl_close($ch);\r\n\r\n        if ($response === false) {\r\n            throw new RuntimeException(\r\n                \"OpenAI connection failed: \" . $curlError\r\n            );\r\n        }\r\n\r\n        $body = json_decode($response, true);\r\n\r\n        if ($httpCode !== 200) {\r\n            $detail = isset($body[\"error\"][\"message\"])\r\n                ? (string)$body[\"error\"][\"message\"]\r\n                : \"Unexpected HTTP response\";\r\n            throw new RuntimeException(\r\n                \"OpenAI HTTP \" . $httpCode . \": \" . substr($detail, 0, 300)\r\n            );\r\n        }\r\n\r\n        $vector = $body[\"data\"][0][\"embedding\"] ?? null;\r\n\r\n        if (!is_array($vector) || count($vector) !== DC_EMBEDDING_DIMENSIONS) {\r\n            throw new RuntimeException(\r\n                \"OpenAI returned an embedding with an unexpected length.\"\r\n            );\r\n        }\r\n\r\n        foreach ($vector as $value) {\r\n            if (!is_numeric($value) || !is_finite((float)$value)) {\r\n                throw new RuntimeException(\r\n                    \"OpenAI returned an invalid embedding value.\"\r\n                );\r\n            }\r\n        }\r\n\r\n        return $vector;\r\n    }\r\n}\r\n\r\nif (!function_exists(\"dcTicketEmbeddingFields\")) {\r\n    function dcTicketEmbeddingFields($subject, $description)\r\n    {\r\n        $text = dcTicketText($subject, $description);\r\n        $vector = dcEmbeddingVector($text);\r\n        $vectorJson = json_encode($vector, JSON_PRESERVE_ZERO_FRACTION);\r\n\r\n        if ($vectorJson === false) {\r\n            throw new RuntimeException(\"Cannot save the returned embedding.\");\r\n        }\r\n\r\n        return array(\r\n            \"embedding_json\" => $vectorJson,\r\n            \"embedding_model\" => DC_EMBEDDING_MODEL,\r\n            \"embedding_sha256\" => hash(\"sha256\", $text),\r\n            \"embedded_at\" => gmdate(\"Y-m-d H:i:s\")\r\n        );\r\n    }\r\n}<\/textarea><\/pre>\n<\/div>\n<p>The same text format must be used whenever a ticket embedding is created or checked. The hash lets the search code ignore an embedding if its subject or description no longer matches the stored vector.<\/p>\n<p>The code contains a placeholder, not a real API key. Do not publish or distribute a populated key in project or generated application files. In production, keep the real key in server-side configuration outside the public application files, then assign that value to the session variable in <strong>After App Init<\/strong>.<\/p>\n<p>Stored ticket vectors and search vectors must use the same embedding model and dimensions. Different models generally produce incompatible vector spaces. Preserve the original ticket text so the vectors can be regenerated if the model is replaced or discontinued. Regeneration uses the Embeddings API again and incurs API usage costs, although those costs are generally modest for small datasets.<\/p>\n<h3>3. Generating an embedding when a ticket is added<\/h3>\n<p>Open the <strong>support_tickets Add page &#8211; Before record added<\/strong> event and add this code:<\/p>\n<div class=\"my-syntax-highlighter\">\n<pre><textarea id=\"mshighlighter\" class=\"mshighlighter\" language=\"php\" name=\"mshighlighter\" >\r\ntry {\r\n    $subject = trim((string)($values[\"subject\"] ?? \"\"));\r\n    $description = trim((string)($values[\"description\"] ?? \"\"));\r\n\r\n    if ($subject === \"\" || $description === \"\") {\r\n        throw new RuntimeException(\"Subject and description are required.\");\r\n    }\r\n\r\n    $embeddingFields = dcTicketEmbeddingFields($subject, $description);\r\n\r\n    foreach ($embeddingFields as $field => $value) {\r\n        $values[$field] = $value;\r\n    }\r\n} catch (Throwable $e) {\r\n    $message = \"Ticket was not saved: \" . $e->getMessage();\r\n    return false;\r\n}\r\n\r\nreturn true;<\/textarea><\/pre>\n<\/div>\n<p>The request is made on the server before the record is saved. The API key and returned vector are never sent to the browser. If the API request fails, the event returns <strong>false<\/strong>, so the ticket is not added with a missing or partial embedding.<\/p>\n<h3>4. Refreshing the embedding when a ticket is edited<\/h3>\n<p>Open the <strong>support_tickets Edit page &#8211; Before record updated<\/strong> event and add this code:<\/p>\n<div class=\"my-syntax-highlighter\">\n<pre><textarea id=\"mshighlighter\" class=\"mshighlighter\" language=\"php\" name=\"mshighlighter\" >\r\ntry {\r\n    $subject = trim((string)($values[\"subject\"] ?? $oldvalues[\"subject\"]));\r\n    $description = trim((string)(\r\n        $values[\"description\"] ?? $oldvalues[\"description\"]\r\n    ));\r\n\r\n    if ($subject === \"\" || $description === \"\") {\r\n        throw new RuntimeException(\"Subject and description are required.\");\r\n    }\r\n\r\n    $oldText = dcTicketText(\r\n        $oldvalues[\"subject\"],\r\n        $oldvalues[\"description\"]\r\n    );\r\n    $newText = dcTicketText($subject, $description);\r\n\r\n    if ($newText !== $oldText) {\r\n        $embeddingFields = dcTicketEmbeddingFields($subject, $description);\r\n\r\n        foreach ($embeddingFields as $field => $value) {\r\n            $values[$field] = $value;\r\n        }\r\n    }\r\n} catch (Throwable $e) {\r\n    $message = \"Ticket was not saved: \" . $e->getMessage();\r\n    return false;\r\n}\r\n\r\nreturn true;<\/textarea><\/pre>\n<\/div>\n<p>The API is called only when <strong>subject<\/strong> or <strong>description<\/strong> changes. Editing another field keeps the current embedding. If the API request fails, returning <strong>false<\/strong> prevents the update, so the previous ticket text and embedding remain unchanged.<\/p>\n<h3>5. Populating embeddings for existing records<\/h3>\n<p>New and edited tickets now receive embeddings automatically. If you imported the supplied sample SQL, its 500 records already include embeddings. Use the following utility only for other existing records that do not have embeddings yet.<\/p>\n<p>Copy the supplied <strong>populate_embeddings.php<\/strong> file to the root of the generated PHPRunner application, next to <strong>support_tickets_list.php<\/strong>. Open the file in a browser and click &#8216;Start \/ Resume&#8217;. It processes 20 tickets per request, skips current embeddings, and continues until all records are ready.<\/p>\n<p>The utility allows access only from localhost or by a PHPRunner administrator. Delete it from the generated application after the initial population is complete.<\/p>\n<h3>6. Replacing the basic search with semantic search<\/h3>\n<p>Open the <strong>support_tickets List page &#8211; Before process<\/strong> event and add the following code:<\/p>\n<div class=\"my-syntax-highlighter\">\n<pre><textarea id=\"mshighlighter\" class=\"mshighlighter\" language=\"php\" name=\"mshighlighter\" >\r\n$srch = SearchClause::getSearchObject(\"support_tickets\");\r\n$phrase = trim((string)$srch->getAllFieldsSearchValue());\r\n\r\nif ($phrase !== \"\") {\r\n    $cacheKey = hash(\"sha256\", DC_EMBEDDING_MODEL . \"\\n\" . $phrase);\r\n    $cached = $_SESSION[\"DC_SEMANTIC_CACHE\"] ?? null;\r\n    $ids = null;\r\n\r\n    unset($_SESSION[\"DC_SEMANTIC_ERROR\"]);\r\n\r\n    $cacheValid = is_array($cached)\r\n        && isset($cached[\"key\"], $cached[\"time\"], $cached[\"ids\"])\r\n        && $cached[\"key\"] === $cacheKey;\r\n\r\n    if ($cacheValid) {\r\n        $cacheAge = time() - (int)$cached[\"time\"];\r\n        $cacheValid = $cacheAge >= 0 && !($cacheAge > 300);\r\n    }\r\n\r\n    if ($cacheValid) {\r\n        $ids = $cached[\"ids\"];\r\n    }\r\n\r\n    if ($ids === null) {\r\n        try {\r\n            if (strlen($phrase) > 1000) {\r\n                throw new RuntimeException(\r\n                    \"Search phrase is too long. The limit is 1000 bytes.\"\r\n                );\r\n            }\r\n\r\n            $queryVector = dcEmbeddingVector($phrase);\r\n            $queryNorm = 0.0;\r\n\r\n            foreach ($queryVector as $value) {\r\n                $number = (float)$value;\r\n                $queryNorm += $number * $number;\r\n            }\r\n\r\n            if ($queryNorm === 0.0) {\r\n                throw new RuntimeException(\"The query vector has zero length.\");\r\n            }\r\n\r\n            $sql = DB::PrepareSQL(\r\n                \"SELECT ticket_id, subject, description, \" .\r\n                \"embedding_json, embedding_sha256 \" .\r\n                \"FROM support_tickets \" .\r\n                \"WHERE embedding_json IS NOT NULL \" .\r\n                \"AND embedding_model=':1'\",\r\n                DC_EMBEDDING_MODEL\r\n            );\r\n            $rs = DB::Query($sql);\r\n\r\n            if (!$rs) {\r\n                throw new RuntimeException(\r\n                    \"Database query failed: \" . DB::LastError()\r\n                );\r\n            }\r\n\r\n            $scores = array();\r\n\r\n            while ($row = $rs->fetchAssoc()) {\r\n                $ticketText = dcTicketText(\r\n                    $row[\"subject\"],\r\n                    $row[\"description\"]\r\n                );\r\n                $expectedHash = hash(\"sha256\", $ticketText);\r\n\r\n                if (!hash_equals(\r\n                    (string)$row[\"embedding_sha256\"],\r\n                    $expectedHash\r\n                )) {\r\n                    continue;\r\n                }\r\n\r\n                $vector = json_decode(\r\n                    (string)$row[\"embedding_json\"],\r\n                    true\r\n                );\r\n\r\n                if (!is_array($vector)\r\n                    || count($vector) !== DC_EMBEDDING_DIMENSIONS) {\r\n                    continue;\r\n                }\r\n\r\n                $dotProduct = 0.0;\r\n                $vectorNorm = 0.0;\r\n\r\n                foreach ($vector as $index => $value) {\r\n                    if (!is_numeric($value)) {\r\n                        continue 2;\r\n                    }\r\n\r\n                    $number = (float)$value;\r\n\r\n                    if (!is_finite($number)) {\r\n                        continue 2;\r\n                    }\r\n\r\n                    $dotProduct += (float)$queryVector[$index] * $number;\r\n                    $vectorNorm += $number * $number;\r\n                }\r\n\r\n                if ($vectorNorm > 0.0) {\r\n                    $scores[(int)$row[\"ticket_id\"]] =\r\n                        $dotProduct \/ sqrt($queryNorm * $vectorNorm);\r\n                }\r\n            }\r\n\r\n            arsort($scores, SORT_NUMERIC);\r\n            $ids = array_slice(array_keys($scores), 0, 30);\r\n\r\n            $_SESSION[\"DC_SEMANTIC_CACHE\"] = array(\r\n                \"key\" => $cacheKey,\r\n                \"time\" => time(),\r\n                \"ids\" => $ids\r\n            );\r\n        } catch (Throwable $e) {\r\n            $_SESSION[\"DC_SEMANTIC_ERROR\"] = $e->getMessage();\r\n            $ids = array();\r\n            unset($_SESSION[\"DC_SEMANTIC_CACHE\"]);\r\n        }\r\n    }\r\n\r\n    $safeIds = array_map(\"intval\", $ids);\r\n    $condition = $safeIds\r\n        ? \"ticket_id IN (\" . implode(\",\", $safeIds) . \")\"\r\n        : \"1=0\";\r\n\r\n    $srch->setSearchSQL(\"semantic_query\", $condition);\r\n}<\/textarea><\/pre>\n<\/div>\n<p>The code sends only the search phrase to OpenAI. The ticket database remains on your server. PHPRunner reads the stored vectors, calculates cosine similarity in PHP, and keeps the 30 highest-scoring ticket IDs. Repeating the same query within five minutes uses the session cache instead of creating another embedding.<\/p>\n<h3>7. Sorting tickets by similarity<\/h3>\n<p>The previous event filters the grid to the 30 matching IDs. MySQL still needs an explicit sort expression to display them in similarity order.<\/p>\n<p>Open the <strong>support_tickets List page &#8211; Before SQL query<\/strong> event and add this code:<\/p>\n<div class=\"my-syntax-highlighter\">\n<pre><textarea id=\"mshighlighter\" class=\"mshighlighter\" language=\"php\" name=\"mshighlighter\" >\r\n$srch = SearchClause::getSearchObject(\"support_tickets\");\r\n$phrase = trim((string)$srch->getAllFieldsSearchValue());\r\n$cached = $_SESSION[\"DC_SEMANTIC_CACHE\"] ?? null;\r\n$cacheKey = hash(\"sha256\", DC_EMBEDDING_MODEL . \"\\n\" . $phrase);\r\n\r\n$cacheValid = $phrase !== \"\"\r\n    && is_array($cached)\r\n    && isset($cached[\"key\"], $cached[\"time\"], $cached[\"ids\"])\r\n    && $cached[\"key\"] === $cacheKey\r\n    && !empty($cached[\"ids\"]);\r\n\r\nif ($cacheValid) {\r\n    $cacheAge = time() - (int)$cached[\"time\"];\r\n    $cacheValid = $cacheAge >= 0 && !($cacheAge > 300);\r\n}\r\n\r\nif ($cacheValid) {\r\n    $safeIds = array_map(\"intval\", $cached[\"ids\"]);\r\n    $strOrderBy = \" ORDER BY FIELD(ticket_id, \" .\r\n        implode(\",\", $safeIds) . \")\";\r\n}<\/textarea><\/pre>\n<\/div>\n<p>The MySQL <strong>FIELD()<\/strong> function follows the ID order produced by the similarity scores. The most relevant ticket appears first instead of the records being sorted by their numeric IDs.<\/p>\n<h3>8. Displaying semantic-search errors<\/h3>\n<p>Open the <strong>support_tickets List page &#8211; Before display<\/strong> event and add this code:<\/p>\n<div class=\"my-syntax-highlighter\">\n<pre><textarea id=\"mshighlighter\" class=\"mshighlighter\" language=\"php\" name=\"mshighlighter\" >\r\nif (!empty($_SESSION[\"DC_SEMANTIC_ERROR\"])) {\r\n    $message = \"Semantic search failed: \" .\r\n        $_SESSION[\"DC_SEMANTIC_ERROR\"];\r\n\r\n    $xt->assign(\r\n        \"message\",\r\n        htmlspecialchars(\r\n            $message,\r\n            ENT_QUOTES | ENT_SUBSTITUTE,\r\n            \"UTF-8\"\r\n        )\r\n    );\r\n}<\/textarea><\/pre>\n<\/div>\n<p>Rebuild the application, populate the existing embeddings, and enter a natural-language phrase in the List page&#8217;s basic search box. The grid should return the 30 tickets whose subject and description are closest in meaning, with the strongest match at the top.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Build semantic search in PHPRunner with OpenAI embeddings, cosine similarity, cached queries, and MySQL result ranking.<\/p>\n","protected":false},"author":3,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1,8],"tags":[],"class_list":["post-3523","post","type-post","status-publish","format-standard","hentry","category-php-category","category-tutorials"],"_links":{"self":[{"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/posts\/3523","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/users\/3"}],"replies":[{"embeddable":true,"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/comments?post=3523"}],"version-history":[{"count":7,"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/posts\/3523\/revisions"}],"predecessor-version":[{"id":3533,"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/posts\/3523\/revisions\/3533"}],"wp:attachment":[{"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/media?parent=3523"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/categories?post=3523"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/tags?post=3523"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}