nodebb-plugin-search-agent 0.0.939 → 0.0.942
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/cosineSimilarity.js +42 -0
- package/lib/searchHandler.js +41 -73
- package/library.js +2 -2
- package/package.json +1 -1
- package/services/embeddingService.js +1 -1
- package/services/vectorSearchService.js +3 -23
- package/test/testCosine.js +15 -0
|
@@ -0,0 +1,42 @@
|
|
|
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/lib/searchHandler.js
CHANGED
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
// ─── Token estimation helper ───────────────────────────────────────────────
|
|
2
|
-
function estimateTokens(str) {
|
|
3
|
-
// Roughly 4 chars/token for English, 2 for Hebrew/UTF-8, but 4 is safe for cost estimation
|
|
4
|
-
return Math.ceil(str.length / 4);
|
|
5
|
-
}
|
|
6
|
-
|
|
7
1
|
'use strict';
|
|
8
2
|
|
|
9
3
|
const https = require('https');
|
|
@@ -181,18 +175,18 @@ function callOpenAI(apiKey, model, messages) {
|
|
|
181
175
|
* @param {string} model
|
|
182
176
|
* @returns {Promise<string>}
|
|
183
177
|
*/
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
178
|
+
async function expandQueryWithHyDE(queryText, apiKey, model) {
|
|
179
|
+
const response = await callOpenAI(apiKey, model, [
|
|
180
|
+
{
|
|
181
|
+
role: 'system',
|
|
182
|
+
content:
|
|
183
|
+
'אתה חבר בפורום. בהינתן שאלת חיפוש, כתוב פוסט תגובה קצר וריאליסטי בפורום שעונה ישירות על השאלה. ' +
|
|
184
|
+
'כתוב רק את תוכן הפוסט — ללא ברכות, הערות מטא, או שורת נושא.',
|
|
185
|
+
},
|
|
186
|
+
{ role: 'user', content: queryText },
|
|
187
|
+
]);
|
|
188
|
+
return (response.choices[0].message.content || '').trim() || queryText;
|
|
189
|
+
}
|
|
196
190
|
|
|
197
191
|
/**
|
|
198
192
|
* Send candidates to OpenAI for independent per-topic relevance scoring.
|
|
@@ -204,38 +198,25 @@ function callOpenAI(apiKey, model, messages) {
|
|
|
204
198
|
async function reRankWithAI(queryText, candidates, topicMap, apiKey, model, maxResults, snippetByTid = {}) {
|
|
205
199
|
console.log('Re-ranking with AI:', { queryText, candidates: candidates.map(c => ({ tid: c.tid, title: (topicMap[String(c.tid)] || {}).title })) });
|
|
206
200
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
});
|
|
216
|
-
const postEmbeddings = await embedBatch(postSnippets);
|
|
217
|
-
|
|
218
|
-
// Format: [tid:..., embedding: [v1, v2, ...]]
|
|
219
|
-
const candidateList = candidates.map((c, i) => {
|
|
220
|
-
return `[tid:${c.tid}]\nembedding: [${postEmbeddings[i].slice(0, 8).map(x => x.toFixed(4)).join(', ')} ...]`;
|
|
221
|
-
}).join('\n\n');
|
|
201
|
+
// Only send the embedded query and the matched post snippet for each candidate
|
|
202
|
+
const candidateList = candidates
|
|
203
|
+
.map((c) => {
|
|
204
|
+
const raw = (snippetByTid[String(c.tid)] || '').replace(/<[^>]*>/g, ' ').replace(/[ \t]+/g, ' ').trim();
|
|
205
|
+
// Only send the snippet, not the title
|
|
206
|
+
return `[tid:${c.tid}]\n${raw.slice(0, 1500)}`;
|
|
207
|
+
})
|
|
208
|
+
.join('\n\n');
|
|
222
209
|
|
|
223
210
|
const systemPrompt =
|
|
224
211
|
'אתה מסנן חיפוש פורום מחמיר. ' +
|
|
225
|
-
'לכל מועמד ברשימה, דרג את הרלוונטיות של
|
|
212
|
+
'לכל מועמד ברשימה, דרג את הרלוונטיות של קטע הפוסט לשאלת המשתמש בסקלה 0-10: ' +
|
|
226
213
|
'10 = עונה ישירות ובאופן מלא. 7-9 = עונה על חלק משמעותי. 0-6 = לא רלוונטי. ' +
|
|
227
214
|
'החזר אך ורק JSON תקני במבנה: {"tid": ציון, ...} — לדוגמה: {"42": 9, "15": 3}. ' +
|
|
228
|
-
'אין להוסיף הסברים, טקסט נוסף, או עיצוב מחוץ ל-JSON.'
|
|
229
|
-
|
|
230
|
-
const userMessage =
|
|
231
|
-
`embedding של שאלת המשתמש: [${queryEmbedding.slice(0, 8).map(x => x.toFixed(4)).join(', ')} ...]\n\nפוסטים:\n${candidateList}`;
|
|
215
|
+
'אין להוסיף הסברים, טקסט נוסף, או עיצוב מחוץ ל-JSON.'+
|
|
216
|
+
'הוסף שדה נוסף "scoreExplanation" עם משפט קצר שמסביר למה קטע עם ציון נמוך לא רלוונטי.';
|
|
232
217
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const embeddingTokens = estimateTokens(queryText) + postSnippets.reduce((sum, s) => sum + estimateTokens(s), 0);
|
|
236
|
-
const llmPromptTokens = estimateTokens(systemPrompt) + estimateTokens(userMessage);
|
|
237
|
-
const winston = require.main.require('winston');
|
|
238
|
-
winston.info(`[search-agent] Token usage: embedding API ≈ ${embeddingTokens} tokens, LLM prompt ≈ ${llmPromptTokens} tokens (for this search)`);
|
|
218
|
+
const userMessage =
|
|
219
|
+
`שאלת המשתמש (מוטמעת): "${queryText}"\n\nפוסטים:\n${candidateList}`;
|
|
239
220
|
|
|
240
221
|
const response = await callOpenAI(apiKey, model, [
|
|
241
222
|
{ role: 'system', content: systemPrompt },
|
|
@@ -255,25 +236,12 @@ async function reRankWithAI(queryText, candidates, topicMap, apiKey, model, maxR
|
|
|
255
236
|
const scores = JSON.parse(match[0]);
|
|
256
237
|
const candidateByTid = Object.fromEntries(candidates.map(c => [String(c.tid), c]));
|
|
257
238
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
// If nothing passed the threshold, return the top scoring candidate (if any)
|
|
266
|
-
if (filtered.length === 0 && candidates.length > 0) {
|
|
267
|
-
// Find the tid with the highest score
|
|
268
|
-
const sortedAll = Object.entries(scores)
|
|
269
|
-
.sort(([, a], [, b]) => Number(b) - Number(a));
|
|
270
|
-
if (sortedAll.length > 0) {
|
|
271
|
-
const [topTid] = sortedAll[0];
|
|
272
|
-
const topCandidate = candidateByTid[topTid];
|
|
273
|
-
if (topCandidate) filtered = [topCandidate];
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
return filtered;
|
|
239
|
+
return Object.entries(scores)
|
|
240
|
+
.filter(([, score]) => Number(score) >= 7)
|
|
241
|
+
.sort(([, a], [, b]) => Number(b) - Number(a))
|
|
242
|
+
.slice(0, maxResults)
|
|
243
|
+
.map(([tid]) => candidateByTid[tid])
|
|
244
|
+
.filter(Boolean);
|
|
277
245
|
}
|
|
278
246
|
|
|
279
247
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
@@ -305,16 +273,16 @@ async function searchTopics(queryText) {
|
|
|
305
273
|
// HyDE: replace the short raw query with a hypothetical answer so the
|
|
306
274
|
// embedding matches post content more closely.
|
|
307
275
|
let embeddingQuery = queryText;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
276
|
+
if (useAI && settings.hydeEnabled) {
|
|
277
|
+
try {
|
|
278
|
+
embeddingQuery = await expandQueryWithHyDE(
|
|
279
|
+
queryText, settings.openaiApiKey, settings.openaiModel
|
|
280
|
+
);
|
|
281
|
+
winston.verbose(`[search-agent] HyDE expanded query (${embeddingQuery.length} chars)`);
|
|
282
|
+
} catch (hydeErr) {
|
|
283
|
+
winston.warn(`[search-agent] HyDE expansion failed, using raw query: ${hydeErr.message}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
318
286
|
|
|
319
287
|
// Request more candidates when AI will re-rank them.
|
|
320
288
|
const vectorLimit = useAI ? settings.aiCandidates : settings.maxResults;
|
package/library.js
CHANGED
|
@@ -30,13 +30,13 @@ plugin.init = async (params) => {
|
|
|
30
30
|
|
|
31
31
|
// Start initial embedding sync in the background — does not block NodeBB startup.
|
|
32
32
|
winston.info('[search-agent] Starting initial embedding sync…');
|
|
33
|
-
|
|
33
|
+
startSync().catch(err => winston.warn(`[search-agent] Initial sync failed: ${err.message}`));
|
|
34
34
|
|
|
35
35
|
// Re-sync every 10 minutes to pick up new posts.
|
|
36
36
|
const RESYNC_INTERVAL_MS = 10 * 60 * 1000;
|
|
37
37
|
setInterval(() => {
|
|
38
38
|
winston.info('[search-agent] Running scheduled embedding re-sync…');
|
|
39
|
-
|
|
39
|
+
startSync().catch(err => winston.warn(`[search-agent] Scheduled re-sync failed: ${err.message}`));
|
|
40
40
|
}, RESYNC_INTERVAL_MS).unref();
|
|
41
41
|
|
|
42
42
|
winston.info('[plugins/search-agent] Initialised.');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodebb-plugin-search-agent",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.942",
|
|
4
4
|
"description": "NodeBB plugin that adds a floating chat assistant to help users find relevant forum topics using TF-IDF text similarity",
|
|
5
5
|
"main": "library.js",
|
|
6
6
|
"author": "Racheli Bayfus",
|
|
@@ -147,26 +147,6 @@ const STOP_WORDS = new Set([
|
|
|
147
147
|
]);
|
|
148
148
|
|
|
149
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
150
|
return String(text || '')
|
|
171
151
|
// remove niqqud / cantillation
|
|
172
152
|
.replace(/[\u0591-\u05C7]/g, '')
|
|
@@ -373,8 +353,8 @@ async function search(query, limit = TOP_K) {
|
|
|
373
353
|
]);
|
|
374
354
|
|
|
375
355
|
const results = await oramaSearch(db, {
|
|
376
|
-
|
|
377
|
-
|
|
356
|
+
mode: 'hybrid',
|
|
357
|
+
term: expanded.term,
|
|
378
358
|
properties: SEARCH_PROPERTIES,
|
|
379
359
|
boost: FIELD_BOOSTS,
|
|
380
360
|
vector: {
|
|
@@ -409,5 +389,5 @@ async function search(query, limit = TOP_K) {
|
|
|
409
389
|
|
|
410
390
|
module.exports = {
|
|
411
391
|
search,
|
|
412
|
-
invalidateIndex
|
|
392
|
+
invalidateIndex
|
|
413
393
|
};
|
|
@@ -0,0 +1,15 @@
|
|
|
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;
|