nodebb-plugin-search-agent 0.0.92 → 0.0.94
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/searchHandler.js +118 -41
- package/library.js +2 -2
- package/package.json +1 -1
- package/services/embeddingService.js +438 -90
- package/services/vectorSearchService.js +379 -93
- package/templates/admin/plugins/search-agent.tpl +12 -0
- package/lib/cosineSimilarity.js +0 -42
- package/test/testCosine.js +0 -15
|
@@ -5,123 +5,409 @@ const { embed } = require('./embeddingService');
|
|
|
5
5
|
const { getAllEmbeddings } = require('./vectorStore');
|
|
6
6
|
|
|
7
7
|
function winston() {
|
|
8
|
-
|
|
8
|
+
return require.main.require('winston');
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
// Fetch this many candidates from Orama — cast a wide net so the AI has enough to choose from
|
|
12
12
|
const TOP_K = 50;
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
const MIN_SCORE = 0.
|
|
17
|
-
|
|
13
|
+
|
|
14
|
+
// Absolute minimum similarity — only filters pure noise.
|
|
15
|
+
// Keep this low; the later ranking layers should handle precision.
|
|
16
|
+
const MIN_SCORE = 0.15;
|
|
17
|
+
|
|
18
|
+
// Rebuild the Orama index after this interval
|
|
18
19
|
const INDEX_TTL_MS = 5 * 60 * 1000;
|
|
19
20
|
|
|
21
|
+
// Hybrid search configuration
|
|
22
|
+
const VECTOR_SIMILARITY = 0.1;
|
|
23
|
+
const SEARCH_PROPERTIES = ['title', 'category', 'tags', 'content', 'parent_content'];
|
|
24
|
+
const FIELD_BOOSTS = {
|
|
25
|
+
title: 3.5,
|
|
26
|
+
tags: 2.8,
|
|
27
|
+
category: 2.2,
|
|
28
|
+
content: 1.0,
|
|
29
|
+
parent_content: 0.8,
|
|
30
|
+
};
|
|
31
|
+
|
|
20
32
|
let _db = null;
|
|
21
33
|
let _dbTs = 0;
|
|
22
34
|
let _buildPromise = null;
|
|
23
35
|
|
|
36
|
+
// Finance-heavy Hebrew forum query expansion.
|
|
37
|
+
// These are intentionally conservative: good recall lift without flooding the query.
|
|
38
|
+
const QUERY_EXPANSIONS = {
|
|
39
|
+
// General finance
|
|
40
|
+
'מניה': ['מניות', 'נייר ערך', 'שוק ההון', 'בורסה'],
|
|
41
|
+
'מניות': ['מניה', 'ניירות ערך', 'שוק ההון', 'בורסה'],
|
|
42
|
+
'אגח': ['אג"ח', 'איגרת חוב', 'איגרות חוב', 'חוב'],
|
|
43
|
+
'אג"ח': ['אגח', 'איגרת חוב', 'איגרות חוב', 'חוב'],
|
|
44
|
+
'קרן': ['קרנות', 'קרן נאמנות', 'קרן סל'],
|
|
45
|
+
'קרנות': ['קרן', 'קרן נאמנות', 'קרן סל'],
|
|
46
|
+
'קרן סל': ['etf', 'תעודת סל', 'קרן מחקה'],
|
|
47
|
+
'תעודת סל': ['etf', 'קרן סל', 'קרן מחקה'],
|
|
48
|
+
'etf': ['קרן סל', 'תעודת סל', 'קרן מחקה'],
|
|
49
|
+
'מדד': ['מדדים', 'מדד מניות', 'תשואת מדד'],
|
|
50
|
+
'מדדים': ['מדד', 'מדד מניות', 'תשואת מדד'],
|
|
51
|
+
'תיק': ['תיק השקעות', 'פיזור', 'החזקות'],
|
|
52
|
+
'השקעה': ['השקעות', 'להשקיע', 'תיק השקעות'],
|
|
53
|
+
'השקעות': ['השקעה', 'להשקיע', 'תיק השקעות'],
|
|
54
|
+
'להשקיע': ['השקעה', 'השקעות', 'תיק השקעות'],
|
|
55
|
+
'תשואה': ['רווח', 'תשואות', 'רווחיות'],
|
|
56
|
+
'רווח': ['רווחים', 'תשואה', 'רווחיות'],
|
|
57
|
+
'הפסד': ['הפסדים', 'ירידה', 'מינוס'],
|
|
58
|
+
'דיבידנד': ['דיבידנדים', 'חלוקת רווחים'],
|
|
59
|
+
'מכפיל': ['מכפיל רווח', 'pe', 'p/e'],
|
|
60
|
+
'pe': ['מכפיל', 'מכפיל רווח', 'p/e'],
|
|
61
|
+
'p/e': ['מכפיל', 'מכפיל רווח', 'pe'],
|
|
62
|
+
'מינוף': ['ממונף', 'הלוואה', 'מרגין', 'margin'],
|
|
63
|
+
'מרגין': ['margin', 'מינוף'],
|
|
64
|
+
'margin': ['מרגין', 'מינוף'],
|
|
65
|
+
'סיכון': ['סיכונים', 'תנודתיות', 'חשיפה'],
|
|
66
|
+
'נזילות': ['נזיל', 'מזומן', 'סחירות'],
|
|
67
|
+
'סחירות': ['נזילות', 'נזיל'],
|
|
68
|
+
|
|
69
|
+
// Tax / regulation
|
|
70
|
+
'מס': ['מיסוי', 'מסים', 'רשות המסים'],
|
|
71
|
+
'מיסוי': ['מס', 'מסים', 'רשות המסים'],
|
|
72
|
+
'מסים': ['מס', 'מיסוי', 'רשות המסים'],
|
|
73
|
+
'קיזוז': ['קיזוז הפסדים', 'מגן מס'],
|
|
74
|
+
'דוח': ['דו"ח', 'דיווח', 'טופס'],
|
|
75
|
+
'דו"ח': ['דוח', 'דיווח', 'טופס'],
|
|
76
|
+
|
|
77
|
+
// Savings / pension
|
|
78
|
+
'פנסיה': ['קרן פנסיה', 'חיסכון פנסיוני', 'קצבה'],
|
|
79
|
+
'גמל': ['קופת גמל', 'קופ"ג'],
|
|
80
|
+
'קופג': ['קופת גמל', 'קופ"ג', 'גמל'],
|
|
81
|
+
'קופ"ג': ['קופת גמל', 'קופג', 'גמל'],
|
|
82
|
+
'השתלמות': ['קרן השתלמות'],
|
|
83
|
+
'משכנתא': ['משכנתאות', 'ריבית', 'הלוואת דיור'],
|
|
84
|
+
'הלוואה': ['הלוואות', 'אשראי', 'מימון'],
|
|
85
|
+
'אשראי': ['הלוואה', 'הלוואות', 'מימון'],
|
|
86
|
+
|
|
87
|
+
// Trading / technical
|
|
88
|
+
'מסחר': ['טריידינג', 'קניה', 'מכירה', 'פקודה'],
|
|
89
|
+
'טריידינג': ['מסחר', 'מסחר יומי', 'קניה', 'מכירה'],
|
|
90
|
+
'שורט': ['short', 'מכירה בחסר'],
|
|
91
|
+
'short': ['שורט', 'מכירה בחסר'],
|
|
92
|
+
'לונג': ['long', 'החזקה'],
|
|
93
|
+
'long': ['לונג', 'החזקה'],
|
|
94
|
+
'פקודה': ['פקודות', 'לימיט', 'מרקט'],
|
|
95
|
+
'לימיט': ['limit', 'פקודת לימיט'],
|
|
96
|
+
'limit': ['לימיט', 'פקודת לימיט'],
|
|
97
|
+
'מרקט': ['market', 'פקודת שוק'],
|
|
98
|
+
'market': ['מרקט', 'פקודת שוק'],
|
|
99
|
+
|
|
100
|
+
// Crypto
|
|
101
|
+
'ביטקוין': ['btc', 'קריפטו', 'מטבע דיגיטלי'],
|
|
102
|
+
'btc': ['ביטקוין', 'קריפטו', 'מטבע דיגיטלי'],
|
|
103
|
+
'אתריום': ['eth', 'קריפטו', 'מטבע דיגיטלי'],
|
|
104
|
+
'eth': ['אתריום', 'קריפטו', 'מטבע דיגיטלי'],
|
|
105
|
+
'קריפטו': ['מטבע דיגיטלי', 'ביטקוין', 'אתריום', 'בלוקציין'],
|
|
106
|
+
'בלוקציין': ['קריפטו', 'מטבע דיגיטלי'],
|
|
107
|
+
|
|
108
|
+
// Hebrew forum / advice intent
|
|
109
|
+
'מומלץ': ['כדאי', 'המלצה', 'עדיף'],
|
|
110
|
+
'כדאי': ['מומלץ', 'המלצה', 'עדיף'],
|
|
111
|
+
'המלצה': ['מומלץ', 'כדאי', 'עדיף'],
|
|
112
|
+
'בעיה': ['תקלה', 'קושי', 'לא עובד'],
|
|
113
|
+
'תקלה': ['בעיה', 'לא עובד', 'שגיאה'],
|
|
114
|
+
'שגיאה': ['תקלה', 'בעיה', 'לא עובד'],
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Common phrase-level expansions that are better handled before token expansion.
|
|
118
|
+
const PHRASE_EXPANSIONS = [
|
|
119
|
+
{
|
|
120
|
+
pattern: /\b(?:קרן\s+סל|תעודת\s+סל|קרן\s+מחקה)\b/gi,
|
|
121
|
+
terms: ['etf', 'קרן סל', 'תעודת סל', 'קרן מחקה'],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
pattern: /\b(?:איגרת\s+חוב|איגרות\s+חוב|אג["׳׳]?\s?ח)\b/gi,
|
|
125
|
+
terms: ['אגח', 'אג"ח', 'איגרת חוב', 'איגרות חוב'],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
pattern: /\b(?:קופת\s+גמל|קופ["׳׳]?\s?ג)\b/gi,
|
|
129
|
+
terms: ['קופת גמל', 'קופג', 'קופ"ג', 'גמל'],
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
pattern: /\b(?:מכפיל\s+רווח|p\/e|pe)\b/gi,
|
|
133
|
+
terms: ['מכפיל', 'מכפיל רווח', 'pe', 'p/e'],
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
pattern: /\b(?:מכירה\s+בחסר|short)\b/gi,
|
|
137
|
+
terms: ['שורט', 'short', 'מכירה בחסר'],
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
// Generic filler words to ignore for lexical expansion
|
|
142
|
+
const STOP_WORDS = new Set([
|
|
143
|
+
'של', 'על', 'עם', 'בלי', 'גם', 'או', 'אם', 'אבל', 'כי', 'זה', 'זאת', 'זו',
|
|
144
|
+
'יש', 'אין', 'אני', 'אתה', 'את', 'הוא', 'היא', 'הם', 'הן', 'אנחנו', 'מה',
|
|
145
|
+
'איך', 'למה', 'מתי', 'איפה', 'האם', 'כל', 'עוד', 'כמו', 'רק', 'מאוד', 'פחות',
|
|
146
|
+
'יותר', 'אחרי', 'לפני', 'תוך', 'דרך', 'לגבי', 'בנוגע', 'בשביל', 'מול',
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
function normalizeHebrew(text) {
|
|
150
|
+
if(!text) return text;
|
|
151
|
+
|
|
152
|
+
// Remove common prefixes
|
|
153
|
+
const prefixes = ['ה', 'ו', 'ב', 'ל', 'מ', 'ש', 'כ'];
|
|
154
|
+
for (const prefix of prefixes) {
|
|
155
|
+
if (text.startsWith(prefix) && text.length > 3) {
|
|
156
|
+
text = text.slice(1);
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Remove common plural suffixes
|
|
162
|
+
const pluralSuffixes = ['ים', 'ות'];
|
|
163
|
+
for (const suffix of pluralSuffixes) {
|
|
164
|
+
if (text.endsWith(suffix) && text.length > 3) {
|
|
165
|
+
text = text.slice(0, -suffix.length);
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return String(text || '')
|
|
171
|
+
// remove niqqud / cantillation
|
|
172
|
+
.replace(/[\u0591-\u05C7]/g, '')
|
|
173
|
+
// normalize Hebrew punctuation variants
|
|
174
|
+
.replace(/[׳']/g, '\'')
|
|
175
|
+
.replace(/[״"]/g, '"')
|
|
176
|
+
// collapse whitespace
|
|
177
|
+
.replace(/\s+/g, ' ')
|
|
178
|
+
.trim();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function normalizeToken(token) {
|
|
182
|
+
return normalizeHebrew(token)
|
|
183
|
+
.toLowerCase()
|
|
184
|
+
.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function uniqueTerms(terms, maxTerms = 24) {
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
const out = [];
|
|
190
|
+
|
|
191
|
+
for (const raw of terms) {
|
|
192
|
+
const term = normalizeHebrew(raw).trim();
|
|
193
|
+
if (!term) continue;
|
|
194
|
+
|
|
195
|
+
const key = term.toLowerCase();
|
|
196
|
+
if (seen.has(key)) continue;
|
|
197
|
+
|
|
198
|
+
seen.add(key);
|
|
199
|
+
out.push(term);
|
|
200
|
+
|
|
201
|
+
if (out.length >= maxTerms) break;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function expandQuery(query) {
|
|
208
|
+
const normalized = normalizeHebrew(query);
|
|
209
|
+
const expanded = [normalized];
|
|
210
|
+
|
|
211
|
+
for (const phraseRule of PHRASE_EXPANSIONS) {
|
|
212
|
+
if (phraseRule.pattern.test(normalized)) {
|
|
213
|
+
expanded.push(...phraseRule.terms);
|
|
214
|
+
}
|
|
215
|
+
phraseRule.pattern.lastIndex = 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const tokens = normalized
|
|
219
|
+
.split(/[\s,/|()[\]{}:;!?]+/)
|
|
220
|
+
.map(normalizeToken)
|
|
221
|
+
.filter(Boolean)
|
|
222
|
+
.filter(token => !STOP_WORDS.has(token));
|
|
223
|
+
|
|
224
|
+
for (const token of tokens) {
|
|
225
|
+
expanded.push(token);
|
|
226
|
+
|
|
227
|
+
const synonyms = QUERY_EXPANSIONS[token];
|
|
228
|
+
if (synonyms) {
|
|
229
|
+
expanded.push(...synonyms);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// A little morphology help for Hebrew singular/plural and abbreviations
|
|
233
|
+
if (token.endsWith('ים') && token.length > 3) {
|
|
234
|
+
expanded.push(token.slice(0, -2));
|
|
235
|
+
}
|
|
236
|
+
if (token.endsWith('ות') && token.length > 3) {
|
|
237
|
+
expanded.push(token.slice(0, -2));
|
|
238
|
+
}
|
|
239
|
+
if (token.endsWith('ה') && token.length > 2) {
|
|
240
|
+
expanded.push(token.slice(0, -1));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const terms = uniqueTerms(expanded, 24);
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
original: query,
|
|
248
|
+
normalized,
|
|
249
|
+
terms,
|
|
250
|
+
// Orama lexical search receives one expanded term string
|
|
251
|
+
term: terms.join(' '),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function coerceString(value) {
|
|
256
|
+
if (value == null) return '';
|
|
257
|
+
|
|
258
|
+
if (Array.isArray(value)) {
|
|
259
|
+
return value
|
|
260
|
+
.map(v => coerceString(v))
|
|
261
|
+
.filter(Boolean)
|
|
262
|
+
.join(', ');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return String(value).trim();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function buildDocument(row) {
|
|
269
|
+
return {
|
|
270
|
+
id: String(row.post_id),
|
|
271
|
+
post_id: row.post_id,
|
|
272
|
+
topic_id: row.topic_id,
|
|
273
|
+
title: coerceString(row.title),
|
|
274
|
+
category: coerceString(row.category),
|
|
275
|
+
tags: coerceString(row.tags),
|
|
276
|
+
parent_content: coerceString(row.parent_content),
|
|
277
|
+
content: coerceString(row.content),
|
|
278
|
+
embedding: row.embedding,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
24
282
|
async function buildIndex() {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
283
|
+
const storedEmbeddings = await getAllEmbeddings();
|
|
284
|
+
|
|
285
|
+
// Detect dimension from data; fall back to 1536 (text-embedding-3-small default)
|
|
286
|
+
const dimensions = storedEmbeddings.length > 0
|
|
287
|
+
? storedEmbeddings[0].embedding.length
|
|
288
|
+
: 1536;
|
|
289
|
+
|
|
290
|
+
const db = await create({
|
|
291
|
+
schema: {
|
|
292
|
+
post_id: 'number',
|
|
293
|
+
topic_id: 'number',
|
|
294
|
+
title: 'string',
|
|
295
|
+
category: 'string',
|
|
296
|
+
tags: 'string',
|
|
297
|
+
parent_content: 'string',
|
|
298
|
+
content: 'string',
|
|
299
|
+
embedding: `vector[${dimensions}]`,
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
if (storedEmbeddings.length > 0) {
|
|
304
|
+
await insertMultiple(db, storedEmbeddings.map(buildDocument));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
winston().info(
|
|
308
|
+
`[search-agent] vectorSearchService: Orama index built with ${storedEmbeddings.length} document(s)`
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
return db;
|
|
53
312
|
}
|
|
54
313
|
|
|
55
314
|
async function getDb() {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
315
|
+
const now = Date.now();
|
|
316
|
+
if (_db && (now - _dbTs) < INDEX_TTL_MS) {
|
|
317
|
+
return _db;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (_buildPromise) {
|
|
321
|
+
return _buildPromise;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
_buildPromise = buildIndex()
|
|
325
|
+
.then((db) => {
|
|
326
|
+
_db = db;
|
|
327
|
+
_dbTs = Date.now();
|
|
328
|
+
_buildPromise = null;
|
|
329
|
+
return db;
|
|
330
|
+
})
|
|
331
|
+
.catch((err) => {
|
|
332
|
+
_buildPromise = null;
|
|
333
|
+
throw err;
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
return _buildPromise;
|
|
76
337
|
}
|
|
77
338
|
|
|
78
339
|
/** Invalidate the in-memory Orama index (e.g. after new embeddings are saved). */
|
|
79
340
|
function invalidateIndex() {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
341
|
+
_db = null;
|
|
342
|
+
_dbTs = 0;
|
|
343
|
+
winston().info('[search-agent] vectorSearchService: Orama index invalidated');
|
|
83
344
|
}
|
|
84
345
|
|
|
85
346
|
/**
|
|
86
|
-
* Performs
|
|
347
|
+
* Performs hybrid search against stored post embeddings using:
|
|
348
|
+
* 1. vector similarity on the original query embedding
|
|
349
|
+
* 2. lexical search on an expanded Hebrew query
|
|
350
|
+
* 3. field boosts to favor title/tags/category matches
|
|
87
351
|
*
|
|
88
352
|
* @param {string} query - The search query string.
|
|
89
|
-
* @
|
|
90
|
-
*
|
|
353
|
+
* @param {number} limit - Max results to return.
|
|
354
|
+
* @returns {Promise<Array<{ topic_id: number, post_id: number, title: string, category: string, tags: string, content: string, score: number }>>}
|
|
91
355
|
*/
|
|
92
356
|
async function search(query, limit = TOP_K) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
357
|
+
if (typeof query !== 'string' || query.trim() === '') {
|
|
358
|
+
throw new Error('search() requires a non-empty query string');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const trimmed = query.trim();
|
|
362
|
+
const expanded = expandQuery(trimmed);
|
|
363
|
+
|
|
364
|
+
winston().verbose(
|
|
365
|
+
`[search-agent] vectorSearchService: running Orama hybrid search for "${trimmed}" (expanded="${expanded.term}")`
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const [queryEmbedding, db] = await Promise.all([
|
|
369
|
+
// Keep the embedding on the original query only.
|
|
370
|
+
// Expansion is mainly for lexical recall, especially in Hebrew forum language.
|
|
371
|
+
embed(trimmed),
|
|
372
|
+
getDb(),
|
|
373
|
+
]);
|
|
374
|
+
|
|
375
|
+
const results = await oramaSearch(db, {
|
|
376
|
+
mode: 'hybrid',
|
|
377
|
+
term: expanded.term,
|
|
378
|
+
properties: SEARCH_PROPERTIES,
|
|
379
|
+
boost: FIELD_BOOSTS,
|
|
380
|
+
vector: {
|
|
381
|
+
value: queryEmbedding,
|
|
382
|
+
property: 'embedding',
|
|
383
|
+
},
|
|
384
|
+
limit,
|
|
385
|
+
similarity: VECTOR_SIMILARITY,
|
|
386
|
+
includeVectors: false,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const hits = Array.isArray(results && results.hits) ? results.hits : [];
|
|
390
|
+
|
|
391
|
+
winston().verbose(`[search-agent] vectorSearchService: Orama returned ${hits.length} hit(s)`);
|
|
392
|
+
|
|
393
|
+
const filtered = hits.filter(hit => typeof hit.score === 'number' && hit.score >= MIN_SCORE);
|
|
394
|
+
|
|
395
|
+
winston().verbose(
|
|
396
|
+
`[search-agent] vectorSearchService: ${filtered.length}/${hits.length} hit(s) passed noise floor (MIN_SCORE=${MIN_SCORE})`
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
return filtered.map(hit => ({
|
|
400
|
+
topic_id: hit.document.topic_id,
|
|
401
|
+
post_id: hit.document.post_id,
|
|
402
|
+
title: hit.document.title || '',
|
|
403
|
+
category: hit.document.category || '',
|
|
404
|
+
tags: hit.document.tags || '',
|
|
405
|
+
content: hit.document.content,
|
|
406
|
+
score: hit.score,
|
|
407
|
+
}));
|
|
125
408
|
}
|
|
126
409
|
|
|
127
|
-
module.exports = {
|
|
410
|
+
module.exports = {
|
|
411
|
+
search,
|
|
412
|
+
invalidateIndex,
|
|
413
|
+
};
|
|
@@ -73,6 +73,18 @@
|
|
|
73
73
|
</label>
|
|
74
74
|
</div>
|
|
75
75
|
|
|
76
|
+
<div class="form-check form-switch mb-3">
|
|
77
|
+
<input type="checkbox" class="form-check-input" id="hydeEnabled" name="hydeEnabled">
|
|
78
|
+
<label for="hydeEnabled" class="form-check-label">
|
|
79
|
+
Enable HyDE query expansion
|
|
80
|
+
</label>
|
|
81
|
+
<div class="form-text">
|
|
82
|
+
Generates a hypothetical forum post from the query before embedding — improves
|
|
83
|
+
recall for vague queries but adds <strong>one extra LLM call per search</strong>.
|
|
84
|
+
Disable to cut AI costs roughly in half.
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
76
88
|
<div class="mb-3">
|
|
77
89
|
<label class="form-label" for="openaiApiKey">OpenAI API Key</label>
|
|
78
90
|
<input
|
package/lib/cosineSimilarity.js
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Computes the cosine similarity between two numeric vectors.
|
|
5
|
-
* Handles mismatched lengths by using the shorter vector's dimension.
|
|
6
|
-
* Returns 0 if either vector has zero magnitude.
|
|
7
|
-
*
|
|
8
|
-
* @param {number[]} a
|
|
9
|
-
* @param {number[]} b
|
|
10
|
-
* @returns {number} similarity in [-1, 1]
|
|
11
|
-
*/
|
|
12
|
-
function cosineSimilarity(a, b) {
|
|
13
|
-
const len = Math.min(a.length, b.length);
|
|
14
|
-
let dot = 0;
|
|
15
|
-
let magA = 0;
|
|
16
|
-
let magB = 0;
|
|
17
|
-
|
|
18
|
-
for (let i = 0; i < len; i++) {
|
|
19
|
-
dot += a[i] * b[i];
|
|
20
|
-
magA += a[i] * a[i];
|
|
21
|
-
magB += b[i] * b[i];
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const denom = Math.sqrt(magA) * Math.sqrt(magB);
|
|
25
|
-
return denom === 0 ? 0 : dot / denom;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Ranks items by cosine similarity to a query embedding.
|
|
30
|
-
* Each item must have an `embedding` property (number[]).
|
|
31
|
-
*
|
|
32
|
-
* @param {number[]} queryEmbedding
|
|
33
|
-
* @param {Array<{embedding: number[], [key: string]: any}>} items
|
|
34
|
-
* @returns {Array<{item: object, score: number}>} sorted descending by score
|
|
35
|
-
*/
|
|
36
|
-
function rankBySimilarity(queryEmbedding, items) {
|
|
37
|
-
return items
|
|
38
|
-
.map(item => ({ item, score: cosineSimilarity(queryEmbedding, item.embedding) }))
|
|
39
|
-
.sort((a, b) => b.score - a.score);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
module.exports = { cosineSimilarity, rankBySimilarity };
|
package/test/testCosine.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
const { cosineSimilarity } = require('../lib/cosineSimilarity');
|
|
2
|
-
|
|
3
|
-
function testCosine() {
|
|
4
|
-
console.log("Testing cosine similarity...");
|
|
5
|
-
|
|
6
|
-
const a = cosineSimilarity([1, 0], [1, 0]);
|
|
7
|
-
const b = cosineSimilarity([1, 0], [0, 1]);
|
|
8
|
-
|
|
9
|
-
if (a < 0.9) throw new Error("Expected high similarity");
|
|
10
|
-
if (b > 0.1) throw new Error("Expected low similarity");
|
|
11
|
-
|
|
12
|
-
console.log("✅ Cosine OK");
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
module.exports = testCosine;
|