]> BookStack Code Mirror - bookstack/blob - app/Search/SearchIndex.php
Improved vector text chunking
[bookstack] / app / Search / SearchIndex.php
1 <?php
2
3 namespace BookStack\Search;
4
5 use BookStack\Activity\Models\Tag;
6 use BookStack\Entities\EntityProvider;
7 use BookStack\Entities\Models\Entity;
8 use BookStack\Entities\Models\Page;
9 use BookStack\Search\Vectors\StoreEntityVectorsJob;
10 use BookStack\Search\Vectors\VectorQueryServiceProvider;
11 use BookStack\Util\HtmlDocument;
12 use DOMNode;
13 use Illuminate\Database\Eloquent\Builder;
14 use Illuminate\Support\Collection;
15
16 class SearchIndex
17 {
18     /**
19      * A list of delimiter characters used to break-up parsed content into terms for indexing.
20      */
21     public static string $delimiters = " \n\t.-,!?:;()[]{}<>`'\"«»";
22
23     /**
24      * A list of delimiter which could be commonly used within a single term and also indicate a break between terms.
25      * The indexer will index the full term with these delimiters, plus the terms split via these delimiters.
26      */
27     public static string $softDelimiters = ".-";
28
29     public function __construct(
30         protected EntityProvider $entityProvider,
31     ) {
32     }
33
34     /**
35      * Index the given entity.
36      */
37     public function indexEntity(Entity $entity): void
38     {
39         $this->deleteEntityTerms($entity);
40         $terms = $this->entityToTermDataArray($entity);
41         $this->insertTerms($terms);
42
43         if (VectorQueryServiceProvider::isEnabled()) {
44             dispatch(new StoreEntityVectorsJob($entity));
45         }
46     }
47
48     /**
49      * Index multiple Entities at once.
50      *
51      * @param Entity[] $entities
52      */
53     public function indexEntities(array $entities): void
54     {
55         $terms = [];
56         $vectorQueryEnabled = VectorQueryServiceProvider::isEnabled();
57
58         foreach ($entities as $entity) {
59             $entityTerms = $this->entityToTermDataArray($entity);
60             array_push($terms, ...$entityTerms);
61
62             if ($vectorQueryEnabled) {
63                 dispatch(new StoreEntityVectorsJob($entity));
64             }
65         }
66
67         $this->insertTerms($terms);
68     }
69
70     /**
71      * Delete and re-index the terms for all entities in the system.
72      * Can take a callback which is used for reporting progress.
73      * Callback receives three arguments:
74      * - An instance of the model being processed
75      * - The number that have been processed so far.
76      * - The total number of that model to be processed.
77      *
78      * @param callable(Entity, int, int):void|null $progressCallback
79      */
80     public function indexAllEntities(?callable $progressCallback = null): void
81     {
82         SearchTerm::query()->truncate();
83
84         foreach ($this->entityProvider->all() as $entityModel) {
85             $indexContentField = $entityModel instanceof Page ? 'html' : 'description';
86             $selectFields = ['id', 'name', $indexContentField];
87             /** @var Builder<Entity> $query */
88             $query = $entityModel->newQuery();
89             $total = $query->withTrashed()->count();
90             $chunkSize = 250;
91             $processed = 0;
92
93             $chunkCallback = function (Collection $entities) use ($progressCallback, &$processed, $total, $chunkSize, $entityModel) {
94                 $this->indexEntities($entities->all());
95                 $processed = min($processed + $chunkSize, $total);
96
97                 if (is_callable($progressCallback)) {
98                     $progressCallback($entityModel, $processed, $total);
99                 }
100             };
101
102             $entityModel->newQuery()
103                 ->select($selectFields)
104                 ->with(['tags:id,name,value,entity_id,entity_type'])
105                 ->chunk($chunkSize, $chunkCallback);
106         }
107     }
108
109     /**
110      * Delete related Entity search terms.
111      */
112     public function deleteEntityTerms(Entity $entity): void
113     {
114         $entity->searchTerms()->delete();
115     }
116
117     /**
118      * Insert the given terms into the database.
119      * Chunks through the given terms to remain within database limits.
120      * @param array[] $terms
121      */
122     protected function insertTerms(array $terms): void
123     {
124         $chunkedTerms = array_chunk($terms, 500);
125         foreach ($chunkedTerms as $termChunk) {
126             SearchTerm::query()->insert($termChunk);
127         }
128     }
129
130     /**
131      * Create a scored term array from the given text, where the keys are the terms
132      * and the values are their scores.
133      *
134      * @returns array<string, int>
135      */
136     protected function generateTermScoreMapFromText(string $text, float $scoreAdjustment = 1): array
137     {
138         $termMap = $this->textToTermCountMap($text);
139
140         foreach ($termMap as $term => $count) {
141             $termMap[$term] = floor($count * $scoreAdjustment);
142         }
143
144         return $termMap;
145     }
146
147     /**
148      * Create a scored term array from the given HTML, where the keys are the terms
149      * and the values are their scores.
150      *
151      * @returns array<string, int>
152      */
153     protected function generateTermScoreMapFromHtml(string $html): array
154     {
155         if (empty($html)) {
156             return [];
157         }
158
159         $scoresByTerm = [];
160         $elementScoreAdjustmentMap = [
161             'h1' => 10,
162             'h2' => 5,
163             'h3' => 4,
164             'h4' => 3,
165             'h5' => 2,
166             'h6' => 1.5,
167         ];
168
169         $html = str_ireplace(['<br>', '<br />', '<br/>'], "\n", $html);
170         $doc = new HtmlDocument($html);
171
172         /** @var DOMNode $child */
173         foreach ($doc->getBodyChildren() as $child) {
174             $nodeName = $child->nodeName;
175             $text = trim($child->textContent);
176             $text = str_replace("\u{00A0}", ' ', $text);
177             $termCounts = $this->textToTermCountMap($text);
178             foreach ($termCounts as $term => $count) {
179                 $scoreChange = $count * ($elementScoreAdjustmentMap[$nodeName] ?? 1);
180                 $scoresByTerm[$term] = ($scoresByTerm[$term] ?? 0) + $scoreChange;
181             }
182         }
183
184         return $scoresByTerm;
185     }
186
187     /**
188      * Create a scored term map from the given set of entity tags.
189      *
190      * @param Tag[] $tags
191      *
192      * @returns array<string, int>
193      */
194     protected function generateTermScoreMapFromTags(array $tags): array
195     {
196         $names = [];
197         $values = [];
198
199         foreach ($tags as $tag) {
200             $names[] = $tag->name;
201             $values[] = $tag->value;
202         }
203
204         $nameMap = $this->generateTermScoreMapFromText(implode(' ', $names), 3);
205         $valueMap = $this->generateTermScoreMapFromText(implode(' ', $values), 5);
206
207         return $this->mergeTermScoreMaps($nameMap, $valueMap);
208     }
209
210     /**
211      * For the given text, return an array where the keys are the unique term words
212      * and the values are the frequency of that term.
213      *
214      * @returns array<string, int>
215      */
216     protected function textToTermCountMap(string $text): array
217     {
218         $tokenMap = []; // {TextToken => OccurrenceCount}
219         $softDelims = static::$softDelimiters;
220         $tokenizer = new SearchTextTokenizer($text, static::$delimiters);
221         $extendedToken = '';
222         $extendedLen = 0;
223
224         $token = $tokenizer->next();
225
226         while ($token !== false) {
227             $delim = $tokenizer->previousDelimiter();
228
229             if ($delim && str_contains($softDelims, $delim) && $token !== '') {
230                 $extendedToken .= $delim . $token;
231                 $extendedLen++;
232             } else {
233                 if ($extendedLen > 1) {
234                     $tokenMap[$extendedToken] = ($tokenMap[$extendedToken] ?? 0) + 1;
235                 }
236                 $extendedToken = $token;
237                 $extendedLen = 1;
238             }
239
240             if ($token) {
241                 $tokenMap[$token] = ($tokenMap[$token] ?? 0) + 1;
242             }
243
244             $token = $tokenizer->next();
245         }
246
247         if ($extendedLen > 1) {
248             $tokenMap[$extendedToken] = ($tokenMap[$extendedToken] ?? 0) + 1;
249         }
250
251         return $tokenMap;
252     }
253
254     /**
255      * For the given entity, Generate an array of term data details.
256      * Is the raw term data, not instances of SearchTerm models.
257      *
258      * @returns array{term: string, score: float, entity_id: int, entity_type: string}[]
259      */
260     protected function entityToTermDataArray(Entity $entity): array
261     {
262         $nameTermsMap = $this->generateTermScoreMapFromText($entity->name, 40 * $entity->searchFactor);
263         $tagTermsMap = $this->generateTermScoreMapFromTags($entity->tags->all());
264
265         if ($entity instanceof Page) {
266             $bodyTermsMap = $this->generateTermScoreMapFromHtml($entity->html);
267         } else {
268             $bodyTermsMap = $this->generateTermScoreMapFromText($entity->getAttribute('description') ?? '', $entity->searchFactor);
269         }
270
271         $mergedScoreMap = $this->mergeTermScoreMaps($nameTermsMap, $bodyTermsMap, $tagTermsMap);
272
273         $dataArray = [];
274         $entityId = $entity->id;
275         $entityType = $entity->getMorphClass();
276         foreach ($mergedScoreMap as $term => $score) {
277             $dataArray[] = [
278                 'term'        => $term,
279                 'score'       => $score,
280                 'entity_type' => $entityType,
281                 'entity_id'   => $entityId,
282             ];
283         }
284
285         return $dataArray;
286     }
287
288     /**
289      * For the given term data arrays, Merge their contents by term
290      * while combining any scores.
291      *
292      * @param array<string, int>[] ...$scoreMaps
293      *
294      * @returns array<string, int>
295      */
296     protected function mergeTermScoreMaps(...$scoreMaps): array
297     {
298         $mergedMap = [];
299
300         foreach ($scoreMaps as $scoreMap) {
301             foreach ($scoreMap as $term => $score) {
302                 $mergedMap[$term] = ($mergedMap[$term] ?? 0) + $score;
303             }
304         }
305
306         return $mergedMap;
307     }
308 }