PluginProbe
wpForo Forum / 3.0.5
wpForo Forum v3.0.5
3.1.5 3.1.4 3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.10 1.4.11 1.4.12 1.4.13 1.4.2 All 137 releases
wpforo / classes / VectorStorageLocal.php

VectorStorageLocal.php in wpForo Forum 3.0.5, at classes/VectorStorageLocal.php

789 lines 21.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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