PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.8
MxChat – AI Chatbot & Content Generation for WordPress v3.2.8
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
mxchat-basic / includes / class-mxchat-chunker.php

class-mxchat-chunker.php in MxChat – AI Chatbot & Content Generation for WordPress 3.2.8, at includes/class-mxchat-chunker.php

325 lines 10.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MxChat Chunker - Text chunking utility for RAG optimization
4 *
5 * Splits large content into chunks for improved semantic retrieval.
6 * All chunks for a URL are reassembled before sending to AI, so no overlap is needed.
7 *
8 * @package MxChat
9 * @since 2.6.3
10 */
11
12 if (!defined('ABSPATH')) {
13 exit; // Exit if accessed directly
14 }
15
16 class MxChat_Chunker {
17
18 /**
19 * Maximum characters per chunk
20 * @var int
21 */
22 private $chunk_size;
23
24 /**
25 * Constructor
26 *
27 * @param int $chunk_size Characters per chunk (default 4000 ≈ 1000 tokens)
28 */
29 public function __construct($chunk_size = 4000) {
30 $this->chunk_size = max(1000, min(10000, intval($chunk_size)));
31 }
32
33 /**
34 * Get chunking settings from WordPress options
35 *
36 * @return array Array with chunk_size and chunking_enabled
37 */
38 public static function get_settings() {
39 $options = get_option('mxchat_options', array());
40
41 return array(
42 'chunk_size' => isset($options['chunk_size']) ? intval($options['chunk_size']) : 4000,
43 'chunking_enabled' => isset($options['chunking_enabled']) ? (bool) $options['chunking_enabled'] : true
44 );
45 }
46
47 /**
48 * Create a chunker instance with settings from WordPress options
49 *
50 * @return MxChat_Chunker
51 */
52 public static function from_settings() {
53 $settings = self::get_settings();
54 return new self($settings['chunk_size']);
55 }
56
57 /**
58 * Check if content should be chunked
59 *
60 * @param string $text Content to evaluate
61 * @return bool True if content should be chunked
62 */
63 public function should_chunk($text) {
64 $settings = self::get_settings();
65
66 // Check if chunking is enabled globally
67 if (!$settings['chunking_enabled']) {
68 return false;
69 }
70
71 // Only chunk if content exceeds chunk size
72 return strlen($text) > $this->chunk_size;
73 }
74
75 /**
76 * Split text into chunks
77 *
78 * Algorithm:
79 * 1. Split content by paragraph boundaries
80 * 2. Accumulate paragraphs until chunk size exceeded
81 * 3. Start new chunk (no overlap needed since we reassemble all chunks)
82 *
83 * @param string $text Content to chunk
84 * @return array Array of chunk strings
85 */
86 public function chunk_text($text) {
87 // Handle empty content
88 if (empty(trim($text))) {
89 return array();
90 }
91
92 // Handle content smaller than chunk size - return as single chunk
93 if (strlen($text) <= $this->chunk_size) {
94 return array(trim($text));
95 }
96
97 $chunks = array();
98 $paragraphs = preg_split('/\n\s*\n/', $text); // Split by paragraph boundaries
99 $current_chunk = '';
100
101 foreach ($paragraphs as $paragraph) {
102 $paragraph = trim($paragraph);
103
104 // Skip empty paragraphs
105 if (empty($paragraph)) {
106 continue;
107 }
108
109 // Calculate size if we add this paragraph
110 $separator = empty($current_chunk) ? '' : "\n\n";
111 $potential_size = strlen($current_chunk) + strlen($separator) + strlen($paragraph);
112
113 // If adding this paragraph exceeds chunk size
114 if ($potential_size > $this->chunk_size && !empty($current_chunk)) {
115 // Save current chunk and start fresh
116 $chunks[] = trim($current_chunk);
117 $current_chunk = $paragraph;
118 } else {
119 // Add paragraph to current chunk
120 $current_chunk .= $separator . $paragraph;
121 }
122
123 // Handle very long paragraphs that exceed chunk size on their own
124 if (strlen($current_chunk) > $this->chunk_size) {
125 $split_chunks = $this->split_long_paragraph($current_chunk);
126
127 // Add all but the last split chunk
128 for ($i = 0; $i < count($split_chunks) - 1; $i++) {
129 $chunks[] = trim($split_chunks[$i]);
130 }
131
132 // Keep the last one as current chunk (may accumulate more)
133 $current_chunk = $split_chunks[count($split_chunks) - 1];
134 }
135 }
136
137 // Add final chunk if not empty
138 if (!empty(trim($current_chunk))) {
139 $chunks[] = trim($current_chunk);
140 }
141
142 return $chunks;
143 }
144
145 /**
146 * Split a very long paragraph into chunks
147 *
148 * Used when a single paragraph exceeds chunk size.
149 * Splits by sentences, then by words if needed.
150 *
151 * @param string $paragraph Long paragraph to split
152 * @return array Array of chunk strings
153 */
154 private function split_long_paragraph($paragraph) {
155 $chunks = array();
156
157 // First try splitting by sentences
158 $sentences = preg_split('/(?<=[.!?])\s+/', $paragraph);
159 $current_chunk = '';
160
161 foreach ($sentences as $sentence) {
162 $sentence = trim($sentence);
163 if (empty($sentence)) {
164 continue;
165 }
166
167 // If single sentence is too long, split by words
168 if (strlen($sentence) > $this->chunk_size) {
169 if (!empty($current_chunk)) {
170 $chunks[] = trim($current_chunk);
171 $current_chunk = '';
172 }
173
174 // Split long sentence by words
175 $word_chunks = $this->split_by_words($sentence);
176 foreach ($word_chunks as $word_chunk) {
177 $chunks[] = $word_chunk;
178 }
179 continue;
180 }
181
182 $separator = empty($current_chunk) ? '' : ' ';
183 $potential_size = strlen($current_chunk) + strlen($separator) + strlen($sentence);
184
185 if ($potential_size > $this->chunk_size && !empty($current_chunk)) {
186 $chunks[] = trim($current_chunk);
187 $current_chunk = $sentence;
188 } else {
189 $current_chunk .= $separator . $sentence;
190 }
191 }
192
193 if (!empty(trim($current_chunk))) {
194 $chunks[] = trim($current_chunk);
195 }
196
197 return $chunks;
198 }
199
200 /**
201 * Split text by words when sentences are too long
202 *
203 * Last resort splitting method for very long unbroken text.
204 *
205 * @param string $text Text to split
206 * @return array Array of chunk strings
207 */
208 private function split_by_words($text) {
209 $chunks = array();
210 $words = preg_split('/\s+/', $text);
211 $current_chunk = '';
212
213 foreach ($words as $word) {
214 $separator = empty($current_chunk) ? '' : ' ';
215 $potential_size = strlen($current_chunk) + strlen($separator) + strlen($word);
216
217 if ($potential_size > $this->chunk_size && !empty($current_chunk)) {
218 $chunks[] = trim($current_chunk);
219 $current_chunk = $word;
220 } else {
221 $current_chunk .= $separator . $word;
222 }
223 }
224
225 if (!empty(trim($current_chunk))) {
226 $chunks[] = trim($current_chunk);
227 }
228
229 return $chunks;
230 }
231
232 /**
233 * Create chunk metadata for storage
234 *
235 * @param int $chunk_index 0-based index of this chunk
236 * @param int $total_chunks Total number of chunks for this content
237 * @param string $source_url Original source URL
238 * @return array Metadata array
239 */
240 public static function create_chunk_metadata($chunk_index, $total_chunks, $source_url) {
241 return array(
242 'document_type' => 'chunked',
243 'chunk_index' => intval($chunk_index),
244 'total_chunks' => intval($total_chunks),
245 'source_url' => $source_url,
246 'parent_url_hash' => md5($source_url)
247 );
248 }
249
250 /**
251 * Format chunk content with metadata prefix (for WordPress DB storage)
252 *
253 * @param string $chunk_content The chunk text
254 * @param array $metadata Chunk metadata
255 * @return string Formatted content with JSON prefix
256 */
257 public static function format_chunk_for_storage($chunk_content, $metadata) {
258 return wp_json_encode($metadata) . "\n---\n" . $chunk_content;
259 }
260
261 /**
262 * Parse chunk content to extract metadata and text
263 *
264 * @param string $stored_content Content from database
265 * @return array Array with 'metadata' and 'text' keys
266 */
267 public static function parse_stored_chunk($stored_content) {
268 // Check if content has metadata prefix
269 if (strpos($stored_content, '{"document_type"') === 0) {
270 $parts = explode("\n---\n", $stored_content, 2);
271
272 if (count($parts) === 2) {
273 $metadata = json_decode($parts[0], true);
274 return array(
275 'metadata' => $metadata ?: array(),
276 'text' => $parts[1],
277 'is_chunked' => isset($metadata['document_type']) && $metadata['document_type'] === 'chunked'
278 );
279 }
280 }
281
282 // Non-chunked content
283 return array(
284 'metadata' => array(),
285 'text' => $stored_content,
286 'is_chunked' => false
287 );
288 }
289
290 /**
291 * Generate vector ID for a chunk
292 *
293 * @param string $source_url Original source URL
294 * @param int $chunk_index 0-based chunk index
295 * @return string Vector ID in format: {md5(url)}_chunk_{index}
296 */
297 public static function generate_chunk_vector_id($source_url, $chunk_index) {
298 $base_id = md5($source_url);
299 return $base_id . '_chunk_' . intval($chunk_index);
300 }
301
302 /**
303 * Extract base URL hash from a chunk vector ID
304 *
305 * @param string $vector_id Vector ID to parse
306 * @return string|null Base URL hash or null if not a chunk ID
307 */
308 public static function get_base_hash_from_vector_id($vector_id) {
309 if (preg_match('/^([a-f0-9]{32})_chunk_\d+$/', $vector_id, $matches)) {
310 return $matches[1];
311 }
312 return null;
313 }
314
315 /**
316 * Check if a vector ID is a chunk ID
317 *
318 * @param string $vector_id Vector ID to check
319 * @return bool True if this is a chunk vector ID
320 */
321 public static function is_chunk_vector_id($vector_id) {
322 return (bool) preg_match('/^[a-f0-9]{32}_chunk_\d+$/', $vector_id);
323 }
324 }
325