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
package/lib/searchHandler.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
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
|
+
|
|
1
7
|
'use strict';
|
|
2
8
|
|
|
3
9
|
const https = require('https');
|
|
@@ -10,10 +16,42 @@ let cachedTopicMap = null;
|
|
|
10
16
|
let cacheTs = 0;
|
|
11
17
|
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
12
18
|
|
|
19
|
+
// ─── Search result cache ──────────────────────────────────────────────────────
|
|
20
|
+
// Caches final search results by normalised query string.
|
|
21
|
+
// Saves all AI calls for repeated queries within the TTL window.
|
|
22
|
+
|
|
23
|
+
const _searchCache = new Map();
|
|
24
|
+
const SEARCH_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
25
|
+
const SEARCH_CACHE_MAX = 200;
|
|
26
|
+
|
|
27
|
+
function _normalizeQuery(q) {
|
|
28
|
+
return q.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function _getSearchCache(queryText) {
|
|
32
|
+
const key = _normalizeQuery(queryText);
|
|
33
|
+
const entry = _searchCache.get(key);
|
|
34
|
+
if (entry && (Date.now() - entry.ts) < SEARCH_CACHE_TTL_MS) {
|
|
35
|
+
return entry.results;
|
|
36
|
+
}
|
|
37
|
+
_searchCache.delete(key);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function _setSearchCache(queryText, results) {
|
|
42
|
+
const key = _normalizeQuery(queryText);
|
|
43
|
+
_searchCache.set(key, { results, ts: Date.now() });
|
|
44
|
+
if (_searchCache.size > SEARCH_CACHE_MAX) {
|
|
45
|
+
// Evict the oldest entry
|
|
46
|
+
_searchCache.delete(_searchCache.keys().next().value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
13
50
|
function invalidateCache() {
|
|
14
51
|
cachedIndex = null;
|
|
15
52
|
cachedTopicMap = null;
|
|
16
53
|
cacheTs = 0;
|
|
54
|
+
_searchCache.clear();
|
|
17
55
|
require.main.require('winston').info('[search-agent] Topic index cache invalidated.');
|
|
18
56
|
}
|
|
19
57
|
|
|
@@ -30,6 +68,8 @@ async function getSettings() {
|
|
|
30
68
|
openaiModel: (raw.openaiModel || 'gpt-4o-mini').trim(),
|
|
31
69
|
// How many TF-IDF candidates to send to AI for re-ranking
|
|
32
70
|
aiCandidates: Math.min(100, Math.max(5, parseInt(raw.aiCandidates, 10) || 30)),
|
|
71
|
+
// HyDE: generate a hypothetical answer before embedding — improves recall but costs one extra LLM call per search
|
|
72
|
+
hydeEnabled: raw.hydeEnabled === 'on',
|
|
33
73
|
// Visibility: 'all' = all logged-in users, 'admins' = administrators only
|
|
34
74
|
visibleTo: raw.visibleTo || 'all',
|
|
35
75
|
// Whether guests (non-logged-in users) may use the widget
|
|
@@ -141,18 +181,18 @@ function callOpenAI(apiKey, model, messages) {
|
|
|
141
181
|
* @param {string} model
|
|
142
182
|
* @returns {Promise<string>}
|
|
143
183
|
*/
|
|
144
|
-
async function expandQueryWithHyDE(queryText, apiKey, model) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
184
|
+
// async function expandQueryWithHyDE(queryText, apiKey, model) {
|
|
185
|
+
// const response = await callOpenAI(apiKey, model, [
|
|
186
|
+
// {
|
|
187
|
+
// role: 'system',
|
|
188
|
+
// content:
|
|
189
|
+
// 'אתה חבר בפורום. בהינתן שאלת חיפוש, כתוב פוסט תגובה קצר וריאליסטי בפורום שעונה ישירות על השאלה. ' +
|
|
190
|
+
// 'כתוב רק את תוכן הפוסט — ללא ברכות, הערות מטא, או שורת נושא.',
|
|
191
|
+
// },
|
|
192
|
+
// { role: 'user', content: queryText },
|
|
193
|
+
// ]);
|
|
194
|
+
// return (response.choices[0].message.content || '').trim() || queryText;
|
|
195
|
+
// }
|
|
156
196
|
|
|
157
197
|
/**
|
|
158
198
|
* Send candidates to OpenAI for independent per-topic relevance scoring.
|
|
@@ -163,25 +203,39 @@ async function expandQueryWithHyDE(queryText, apiKey, model) {
|
|
|
163
203
|
*/
|
|
164
204
|
async function reRankWithAI(queryText, candidates, topicMap, apiKey, model, maxResults, snippetByTid = {}) {
|
|
165
205
|
console.log('Re-ranking with AI:', { queryText, candidates: candidates.map(c => ({ tid: c.tid, title: (topicMap[String(c.tid)] || {}).title })) });
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
.
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
// Embed the query and all candidate post snippets
|
|
209
|
+
|
|
210
|
+
const { embed, embedBatch } = require('../services/embeddingService');
|
|
211
|
+
const queryEmbedding = await embed(queryText);
|
|
212
|
+
const postSnippets = candidates.map((c) => {
|
|
213
|
+
const raw = (snippetByTid[String(c.tid)] || '').replace(/<[^>]*>/g, ' ').replace(/[ \t]+/g, ' ').trim();
|
|
214
|
+
return raw.slice(0, 1500);
|
|
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');
|
|
174
222
|
|
|
175
223
|
const systemPrompt =
|
|
176
224
|
'אתה מסנן חיפוש פורום מחמיר. ' +
|
|
177
|
-
'לכל
|
|
225
|
+
'לכל מועמד ברשימה, דרג את הרלוונטיות של embedding הפוסט לembedding של השאלה בסקלה 0-10: ' +
|
|
178
226
|
'10 = עונה ישירות ובאופן מלא. 7-9 = עונה על חלק משמעותי. 0-6 = לא רלוונטי. ' +
|
|
179
227
|
'החזר אך ורק JSON תקני במבנה: {"tid": ציון, ...} — לדוגמה: {"42": 9, "15": 3}. ' +
|
|
180
|
-
'אין להוסיף הסברים, טקסט נוסף, או עיצוב מחוץ ל-JSON.'
|
|
181
|
-
|
|
182
|
-
|
|
228
|
+
'אין להוסיף הסברים, טקסט נוסף, או עיצוב מחוץ ל-JSON.';
|
|
229
|
+
|
|
183
230
|
const userMessage =
|
|
184
|
-
|
|
231
|
+
`embedding של שאלת המשתמש: [${queryEmbedding.slice(0, 8).map(x => x.toFixed(4)).join(', ')} ...]\n\nפוסטים:\n${candidateList}`;
|
|
232
|
+
|
|
233
|
+
// --- Token count logging ---
|
|
234
|
+
const totalEmbeddingChars = queryText.length + postSnippets.reduce((sum, s) => sum + s.length, 0);
|
|
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)`);
|
|
185
239
|
|
|
186
240
|
const response = await callOpenAI(apiKey, model, [
|
|
187
241
|
{ role: 'system', content: systemPrompt },
|
|
@@ -201,12 +255,25 @@ async function reRankWithAI(queryText, candidates, topicMap, apiKey, model, maxR
|
|
|
201
255
|
const scores = JSON.parse(match[0]);
|
|
202
256
|
const candidateByTid = Object.fromEntries(candidates.map(c => [String(c.tid), c]));
|
|
203
257
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
258
|
+
let filtered = Object.entries(scores)
|
|
259
|
+
.filter(([, score]) => Number(score) >= 7)
|
|
260
|
+
.sort(([, a], [, b]) => Number(b) - Number(a))
|
|
261
|
+
.slice(0, maxResults)
|
|
262
|
+
.map(([tid]) => candidateByTid[tid])
|
|
263
|
+
.filter(Boolean);
|
|
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;
|
|
210
277
|
}
|
|
211
278
|
|
|
212
279
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
@@ -220,6 +287,14 @@ async function reRankWithAI(queryText, candidates, topicMap, apiKey, model, maxR
|
|
|
220
287
|
*/
|
|
221
288
|
async function searchTopics(queryText) {
|
|
222
289
|
const winston = require.main.require('winston');
|
|
290
|
+
|
|
291
|
+
// ── Search result cache ───────────────────────────────────────────────────
|
|
292
|
+
const cachedResults = _getSearchCache(queryText);
|
|
293
|
+
if (cachedResults) {
|
|
294
|
+
winston.verbose(`[search-agent] Search cache hit for "${queryText}" (${cachedResults.length} results)`);
|
|
295
|
+
return cachedResults;
|
|
296
|
+
}
|
|
297
|
+
|
|
223
298
|
const settings = await getSettings();
|
|
224
299
|
|
|
225
300
|
// ── Semantic search (primary) ────────────────────────────────────────────
|
|
@@ -230,16 +305,16 @@ async function searchTopics(queryText) {
|
|
|
230
305
|
// HyDE: replace the short raw query with a hypothetical answer so the
|
|
231
306
|
// embedding matches post content more closely.
|
|
232
307
|
let embeddingQuery = queryText;
|
|
233
|
-
if (useAI) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
308
|
+
// if (useAI && settings.hydeEnabled) {
|
|
309
|
+
// try {
|
|
310
|
+
// embeddingQuery = await expandQueryWithHyDE(
|
|
311
|
+
// queryText, settings.openaiApiKey, settings.openaiModel
|
|
312
|
+
// );
|
|
313
|
+
// winston.verbose(`[search-agent] HyDE expanded query (${embeddingQuery.length} chars)`);
|
|
314
|
+
// } catch (hydeErr) {
|
|
315
|
+
// winston.warn(`[search-agent] HyDE expansion failed, using raw query: ${hydeErr.message}`);
|
|
316
|
+
// }
|
|
317
|
+
// }
|
|
243
318
|
|
|
244
319
|
// Request more candidates when AI will re-rank them.
|
|
245
320
|
const vectorLimit = useAI ? settings.aiCandidates : settings.maxResults;
|
|
@@ -314,6 +389,7 @@ async function searchTopics(queryText) {
|
|
|
314
389
|
|
|
315
390
|
if (results.length > 0) {
|
|
316
391
|
winston.info(`[search-agent] Semantic search returned ${results.length} results for "${queryText}".`);
|
|
392
|
+
_setSearchCache(queryText, results);
|
|
317
393
|
return results;
|
|
318
394
|
}
|
|
319
395
|
}
|
|
@@ -369,6 +445,7 @@ async function searchTopics(queryText) {
|
|
|
369
445
|
url: `/topic/${(topicMap[String(r.tid)] || {}).slug || r.tid}`,
|
|
370
446
|
}));
|
|
371
447
|
winston.info(`[search-agent] Final results: ${JSON.stringify(results.map(r => r.title))}`);
|
|
448
|
+
_setSearchCache(queryText, results);
|
|
372
449
|
return results;
|
|
373
450
|
}
|
|
374
451
|
|
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();
|
|
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();
|
|
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.94",
|
|
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",
|