| 1 |
<?php |
| 2 |
|
| 3 |
namespace wpforo\classes; |
| 4 |
|
| 5 |
// Exit if accessed directly |
| 6 |
if ( ! defined( 'ABSPATH' ) ) exit; |
| 7 |
|
| 8 |
/** |
| 9 |
* Local Vector Storage for AI Embeddings |
| 10 |
* |
| 11 |
* Handles storing and searching embeddings in WordPress MySQL database |
| 12 |
* as an alternative to cloud-based vector storage (gVectors AI Services on AWS Cloud). |
| 13 |
* |
| 14 |
* Features: |
| 15 |
* - Binary packed vector storage (efficient BLOB) |
| 16 |
* - Pre-computed magnitudes for fast cosine similarity |
| 17 |
* - Similarity cache with TTL |
| 18 |
* - PHP-based cosine similarity calculation |
| 19 |
* |
| 20 |
* @since 3.0.0 |
| 21 |
*/ |
| 22 |
class VectorStorageLocal { |
| 23 |
|
| 24 |
/** |
| 25 |
* Default cache TTL in seconds (1 hour) |
| 26 |
*/ |
| 27 |
const CACHE_TTL = 3600; |
| 28 |
|
| 29 |
/** |
| 30 |
* Maximum similar items to cache per source |
| 31 |
*/ |
| 32 |
const MAX_CACHED_SIMILAR = 20; |
| 33 |
|
| 34 |
/** |
| 35 |
* Post count threshold for performance warning |
| 36 |
*/ |
| 37 |
const PERFORMANCE_THRESHOLD = 5000000; |
| 38 |
|
| 39 |
/** |
| 40 |
* Default vector dimensions |
| 41 |
*/ |
| 42 |
const DEFAULT_DIMENSIONS = 1024; |
| 43 |
|
| 44 |
/** |
| 45 |
* Constructor |
| 46 |
*/ |
| 47 |
public function __construct() { |
| 48 |
// Note: Cron registration moved to VectorStorageManager::register_cron_hooks() |
| 49 |
// This ensures the cleanup callback is available when the cron fires |
| 50 |
} |
| 51 |
|
| 52 |
/** |
| 53 |
* Store an embedding vector for a post |
| 54 |
* |
| 55 |
* @param int $topicid Topic ID |
| 56 |
* @param int $postid Post ID (chunk) |
| 57 |
* @param int $forumid Forum ID |
| 58 |
* @param int $userid User ID |
| 59 |
* @param array $vector Float array of embeddings |
| 60 |
* @param string $content_hash MD5 hash of content |
| 61 |
* @param string $content_preview Content preview (full chunk text) |
| 62 |
* @param string $model_name Model used for embedding |
| 63 |
* @return int|false Insert ID or false on failure |
| 64 |
*/ |
| 65 |
public function store_embedding( $topicid, $postid, $forumid, $userid, $vector, $content_hash, $content_preview = '', $model_name = 'amazon.titan-embed-text-v2', $content_type = 'forum' ) { |
| 66 |
global $wpdb; |
| 67 |
|
| 68 |
if ( empty( $vector ) || ! is_array( $vector ) ) { |
| 69 |
return false; |
| 70 |
} |
| 71 |
|
| 72 |
$dimensions = count( $vector ); |
| 73 |
$magnitude = $this->calculate_magnitude( $vector ); |
| 74 |
|
| 75 |
// Normalize vector for faster similarity computation |
| 76 |
$normalized_vector = $this->normalize_vector( $vector, $magnitude ); |
| 77 |
$binary_vector = $this->pack_vector( $normalized_vector ); |
| 78 |
|
| 79 |
// Check if embedding already exists |
| 80 |
$existing = $wpdb->get_var( $wpdb->prepare( |
| 81 |
"SELECT id FROM " . WPF()->tables->ai_embeddings . " WHERE postid = %d AND model_name = %s", |
| 82 |
$postid, |
| 83 |
$model_name |
| 84 |
) ); |
| 85 |
|
| 86 |
$data = [ |
| 87 |
'topicid' => $topicid, |
| 88 |
'postid' => $postid, |
| 89 |
'forumid' => $forumid, |
| 90 |
'userid' => $userid, |
| 91 |
'embedding_vector' => $binary_vector, |
| 92 |
'vector_dimensions' => $dimensions, |
| 93 |
'vector_magnitude' => 1.0, // Normalized vectors have magnitude 1 |
| 94 |
'model_name' => $model_name, |
| 95 |
'content_hash' => $content_hash, |
| 96 |
'content_preview' => $content_preview, |
| 97 |
'content_type' => $content_type, |
| 98 |
]; |
| 99 |
|
| 100 |
if ( $existing ) { |
| 101 |
// Update existing |
| 102 |
$result = $wpdb->update( |
| 103 |
WPF()->tables->ai_embeddings, |
| 104 |
$data, |
| 105 |
[ 'id' => $existing ], |
| 106 |
[ '%d', '%d', '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s' ], |
| 107 |
[ '%d' ] |
| 108 |
); |
| 109 |
|
| 110 |
// Invalidate cache for this post |
| 111 |
$this->invalidate_cache( 'post', $postid ); |
| 112 |
|
| 113 |
return $existing; |
| 114 |
} else { |
| 115 |
// Insert new |
| 116 |
$result = $wpdb->insert( |
| 117 |
WPF()->tables->ai_embeddings, |
| 118 |
$data, |
| 119 |
[ '%d', '%d', '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s' ] |
| 120 |
); |
| 121 |
|
| 122 |
return $result ? $wpdb->insert_id : false; |
| 123 |
} |
| 124 |
} |
| 125 |
|
| 126 |
/** |
| 127 |
* Get embedding for a post |
| 128 |
* |
| 129 |
* @param int $postid Post ID |
| 130 |
* @param string $model_name Model name |
| 131 |
* @return array|null Embedding data or null |
| 132 |
*/ |
| 133 |
public function get_embedding( $postid, $model_name = 'amazon.titan-embed-text-v2' ) { |
| 134 |
global $wpdb; |
| 135 |
|
| 136 |
$row = $wpdb->get_row( $wpdb->prepare( |
| 137 |
"SELECT * FROM " . WPF()->tables->ai_embeddings . " WHERE postid = %d AND model_name = %s", |
| 138 |
$postid, |
| 139 |
$model_name |
| 140 |
), ARRAY_A ); |
| 141 |
|
| 142 |
if ( ! $row ) { |
| 143 |
return null; |
| 144 |
} |
| 145 |
|
| 146 |
$row['vector'] = $this->unpack_vector( $row['embedding_vector'] ); |
| 147 |
unset( $row['embedding_vector'] ); |
| 148 |
|
| 149 |
return $row; |
| 150 |
} |
| 151 |
|
| 152 |
/** |
| 153 |
* Delete embedding for a post |
| 154 |
* |
| 155 |
* @param int $postid Post ID |
| 156 |
* @return bool Success |
| 157 |
*/ |
| 158 |
public function delete_embedding( $postid ) { |
| 159 |
global $wpdb; |
| 160 |
|
| 161 |
$result = $wpdb->delete( |
| 162 |
WPF()->tables->ai_embeddings, |
| 163 |
[ 'postid' => $postid ], |
| 164 |
[ '%d' ] |
| 165 |
); |
| 166 |
|
| 167 |
// Also delete from cache |
| 168 |
$this->invalidate_cache( 'post', $postid ); |
| 169 |
|
| 170 |
return $result !== false; |
| 171 |
} |
| 172 |
|
| 173 |
/** |
| 174 |
* Delete all embeddings for a topic |
| 175 |
* |
| 176 |
* @param int $topicid Topic ID |
| 177 |
* @return int Number of deleted rows |
| 178 |
*/ |
| 179 |
public function delete_topic_embeddings( $topicid ) { |
| 180 |
global $wpdb; |
| 181 |
|
| 182 |
// Get all postids first for cache invalidation |
| 183 |
$postids = $wpdb->get_col( $wpdb->prepare( |
| 184 |
"SELECT postid FROM " . WPF()->tables->ai_embeddings . " WHERE topicid = %d", |
| 185 |
$topicid |
| 186 |
) ); |
| 187 |
|
| 188 |
$result = $wpdb->delete( |
| 189 |
WPF()->tables->ai_embeddings, |
| 190 |
[ 'topicid' => $topicid ], |
| 191 |
[ '%d' ] |
| 192 |
); |
| 193 |
|
| 194 |
// Invalidate cache for all posts |
| 195 |
foreach ( $postids as $postid ) { |
| 196 |
$this->invalidate_cache( 'post', $postid ); |
| 197 |
} |
| 198 |
|
| 199 |
return $result; |
| 200 |
} |
| 201 |
|
| 202 |
/** |
| 203 |
* Delete WordPress CPT embeddings from local storage. |
| 204 |
* |
| 205 |
* @param array|null $post_types Post types to delete (e.g., ['docs', 'page']). Null = all non-forum. |
| 206 |
* @param array|null $post_ids Specific post IDs to delete. |
| 207 |
* @return int Number of deleted rows. |
| 208 |
*/ |
| 209 |
public function delete_wp_embeddings( $post_types = null, $post_ids = null ) { |
| 210 |
global $wpdb; |
| 211 |
|
| 212 |
$table = WPF()->tables->ai_embeddings; |
| 213 |
|
| 214 |
if ( ! empty( $post_ids ) ) { |
| 215 |
// Delete specific post IDs |
| 216 |
$placeholders = implode( ', ', array_fill( 0, count( $post_ids ), '%d' ) ); |
| 217 |
$deleted = $wpdb->query( |
| 218 |
$wpdb->prepare( |
| 219 |
"DELETE FROM $table WHERE postid IN ($placeholders) AND content_type != 'forum'", |
| 220 |
...$post_ids |
| 221 |
) |
| 222 |
); |
| 223 |
} elseif ( ! empty( $post_types ) ) { |
| 224 |
// Delete by content_type |
| 225 |
$placeholders = implode( ', ', array_fill( 0, count( $post_types ), '%s' ) ); |
| 226 |
$deleted = $wpdb->query( |
| 227 |
$wpdb->prepare( |
| 228 |
"DELETE FROM $table WHERE content_type IN ($placeholders)", |
| 229 |
...$post_types |
| 230 |
) |
| 231 |
); |
| 232 |
} else { |
| 233 |
// Delete all non-forum embeddings |
| 234 |
$deleted = $wpdb->query( |
| 235 |
"DELETE FROM $table WHERE content_type != 'forum'" |
| 236 |
); |
| 237 |
} |
| 238 |
|
| 239 |
// Clear the embeddings cache |
| 240 |
$wpdb->query( "DELETE FROM " . WPF()->tables->ai_embeddings_cache ); |
| 241 |
|
| 242 |
return (int) $deleted; |
| 243 |
} |
| 244 |
|
| 245 |
/** |
| 246 |
* Semantic search using cosine similarity |
| 247 |
* |
| 248 |
* @param array $query_vector Query embedding vector |
| 249 |
* @param int $limit Maximum results |
| 250 |
* @param array $filters Optional filters: forumid, userid, etc. |
| 251 |
* @return array Search results with scores |
| 252 |
*/ |
| 253 |
public function semantic_search( $query_vector, $limit = 10, $filters = [] ) { |
| 254 |
global $wpdb; |
| 255 |
|
| 256 |
if ( empty( $query_vector ) ) { |
| 257 |
return []; |
| 258 |
} |
| 259 |
|
| 260 |
// Extract minimum score threshold from filters (0-1 scale, 0 = no filtering) |
| 261 |
$min_score = isset( $filters['min_score'] ) ? (float) $filters['min_score'] : 0; |
| 262 |
|
| 263 |
// Normalize query vector |
| 264 |
$query_magnitude = $this->calculate_magnitude( $query_vector ); |
| 265 |
$normalized_query = $this->normalize_vector( $query_vector, $query_magnitude ); |
| 266 |
|
| 267 |
// Build WHERE clause for filters |
| 268 |
$where = []; |
| 269 |
$values = []; |
| 270 |
|
| 271 |
if ( ! empty( $filters['forumid'] ) ) { |
| 272 |
$where[] = 'forumid = %d'; |
| 273 |
$values[] = (int) $filters['forumid']; |
| 274 |
} |
| 275 |
|
| 276 |
if ( ! empty( $filters['forumids'] ) && is_array( $filters['forumids'] ) ) { |
| 277 |
$placeholders = implode( ',', array_fill( 0, count( $filters['forumids'] ), '%d' ) ); |
| 278 |
$where[] = "forumid IN ($placeholders)"; |
| 279 |
$values = array_merge( $values, array_map( 'intval', $filters['forumids'] ) ); |
| 280 |
} |
| 281 |
|
| 282 |
if ( ! empty( $filters['userid'] ) ) { |
| 283 |
$where[] = 'userid = %d'; |
| 284 |
$values[] = (int) $filters['userid']; |
| 285 |
} |
| 286 |
|
| 287 |
if ( ! empty( $filters['exclude_topicids'] ) && is_array( $filters['exclude_topicids'] ) ) { |
| 288 |
$placeholders = implode( ',', array_fill( 0, count( $filters['exclude_topicids'] ), '%d' ) ); |
| 289 |
$where[] = "topicid NOT IN ($placeholders)"; |
| 290 |
$values = array_merge( $values, array_map( 'intval', $filters['exclude_topicids'] ) ); |
| 291 |
} |
| 292 |
|
| 293 |
$where_sql = ! empty( $where ) ? 'WHERE ' . implode( ' AND ', $where ) : ''; |
| 294 |
|
| 295 |
// Process embeddings in batches to avoid memory exhaustion on large datasets. |
| 296 |
// Each batch loads ~2000 rows (~9MB with 1024-dim vectors), keeping peak memory safe. |
| 297 |
$batch_size = 2000; |
| 298 |
$offset = 0; |
| 299 |
$top_results = []; |
| 300 |
|
| 301 |
$table = WPF()->tables->ai_embeddings; |
| 302 |
|
| 303 |
while ( true ) { |
| 304 |
$batch_query = "SELECT id, topicid, postid, forumid, userid, embedding_vector, vector_dimensions, content_preview, content_type |
| 305 |
FROM {$table} {$where_sql} |
| 306 |
ORDER BY id ASC LIMIT %d OFFSET %d"; |
| 307 |
$batch_values = array_merge( $values, [ $batch_size, $offset ] ); |
| 308 |
$batch_query = $wpdb->prepare( $batch_query, $batch_values ); |
| 309 |
|
| 310 |
$rows = $wpdb->get_results( $batch_query, ARRAY_A ); |
| 311 |
|
| 312 |
if ( empty( $rows ) ) { |
| 313 |
break; |
| 314 |
} |
| 315 |
|
| 316 |
// Calculate similarity for each embedding in this batch |
| 317 |
foreach ( $rows as $row ) { |
| 318 |
$stored_vector = $this->unpack_vector( $row['embedding_vector'] ); |
| 319 |
$similarity = $this->dot_product( $normalized_query, $stored_vector ); |
| 320 |
|
| 321 |
// Skip results below minimum score threshold |
| 322 |
if ( $min_score > 0 && $similarity < $min_score ) { |
| 323 |
continue; |
| 324 |
} |
| 325 |
|
| 326 |
$top_results[] = [ |
| 327 |
'id' => $row['id'], |
| 328 |
'topicid' => $row['topicid'], |
| 329 |
'postid' => $row['postid'], |
| 330 |
'forumid' => $row['forumid'], |
| 331 |
'userid' => $row['userid'], |
| 332 |
'similarity' => $similarity, |
| 333 |
'content_preview' => $row['content_preview'], |
| 334 |
'content_type' => $row['content_type'] ?? 'forum', |
| 335 |
]; |
| 336 |
} |
| 337 |
|
| 338 |
// Trim accumulated results to top N to bound memory growth |
| 339 |
if ( count( $top_results ) > $limit * 3 ) { |
| 340 |
usort( $top_results, function( $a, $b ) { |
| 341 |
return $b['similarity'] <=> $a['similarity']; |
| 342 |
} ); |
| 343 |
$top_results = array_slice( $top_results, 0, $limit ); |
| 344 |
} |
| 345 |
|
| 346 |
// If fewer rows than batch size, we've processed everything |
| 347 |
if ( count( $rows ) < $batch_size ) { |
| 348 |
break; |
| 349 |
} |
| 350 |
|
| 351 |
$offset += $batch_size; |
| 352 |
|
| 353 |
// Free batch memory before loading next batch |
| 354 |
unset( $rows ); |
| 355 |
} |
| 356 |
|
| 357 |
if ( empty( $top_results ) ) { |
| 358 |
return []; |
| 359 |
} |
| 360 |
|
| 361 |
// Final sort by similarity (descending) |
| 362 |
usort( $top_results, function( $a, $b ) { |
| 363 |
return $b['similarity'] <=> $a['similarity']; |
| 364 |
} ); |
| 365 |
|
| 366 |
// Return top N results |
| 367 |
return array_slice( $top_results, 0, $limit ); |
| 368 |
} |
| 369 |
|
| 370 |
/** |
| 371 |
* Find similar items with caching |
| 372 |
* |
| 373 |
* @param string $source_type 'topic' or 'post' |
| 374 |
* @param int $source_id Source item ID |
| 375 |
* @param int $limit Maximum results |
| 376 |
* @param bool $force_refresh Force cache refresh |
| 377 |
* @return array Similar items with scores |
| 378 |
*/ |
| 379 |
public function find_similar( $source_type, $source_id, $limit = 10, $force_refresh = false ) { |
| 380 |
// Check cache first |
| 381 |
if ( ! $force_refresh ) { |
| 382 |
$cached = $this->get_cached_similar( $source_type, $source_id, $limit ); |
| 383 |
if ( $cached !== null ) { |
| 384 |
return $cached; |
| 385 |
} |
| 386 |
} |
| 387 |
|
| 388 |
// Get source embedding |
| 389 |
if ( $source_type === 'topic' ) { |
| 390 |
// Get first post's embedding for topic |
| 391 |
global $wpdb; |
| 392 |
$first_postid = $wpdb->get_var( $wpdb->prepare( |
| 393 |
"SELECT postid FROM " . WPF()->tables->ai_embeddings . " WHERE topicid = %d ORDER BY postid ASC LIMIT 1", |
| 394 |
$source_id |
| 395 |
) ); |
| 396 |
if ( ! $first_postid ) { |
| 397 |
return []; |
| 398 |
} |
| 399 |
$embedding = $this->get_embedding( $first_postid ); |
| 400 |
} else { |
| 401 |
$embedding = $this->get_embedding( $source_id ); |
| 402 |
} |
| 403 |
|
| 404 |
if ( ! $embedding || empty( $embedding['vector'] ) ) { |
| 405 |
return []; |
| 406 |
} |
| 407 |
|
| 408 |
// Search for similar items |
| 409 |
$filters = [ |
| 410 |
'exclude_topicids' => [ $embedding['topicid'] ], // Exclude self |
| 411 |
]; |
| 412 |
|
| 413 |
$results = $this->semantic_search( $embedding['vector'], self::MAX_CACHED_SIMILAR, $filters ); |
| 414 |
|
| 415 |
// Group by topic (forum) or post (CPT) and take best match per group |
| 416 |
$by_group = []; |
| 417 |
foreach ( $results as $result ) { |
| 418 |
$content_type = $result['content_type'] ?? 'forum'; |
| 419 |
if ( $content_type !== 'forum' ) { |
| 420 |
// WordPress CPT: group by postid (topicid is 0 for all CPT) |
| 421 |
$group_key = 'wp_' . $result['postid']; |
| 422 |
} else { |
| 423 |
// Forum: group by topicid |
| 424 |
$group_key = 'topic_' . $result['topicid']; |
| 425 |
} |
| 426 |
if ( ! isset( $by_group[ $group_key ] ) || $result['similarity'] > $by_group[ $group_key ]['similarity'] ) { |
| 427 |
$by_group[ $group_key ] = $result; |
| 428 |
} |
| 429 |
} |
| 430 |
|
| 431 |
// Re-sort and limit |
| 432 |
$similar = array_values( $by_group ); |
| 433 |
usort( $similar, function( $a, $b ) { |
| 434 |
return $b['similarity'] <=> $a['similarity']; |
| 435 |
} ); |
| 436 |
$similar = array_slice( $similar, 0, self::MAX_CACHED_SIMILAR ); |
| 437 |
|
| 438 |
// Cache results |
| 439 |
$this->cache_similar( $source_type, $source_id, 'topic', $similar ); |
| 440 |
|
| 441 |
return array_slice( $similar, 0, $limit ); |
| 442 |
} |
| 443 |
|
| 444 |
/** |
| 445 |
* Get cached similar items |
| 446 |
* |
| 447 |
* @param string $source_type Source type |
| 448 |
* @param int $source_id Source ID |
| 449 |
* @param int $limit Maximum results |
| 450 |
* @return array|null Cached results or null if not cached/expired |
| 451 |
*/ |
| 452 |
private function get_cached_similar( $source_type, $source_id, $limit ) { |
| 453 |
global $wpdb; |
| 454 |
|
| 455 |
$now = current_time( 'mysql' ); |
| 456 |
|
| 457 |
$results = $wpdb->get_results( $wpdb->prepare( |
| 458 |
"SELECT similar_id, similarity_score |
| 459 |
FROM " . WPF()->tables->ai_embeddings_cache . " |
| 460 |
WHERE source_type = %s AND source_id = %d AND expires_at > %s |
| 461 |
ORDER BY rank_position ASC |
| 462 |
LIMIT %d", |
| 463 |
$source_type, |
| 464 |
$source_id, |
| 465 |
$now, |
| 466 |
$limit |
| 467 |
), ARRAY_A ); |
| 468 |
|
| 469 |
if ( empty( $results ) ) { |
| 470 |
return null; |
| 471 |
} |
| 472 |
|
| 473 |
// Enrich with topic data |
| 474 |
$enriched = []; |
| 475 |
foreach ( $results as $row ) { |
| 476 |
$enriched[] = [ |
| 477 |
'topicid' => (int) $row['similar_id'], |
| 478 |
'similarity' => (float) $row['similarity_score'], |
| 479 |
]; |
| 480 |
} |
| 481 |
|
| 482 |
return $enriched; |
| 483 |
} |
| 484 |
|
| 485 |
/** |
| 486 |
* Cache similar items |
| 487 |
* |
| 488 |
* @param string $source_type Source type |
| 489 |
* @param int $source_id Source ID |
| 490 |
* @param string $similar_type Similar item type |
| 491 |
* @param array $similar Similar items |
| 492 |
*/ |
| 493 |
private function cache_similar( $source_type, $source_id, $similar_type, $similar ) { |
| 494 |
global $wpdb; |
| 495 |
|
| 496 |
// Delete existing cache for this source |
| 497 |
$wpdb->delete( |
| 498 |
WPF()->tables->ai_embeddings_cache, |
| 499 |
[ |
| 500 |
'source_type' => $source_type, |
| 501 |
'source_id' => $source_id, |
| 502 |
], |
| 503 |
[ '%s', '%d' ] |
| 504 |
); |
| 505 |
|
| 506 |
// Insert new cache entries |
| 507 |
$expires_at = date( 'Y-m-d H:i:s', time() + self::CACHE_TTL ); |
| 508 |
|
| 509 |
foreach ( $similar as $rank => $item ) { |
| 510 |
$wpdb->insert( |
| 511 |
WPF()->tables->ai_embeddings_cache, |
| 512 |
[ |
| 513 |
'source_type' => $source_type, |
| 514 |
'source_id' => $source_id, |
| 515 |
'similar_type' => $similar_type, |
| 516 |
'similar_id' => $item['topicid'], |
| 517 |
'similarity_score' => $item['similarity'], |
| 518 |
'rank_position' => $rank + 1, |
| 519 |
'expires_at' => $expires_at, |
| 520 |
], |
| 521 |
[ '%s', '%d', '%s', '%d', '%f', '%d', '%s' ] |
| 522 |
); |
| 523 |
} |
| 524 |
} |
| 525 |
|
| 526 |
/** |
| 527 |
* Invalidate cache for an item |
| 528 |
* |
| 529 |
* @param string $type Item type |
| 530 |
* @param int $id Item ID |
| 531 |
*/ |
| 532 |
public function invalidate_cache( $type, $id ) { |
| 533 |
global $wpdb; |
| 534 |
|
| 535 |
// Delete where this item is the source |
| 536 |
$wpdb->delete( |
| 537 |
WPF()->tables->ai_embeddings_cache, |
| 538 |
[ |
| 539 |
'source_type' => $type, |
| 540 |
'source_id' => $id, |
| 541 |
], |
| 542 |
[ '%s', '%d' ] |
| 543 |
); |
| 544 |
|
| 545 |
// Delete where this item is in similar results |
| 546 |
$wpdb->delete( |
| 547 |
WPF()->tables->ai_embeddings_cache, |
| 548 |
[ |
| 549 |
'similar_type' => $type, |
| 550 |
'similar_id' => $id, |
| 551 |
], |
| 552 |
[ '%s', '%d' ] |
| 553 |
); |
| 554 |
} |
| 555 |
|
| 556 |
/** |
| 557 |
* Cleanup expired cache entries |
| 558 |
*/ |
| 559 |
public function cleanup_expired_cache() { |
| 560 |
global $wpdb; |
| 561 |
|
| 562 |
$now = current_time( 'mysql' ); |
| 563 |
|
| 564 |
$wpdb->query( $wpdb->prepare( |
| 565 |
"DELETE FROM " . WPF()->tables->ai_embeddings_cache . " WHERE expires_at < %s", |
| 566 |
$now |
| 567 |
) ); |
| 568 |
} |
| 569 |
|
| 570 |
/** |
| 571 |
* Get embedding statistics |
| 572 |
* |
| 573 |
* @return array Statistics |
| 574 |
*/ |
| 575 |
public function get_stats() { |
| 576 |
global $wpdb; |
| 577 |
|
| 578 |
$stats = [ |
| 579 |
'total_embeddings' => 0, |
| 580 |
'total_topics' => 0, |
| 581 |
'total_posts' => 0, |
| 582 |
'cache_entries' => 0, |
| 583 |
'storage_size_mb' => 0, |
| 584 |
'last_indexed_at' => null, |
| 585 |
]; |
| 586 |
|
| 587 |
// Combined query: total embeddings, unique topics, unique posts, last indexed |
| 588 |
// (4 queries → 1 on the same table) |
| 589 |
$combined = $wpdb->get_row( |
| 590 |
"SELECT COUNT(*) as total_embeddings, |
| 591 |
COUNT(DISTINCT topicid) as total_topics, |
| 592 |
COUNT(DISTINCT postid) as total_posts, |
| 593 |
MAX(updated_at) as last_indexed_at |
| 594 |
FROM " . WPF()->tables->ai_embeddings, |
| 595 |
ARRAY_A |
| 596 |
); |
| 597 |
if ( $combined ) { |
| 598 |
$stats['total_embeddings'] = (int) $combined['total_embeddings']; |
| 599 |
$stats['total_topics'] = (int) $combined['total_topics']; |
| 600 |
$stats['total_posts'] = (int) $combined['total_posts']; |
| 601 |
$stats['last_indexed_at'] = $combined['last_indexed_at']; |
| 602 |
} |
| 603 |
|
| 604 |
// Cache entries (separate table) |
| 605 |
$stats['cache_entries'] = (int) $wpdb->get_var( |
| 606 |
"SELECT COUNT(*) FROM " . WPF()->tables->ai_embeddings_cache |
| 607 |
); |
| 608 |
|
| 609 |
// Storage size (approximate) |
| 610 |
$table_name = WPF()->tables->ai_embeddings; |
| 611 |
$table_status = $wpdb->get_row( |
| 612 |
$wpdb->prepare( "SHOW TABLE STATUS WHERE Name = %s", $table_name ) |
| 613 |
); |
| 614 |
if ( $table_status ) { |
| 615 |
$stats['storage_size_mb'] = round( ( $table_status->Data_length + $table_status->Index_length ) / 1024 / 1024, 2 ); |
| 616 |
} |
| 617 |
|
| 618 |
return $stats; |
| 619 |
} |
| 620 |
|
| 621 |
/** |
| 622 |
* Get counts of indexed WordPress CPT content grouped by post type. |
| 623 |
* |
| 624 |
* Queries the ai_embeddings table for non-forum content and returns |
| 625 |
* counts keyed by wp_{content_type} (e.g., wp_docs, wp_page, wp_post). |
| 626 |
* This matches the format returned by the cloud API's /rag/wordpress/status. |
| 627 |
* |
| 628 |
* @return array<string, int> Post type => count (e.g., ['wp_docs' => 145, 'wp_page' => 20]) |
| 629 |
*/ |
| 630 |
public function get_wp_indexed_counts() { |
| 631 |
global $wpdb; |
| 632 |
|
| 633 |
$results = $wpdb->get_results( |
| 634 |
"SELECT content_type, COUNT(*) as cnt |
| 635 |
FROM " . WPF()->tables->ai_embeddings . " |
| 636 |
WHERE content_type != 'forum' |
| 637 |
GROUP BY content_type", |
| 638 |
ARRAY_A |
| 639 |
); |
| 640 |
|
| 641 |
$counts = []; |
| 642 |
if ( $results ) { |
| 643 |
foreach ( $results as $row ) { |
| 644 |
$counts[ 'wp_' . $row['content_type'] ] = (int) $row['cnt']; |
| 645 |
} |
| 646 |
} |
| 647 |
|
| 648 |
return $counts; |
| 649 |
} |
| 650 |
|
| 651 |
/** |
| 652 |
* Check if local storage should be recommended based on post count |
| 653 |
* |
| 654 |
* @return array Recommendation with status and message |
| 655 |
*/ |
| 656 |
public function get_storage_recommendation() { |
| 657 |
$post_count = WPF()->post->get_count(); |
| 658 |
|
| 659 |
if ( $post_count < 10000 ) { |
| 660 |
return [ |
| 661 |
'status' => 'good', |
| 662 |
'message' => wpforo_phrase( 'Excellent choice for your forum size. Local storage will provide fast performance.', false ), |
| 663 |
'icon' => 'yes-alt', |
| 664 |
]; |
| 665 |
} elseif ( $post_count < self::PERFORMANCE_THRESHOLD ) { |
| 666 |
return [ |
| 667 |
'status' => 'good', |
| 668 |
'message' => wpforo_phrase( 'Good choice. Local storage with caching will provide acceptable performance.', false ), |
| 669 |
'icon' => 'yes', |
| 670 |
]; |
| 671 |
} elseif ( $post_count < 100000 ) { |
| 672 |
return [ |
| 673 |
'status' => 'warning', |
| 674 |
'message' => sprintf( |
| 675 |
wpforo_phrase( 'Your forum has %s posts. Local storage may have slower search performance. Consider using cloud storage for better results.', false ), |
| 676 |
number_format( $post_count ) |
| 677 |
), |
| 678 |
'icon' => 'warning', |
| 679 |
]; |
| 680 |
} else { |
| 681 |
return [ |
| 682 |
'status' => 'not_recommended', |
| 683 |
'message' => sprintf( |
| 684 |
wpforo_phrase( 'Your forum has %s posts. Cloud storage (gVectors) is recommended for optimal performance.', false ), |
| 685 |
number_format( $post_count ) |
| 686 |
), |
| 687 |
'icon' => 'dismiss', |
| 688 |
]; |
| 689 |
} |
| 690 |
} |
| 691 |
|
| 692 |
// ========================================================================= |
| 693 |
// Vector Math Utilities |
| 694 |
// ========================================================================= |
| 695 |
|
| 696 |
/** |
| 697 |
* Pack float array to binary |
| 698 |
* |
| 699 |
* @param array $vector Float array |
| 700 |
* @return string Binary packed data |
| 701 |
*/ |
| 702 |
private function pack_vector( $vector ) { |
| 703 |
return pack( 'f*', ...$vector ); |
| 704 |
} |
| 705 |
|
| 706 |
/** |
| 707 |
* Unpack binary to float array |
| 708 |
* |
| 709 |
* @param string $binary Binary data |
| 710 |
* @return array Float array |
| 711 |
*/ |
| 712 |
private function unpack_vector( $binary ) { |
| 713 |
$floats = unpack( 'f*', $binary ); |
| 714 |
return array_values( $floats ); |
| 715 |
} |
| 716 |
|
| 717 |
/** |
| 718 |
* Calculate vector magnitude |
| 719 |
* |
| 720 |
* @param array $vector Float array |
| 721 |
* @return float Magnitude |
| 722 |
*/ |
| 723 |
private function calculate_magnitude( $vector ) { |
| 724 |
$sum = 0; |
| 725 |
foreach ( $vector as $val ) { |
| 726 |
$sum += $val * $val; |
| 727 |
} |
| 728 |
return sqrt( $sum ); |
| 729 |
} |
| 730 |
|
| 731 |
/** |
| 732 |
* Normalize vector to unit length |
| 733 |
* |
| 734 |
* @param array $vector Float array |
| 735 |
* @param float $magnitude Pre-computed magnitude (optional) |
| 736 |
* @return array Normalized vector |
| 737 |
*/ |
| 738 |
private function normalize_vector( $vector, $magnitude = null ) { |
| 739 |
if ( $magnitude === null ) { |
| 740 |
$magnitude = $this->calculate_magnitude( $vector ); |
| 741 |
} |
| 742 |
|
| 743 |
if ( $magnitude == 0 ) { |
| 744 |
return $vector; |
| 745 |
} |
| 746 |
|
| 747 |
return array_map( function( $val ) use ( $magnitude ) { |
| 748 |
return $val / $magnitude; |
| 749 |
}, $vector ); |
| 750 |
} |
| 751 |
|
| 752 |
/** |
| 753 |
* Calculate dot product of two vectors |
| 754 |
* |
| 755 |
* @param array $a Vector A |
| 756 |
* @param array $b Vector B |
| 757 |
* @return float Dot product |
| 758 |
*/ |
| 759 |
private function dot_product( $a, $b ) { |
| 760 |
$sum = 0; |
| 761 |
$len = min( count( $a ), count( $b ) ); |
| 762 |
|
| 763 |
for ( $i = 0; $i < $len; $i++ ) { |
| 764 |
$sum += $a[ $i ] * $b[ $i ]; |
| 765 |
} |
| 766 |
|
| 767 |
return $sum; |
| 768 |
} |
| 769 |
|
| 770 |
/** |
| 771 |
* Calculate cosine similarity between two vectors |
| 772 |
* |
| 773 |
* @param array $a Vector A |
| 774 |
* @param array $b Vector B |
| 775 |
* @return float Similarity score (0-1) |
| 776 |
*/ |
| 777 |
public function cosine_similarity( $a, $b ) { |
| 778 |
$dot = $this->dot_product( $a, $b ); |
| 779 |
$mag_a = $this->calculate_magnitude( $a ); |
| 780 |
$mag_b = $this->calculate_magnitude( $b ); |
| 781 |
|
| 782 |
if ( $mag_a == 0 || $mag_b == 0 ) { |
| 783 |
return 0; |
| 784 |
} |
| 785 |
|
| 786 |
return $dot / ( $mag_a * $mag_b ); |
| 787 |
} |
| 788 |
} |
| 789 |
|