@memberjunction/search-engine 5.32.0 → 5.34.0
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/dist/generic/BaseReRanker.d.ts +164 -0
- package/dist/generic/BaseReRanker.d.ts.map +1 -0
- package/dist/generic/BaseReRanker.js +209 -0
- package/dist/generic/BaseReRanker.js.map +1 -0
- package/dist/generic/EntitySearchProvider.d.ts +51 -3
- package/dist/generic/EntitySearchProvider.d.ts.map +1 -1
- package/dist/generic/EntitySearchProvider.js +130 -17
- package/dist/generic/EntitySearchProvider.js.map +1 -1
- package/dist/generic/FullTextSearchProvider.d.ts +7 -2
- package/dist/generic/FullTextSearchProvider.d.ts.map +1 -1
- package/dist/generic/FullTextSearchProvider.js +25 -4
- package/dist/generic/FullTextSearchProvider.js.map +1 -1
- package/dist/generic/ISearchProvider.d.ts +44 -2
- package/dist/generic/ISearchProvider.d.ts.map +1 -1
- package/dist/generic/ISearchProvider.js +35 -1
- package/dist/generic/ISearchProvider.js.map +1 -1
- package/dist/generic/NoopReRanker.d.ts +28 -0
- package/dist/generic/NoopReRanker.d.ts.map +1 -0
- package/dist/generic/NoopReRanker.js +49 -0
- package/dist/generic/NoopReRanker.js.map +1 -0
- package/dist/generic/ScopeTemplateRenderer.d.ts +36 -0
- package/dist/generic/ScopeTemplateRenderer.d.ts.map +1 -0
- package/dist/generic/ScopeTemplateRenderer.js +110 -0
- package/dist/generic/ScopeTemplateRenderer.js.map +1 -0
- package/dist/generic/SearchEngine.d.ts +180 -12
- package/dist/generic/SearchEngine.d.ts.map +1 -1
- package/dist/generic/SearchEngine.js +737 -35
- package/dist/generic/SearchEngine.js.map +1 -1
- package/dist/generic/SearchFusion.d.ts +40 -6
- package/dist/generic/SearchFusion.d.ts.map +1 -1
- package/dist/generic/SearchFusion.js +139 -18
- package/dist/generic/SearchFusion.js.map +1 -1
- package/dist/generic/StorageSearchProvider.d.ts +9 -2
- package/dist/generic/StorageSearchProvider.d.ts.map +1 -1
- package/dist/generic/StorageSearchProvider.js +44 -12
- package/dist/generic/StorageSearchProvider.js.map +1 -1
- package/dist/generic/VectorSearchProvider.d.ts +9 -2
- package/dist/generic/VectorSearchProvider.d.ts.map +1 -1
- package/dist/generic/VectorSearchProvider.js +83 -13
- package/dist/generic/VectorSearchProvider.js.map +1 -1
- package/dist/generic/search.types.d.ts +206 -0
- package/dist/generic/search.types.d.ts.map +1 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -1
- package/dist/permissions/SearchScopePermissionResolver.d.ts +109 -0
- package/dist/permissions/SearchScopePermissionResolver.d.ts.map +1 -0
- package/dist/permissions/SearchScopePermissionResolver.js +159 -0
- package/dist/permissions/SearchScopePermissionResolver.js.map +1 -0
- package/dist/providers/AzureAISearchProvider.d.ts +37 -0
- package/dist/providers/AzureAISearchProvider.d.ts.map +1 -0
- package/dist/providers/AzureAISearchProvider.js +180 -0
- package/dist/providers/AzureAISearchProvider.js.map +1 -0
- package/dist/providers/ElasticsearchSearchProvider.d.ts +43 -0
- package/dist/providers/ElasticsearchSearchProvider.d.ts.map +1 -0
- package/dist/providers/ElasticsearchSearchProvider.js +200 -0
- package/dist/providers/ElasticsearchSearchProvider.js.map +1 -0
- package/dist/providers/OpenSearchSearchProvider.d.ts +36 -0
- package/dist/providers/OpenSearchSearchProvider.d.ts.map +1 -0
- package/dist/providers/OpenSearchSearchProvider.js +167 -0
- package/dist/providers/OpenSearchSearchProvider.js.map +1 -0
- package/dist/providers/TypesenseSearchProvider.d.ts +36 -0
- package/dist/providers/TypesenseSearchProvider.d.ts.map +1 -0
- package/dist/providers/TypesenseSearchProvider.js +161 -0
- package/dist/providers/TypesenseSearchProvider.js.map +1 -0
- package/dist/rerankers/BGEReRanker.d.ts +57 -0
- package/dist/rerankers/BGEReRanker.d.ts.map +1 -0
- package/dist/rerankers/BGEReRanker.js +193 -0
- package/dist/rerankers/BGEReRanker.js.map +1 -0
- package/dist/rerankers/CohereReRanker.d.ts +65 -0
- package/dist/rerankers/CohereReRanker.d.ts.map +1 -0
- package/dist/rerankers/CohereReRanker.js +155 -0
- package/dist/rerankers/CohereReRanker.js.map +1 -0
- package/dist/rerankers/OpenAIReRanker.d.ts +62 -0
- package/dist/rerankers/OpenAIReRanker.d.ts.map +1 -0
- package/dist/rerankers/OpenAIReRanker.js +197 -0
- package/dist/rerankers/OpenAIReRanker.js.map +1 -0
- package/dist/rerankers/RerankerBudgetGuard.d.ts +54 -0
- package/dist/rerankers/RerankerBudgetGuard.d.ts.map +1 -0
- package/dist/rerankers/RerankerBudgetGuard.js +67 -0
- package/dist/rerankers/RerankerBudgetGuard.js.map +1 -0
- package/dist/rerankers/VoyageReRanker.d.ts +59 -0
- package/dist/rerankers/VoyageReRanker.d.ts.map +1 -0
- package/dist/rerankers/VoyageReRanker.js +184 -0
- package/dist/rerankers/VoyageReRanker.js.map +1 -0
- package/package.json +13 -8
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
* fuses results with Reciprocal Rank Fusion (RRF), applies enrichment
|
|
6
6
|
* (entity icons, record names, tags), and filters by minimum score.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* When `SearchParams.ScopeIDs` is provided, the engine resolves each scope against
|
|
9
|
+
* `SearchEngineBase` (which caches all `MJ: Search Scope*` metadata), builds a
|
|
10
|
+
* `ScopeConstraints` object per scope (including Nunjucks-rendered MetadataFilter,
|
|
11
|
+
* ExtraFilter, UserSearchString, and FolderPath values), runs each scope's providers
|
|
12
|
+
* in parallel, then fuses the per-scope results via cross-scope RRF. An optional
|
|
13
|
+
* re-ranker stage (`BaseReRanker`) runs after fusion when configured.
|
|
14
|
+
*
|
|
15
|
+
* Providers are loaded via `@RegisterClass(BaseSearchProvider, DriverClass)` and
|
|
9
16
|
* instantiated using the MJ ClassFactory based on active SearchProvider records.
|
|
10
17
|
*
|
|
11
18
|
* Uses BaseSingleton from @memberjunction/global for a truly global instance.
|
|
@@ -14,29 +21,43 @@
|
|
|
14
21
|
*/
|
|
15
22
|
import { EntityPermissionType, LogError, LogStatus, RunView } from '@memberjunction/core';
|
|
16
23
|
import { SearchEngineBase } from '@memberjunction/core-entities';
|
|
17
|
-
import { BaseSingleton, MJGlobal, NormalizeUUID } from '@memberjunction/global';
|
|
24
|
+
import { BaseSingleton, MJGlobal, NormalizeUUID, UUIDsEqual } from '@memberjunction/global';
|
|
18
25
|
import { BaseSearchProvider } from './ISearchProvider.js';
|
|
19
26
|
import { SearchFusion } from './SearchFusion.js';
|
|
20
27
|
import { SearchEnricher } from './SearchEnricher.js';
|
|
21
28
|
import { FullTextSearchProvider } from './FullTextSearchProvider.js';
|
|
29
|
+
import { BaseReRanker } from './BaseReRanker.js';
|
|
30
|
+
import { NoopReRanker, LoadNoopReRanker } from './NoopReRanker.js';
|
|
31
|
+
import { RerankerBudgetGuard } from '../rerankers/RerankerBudgetGuard.js';
|
|
32
|
+
import { RenderScopeTemplate, RenderScopeJsonTemplate } from './ScopeTemplateRenderer.js';
|
|
33
|
+
// Keep the default re-ranker registration alive under tree-shaking
|
|
34
|
+
LoadNoopReRanker();
|
|
22
35
|
/**
|
|
23
36
|
* Singleton search engine that orchestrates multi-source search with RRF fusion.
|
|
24
37
|
*
|
|
25
38
|
* Providers are discovered from the MJ: Search Providers entity. Each active
|
|
26
39
|
* provider's DriverClass is resolved via ClassFactory to create an instance,
|
|
27
|
-
* which is then initialized with the provider's config from the DB.
|
|
40
|
+
* which is then initialized with the provider's config from the DB record.
|
|
28
41
|
*
|
|
29
42
|
* Usage:
|
|
30
43
|
* ```typescript
|
|
31
44
|
* // Initialize once at server startup
|
|
32
45
|
* await SearchEngine.Instance.Config({}, contextUser);
|
|
33
46
|
*
|
|
34
|
-
* // Execute searches
|
|
47
|
+
* // Execute searches (unscoped — original behavior)
|
|
35
48
|
* const result = await SearchEngine.Instance.Search({
|
|
36
49
|
* Query: 'quarterly revenue',
|
|
37
50
|
* MaxResults: 20,
|
|
38
51
|
* MinScore: 0.1
|
|
39
52
|
* }, contextUser);
|
|
53
|
+
*
|
|
54
|
+
* // Scoped search against two scopes with multi-tenant context
|
|
55
|
+
* const scopedResult = await SearchEngine.Instance.Search({
|
|
56
|
+
* Query: 'refund policy',
|
|
57
|
+
* MaxResults: 20,
|
|
58
|
+
* ScopeIDs: ['hr-scope-id', 'legal-scope-id'],
|
|
59
|
+
* SearchContext: { PrimaryScopeRecordID: 'tenant-a' }
|
|
60
|
+
* }, contextUser);
|
|
40
61
|
* ```
|
|
41
62
|
*/
|
|
42
63
|
export class SearchEngine extends BaseSingleton {
|
|
@@ -48,11 +69,28 @@ export class SearchEngine extends BaseSingleton {
|
|
|
48
69
|
this._fusion = new SearchFusion();
|
|
49
70
|
this._enricher = new SearchEnricher();
|
|
50
71
|
this._defaultMaxResults = 20;
|
|
72
|
+
this._defaultOverfetchFactor = 2;
|
|
73
|
+
this._cache = new Map();
|
|
51
74
|
}
|
|
52
75
|
/** Static accessor for the global singleton instance */
|
|
53
76
|
static get Instance() {
|
|
54
77
|
return super.getInstance();
|
|
55
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Minimum trimmed query length we accept. One- and two-character queries against
|
|
81
|
+
* a `LIKE '%term%'` fan-out are essentially full-database scans with negligible
|
|
82
|
+
* relevance — the providers also enforce this, but we short-circuit here to
|
|
83
|
+
* avoid the cache lookup and provider dispatch overhead too.
|
|
84
|
+
*/
|
|
85
|
+
static { this.MIN_TERM_LENGTH = 3; }
|
|
86
|
+
/**
|
|
87
|
+
* Result cache TTL. 30s balances "user resubmits the same prefix" wins against
|
|
88
|
+
* "results stay reasonably fresh after a write". Cache key includes the user's
|
|
89
|
+
* ID so two users with different RLS scopes never share an entry.
|
|
90
|
+
*/
|
|
91
|
+
static { this.CACHE_TTL_MS = 30_000; }
|
|
92
|
+
/** Maximum cached entries across all users. LRU-evicted on overflow. */
|
|
93
|
+
static { this.CACHE_MAX_ENTRIES = 500; }
|
|
56
94
|
/** Access the cached provider metadata from SearchEngineBase */
|
|
57
95
|
get Base() {
|
|
58
96
|
return SearchEngineBase.Instance;
|
|
@@ -76,8 +114,9 @@ export class SearchEngine extends BaseSingleton {
|
|
|
76
114
|
if (this._configured && !forceRefresh)
|
|
77
115
|
return;
|
|
78
116
|
this._defaultMaxResults = config.DefaultMaxResults ?? 20;
|
|
117
|
+
this._defaultOverfetchFactor = Math.max(1, config.DefaultPermissionOverfetchFactor ?? 2);
|
|
79
118
|
this._providerEntries = [];
|
|
80
|
-
// Ensure SearchEngineBase has loaded provider metadata
|
|
119
|
+
// Ensure SearchEngineBase has loaded provider + scope metadata
|
|
81
120
|
await this.Base.Config(forceRefresh, contextUser);
|
|
82
121
|
const providerRecords = this.Base.ActiveProviders;
|
|
83
122
|
if (providerRecords.length === 0) {
|
|
@@ -95,29 +134,67 @@ export class SearchEngine extends BaseSingleton {
|
|
|
95
134
|
this._providerEntries.sort((a, b) => a.Priority - b.Priority);
|
|
96
135
|
this._configured = true;
|
|
97
136
|
const names = this._providerEntries.map(e => e.Provider.SourceType);
|
|
98
|
-
LogStatus(`SearchEngine: Configured with ${this._providerEntries.length} provider(s): ${names.join(', ')}`);
|
|
137
|
+
LogStatus(`SearchEngine: Configured with ${this._providerEntries.length} provider(s): ${names.join(', ')} and ${this.Base.Scopes.length} scope(s)`);
|
|
99
138
|
}
|
|
100
139
|
/**
|
|
101
140
|
* Execute a multi-source search with RRF fusion and optional enrichment.
|
|
102
141
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* 2. Fuse results with RRF
|
|
106
|
-
* 3. Deduplicate by EntityName+RecordID
|
|
107
|
-
* 4. Exclude redundant entity-sourced Content Items
|
|
108
|
-
* 5. Apply minimum score threshold
|
|
109
|
-
* 6. Enrich with icons, names, and tags (skipped in preview mode)
|
|
142
|
+
* When `params.ScopeIDs` is provided, each scope runs independently and the results
|
|
143
|
+
* are combined via cross-scope RRF before deduplication, re-ranking, and enrichment.
|
|
110
144
|
*
|
|
111
145
|
* @param params - Search parameters
|
|
112
146
|
* @param contextUser - The user performing the search
|
|
113
147
|
* @returns Aggregated search result
|
|
114
148
|
*/
|
|
115
149
|
async Search(params, contextUser) {
|
|
150
|
+
return this.searchInternal(params, contextUser);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Internal search implementation that optionally fires `onProviderResolved`
|
|
154
|
+
* as each provider's promise settles. Exposed via the public {@link Search}
|
|
155
|
+
* (no callback) and {@link streamSearch} (queue-backed callback that
|
|
156
|
+
* yields `provider` events to the caller).
|
|
157
|
+
*/
|
|
158
|
+
async searchInternal(params, contextUser, onProviderResolved) {
|
|
116
159
|
const startTime = Date.now();
|
|
160
|
+
// Per-invocation tracking for the post-search SearchExecutionLog row (P3.2).
|
|
161
|
+
let invocationBudgetGuard = null;
|
|
162
|
+
let invocationRerankerName = null;
|
|
117
163
|
try {
|
|
118
|
-
|
|
164
|
+
// Defensive null-check: `params.Query.trim()` throws on null/undefined,
|
|
165
|
+
// and Sage's LLM has been observed to emit empty/missing tool args.
|
|
166
|
+
// Coerce to string before validating so we surface a clean error
|
|
167
|
+
// instead of a TypeError that would also skip the audit-log row.
|
|
168
|
+
if (params.Query == null || typeof params.Query !== 'string' || !params.Query.trim()) {
|
|
169
|
+
this.logSearchExecution({
|
|
170
|
+
Status: 'Failure',
|
|
171
|
+
FailureReason: 'Query cannot be empty',
|
|
172
|
+
Query: typeof params.Query === 'string' ? params.Query : '',
|
|
173
|
+
ScopeIDs: params.ScopeIDs,
|
|
174
|
+
StartTime: startTime,
|
|
175
|
+
ResultCount: 0,
|
|
176
|
+
RerankerName: null,
|
|
177
|
+
RerankerCostCents: null,
|
|
178
|
+
SourceCounts: undefined,
|
|
179
|
+
ContextUser: contextUser,
|
|
180
|
+
AIAgentID: params.AIAgentID ?? null,
|
|
181
|
+
});
|
|
119
182
|
return this.buildErrorResult('Query cannot be empty', startTime);
|
|
120
183
|
}
|
|
184
|
+
const trimmed = params.Query.trim();
|
|
185
|
+
if (trimmed.length < SearchEngine.MIN_TERM_LENGTH) {
|
|
186
|
+
// Short queries hit unindexed table-scans fanned out across every
|
|
187
|
+
// searchable entity with negligible relevance. Return an empty
|
|
188
|
+
// success result rather than burning resources.
|
|
189
|
+
return {
|
|
190
|
+
Success: true,
|
|
191
|
+
Results: [],
|
|
192
|
+
TotalCount: 0,
|
|
193
|
+
ElapsedMs: Date.now() - startTime,
|
|
194
|
+
SourceCounts: { Vector: 0, FullText: 0, Entity: 0, Storage: 0 },
|
|
195
|
+
Providers: this._configured ? this.buildProviderInfoList() : [],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
121
198
|
// Ensure configured
|
|
122
199
|
if (!this._configured) {
|
|
123
200
|
await this.Config({}, contextUser);
|
|
@@ -125,28 +202,114 @@ export class SearchEngine extends BaseSingleton {
|
|
|
125
202
|
const topK = params.MaxResults ?? this._defaultMaxResults;
|
|
126
203
|
const mode = params.Mode ?? 'full';
|
|
127
204
|
const isPreview = mode === 'preview';
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
//
|
|
205
|
+
const overfetchFactor = Math.max(1, params.PermissionOverfetchFactor ?? this._defaultOverfetchFactor);
|
|
206
|
+
const providerTopK = Math.max(topK, Math.ceil(topK * overfetchFactor));
|
|
207
|
+
// ──────────────────────────────────────────────────────────
|
|
208
|
+
// Resolve scopes (when supplied)
|
|
209
|
+
// ──────────────────────────────────────────────────────────
|
|
210
|
+
const resolvedScopes = this.resolveScopes(params.ScopeIDs);
|
|
211
|
+
const isUnconstrained = resolvedScopes.length === 0 || resolvedScopes.some(s => s.Scope.IsGlobal);
|
|
212
|
+
// ──────────────────────────────────────────────────────────
|
|
213
|
+
// Cache lookup (next PR #2532). Skip preview searches — they're already
|
|
214
|
+
// cheap and caching would mask config changes during dev. Cache key
|
|
215
|
+
// includes the user's ID so two users with different RLS scopes never
|
|
216
|
+
// share an entry.
|
|
217
|
+
// ──────────────────────────────────────────────────────────
|
|
218
|
+
const cacheKey = isPreview ? null : this.buildCacheKey(trimmed, params, contextUser);
|
|
219
|
+
if (cacheKey) {
|
|
220
|
+
const hit = this._cache.get(cacheKey);
|
|
221
|
+
if (hit && hit.expires > Date.now()) {
|
|
222
|
+
// Move to end for LRU recency
|
|
223
|
+
this._cache.delete(cacheKey);
|
|
224
|
+
this._cache.set(cacheKey, hit);
|
|
225
|
+
return { ...hit.result, ElapsedMs: Date.now() - startTime };
|
|
226
|
+
}
|
|
227
|
+
if (hit)
|
|
228
|
+
this._cache.delete(cacheKey); // expired
|
|
229
|
+
}
|
|
230
|
+
// ──────────────────────────────────────────────────────────
|
|
231
|
+
// Execute providers — either unscoped (original path) or per-scope
|
|
232
|
+
// ──────────────────────────────────────────────────────────
|
|
233
|
+
let sourceCounts;
|
|
234
|
+
let fusedResults;
|
|
235
|
+
if (isUnconstrained) {
|
|
236
|
+
const labeledLists = await this.executeProviders(params.Query, providerTopK, params.Filters, contextUser, isPreview, undefined, onProviderResolved);
|
|
237
|
+
sourceCounts = this.countSources(labeledLists);
|
|
238
|
+
const defaultFusionWeights = params.FusionWeightsOverride;
|
|
239
|
+
fusedResults = this._fusion.Fuse(labeledLists, providerTopK, defaultFusionWeights);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
// Run each scope independently, then cross-scope RRF
|
|
243
|
+
const perScopeRunResults = await Promise.all(resolvedScopes.map(bundle => this.executeScopeBundle(params.Query, providerTopK, params.Filters, contextUser, isPreview, bundle, params.SearchContext, params.FusionWeightsOverride, onProviderResolved)));
|
|
244
|
+
const sc = { Vector: 0, FullText: 0, Entity: 0, Storage: 0 };
|
|
245
|
+
const perScopeFused = new Map();
|
|
246
|
+
for (const r of perScopeRunResults) {
|
|
247
|
+
sc.Vector += r.sourceCounts.Vector;
|
|
248
|
+
sc.FullText += r.sourceCounts.FullText;
|
|
249
|
+
sc.Entity += r.sourceCounts.Entity;
|
|
250
|
+
sc.Storage += r.sourceCounts.Storage;
|
|
251
|
+
perScopeFused.set(r.scopeID, r.fused);
|
|
252
|
+
}
|
|
253
|
+
sourceCounts = sc;
|
|
254
|
+
if (perScopeFused.size > 1) {
|
|
255
|
+
fusedResults = this._fusion.CrossScopeFusion(perScopeFused, providerTopK);
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
const only = perScopeFused.values().next().value;
|
|
259
|
+
fusedResults = only ?? [];
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// ──────────────────────────────────────────────────────────
|
|
263
|
+
// Optional re-ranker stage (one per leading scope — pick the first active scope's config)
|
|
264
|
+
// ──────────────────────────────────────────────────────────
|
|
265
|
+
const reRankerConfig = this.pickReRankerConfig(resolvedScopes);
|
|
266
|
+
if (reRankerConfig?.driverClass) {
|
|
267
|
+
// Budget guard (P2D.6): cap real-provider rerank spend at the scope's
|
|
268
|
+
// RerankerBudgetCents. Pulled from the same scope that supplied the
|
|
269
|
+
// reranker config — keeping the policy local to the scope that opted in.
|
|
270
|
+
const budgetCents = this.pickRerankerBudgetCents(resolvedScopes);
|
|
271
|
+
invocationBudgetGuard = new RerankerBudgetGuard(budgetCents);
|
|
272
|
+
invocationRerankerName = reRankerConfig.driverClass;
|
|
273
|
+
fusedResults = await this.runReRanker(params.Query, fusedResults, reRankerConfig, contextUser, invocationBudgetGuard);
|
|
274
|
+
}
|
|
275
|
+
// ──────────────────────────────────────────────────────────
|
|
276
|
+
// Dedup → content-item exclusion → permission safety net → score threshold → enrich
|
|
277
|
+
// ──────────────────────────────────────────────────────────
|
|
278
|
+
let results = this._fusion.Deduplicate(fusedResults);
|
|
136
279
|
results = await this._enricher.ExcludeEntitySourcedContentItems(results, contextUser);
|
|
137
|
-
|
|
280
|
+
const beforePermCount = results.length;
|
|
138
281
|
results = await this.filterByPermissions(results, contextUser);
|
|
139
|
-
|
|
282
|
+
const lateFilteredCount = beforePermCount - results.length;
|
|
283
|
+
if (lateFilteredCount > 0) {
|
|
284
|
+
// Observability: Section 3.6 — if a provider's push-down is complete, this
|
|
285
|
+
// number should be zero (the safety net should never trim anything).
|
|
286
|
+
LogStatus(`SearchEngine: Residual permission filter removed ${lateFilteredCount} result(s) — consider tightening provider push-down.`);
|
|
287
|
+
}
|
|
140
288
|
const scoreThreshold = params.MinScore ?? 0;
|
|
141
289
|
if (scoreThreshold > 0) {
|
|
142
290
|
results = results.filter(r => r.Score >= scoreThreshold);
|
|
143
291
|
}
|
|
144
|
-
//
|
|
292
|
+
// Trim to caller's requested topK (we overfetched earlier)
|
|
293
|
+
if (results.length > topK)
|
|
294
|
+
results = results.slice(0, topK);
|
|
145
295
|
if (!isPreview) {
|
|
146
296
|
await this._enricher.Enrich(results, contextUser);
|
|
147
297
|
}
|
|
148
|
-
LogStatus(`SearchEngine: Search complete in ${Date.now() - startTime}ms - ${results.length}
|
|
149
|
-
|
|
298
|
+
LogStatus(`SearchEngine: Search complete in ${Date.now() - startTime}ms - ${results.length} result(s)${resolvedScopes.length ? ` across ${resolvedScopes.length} scope(s)` : ''}`);
|
|
299
|
+
this.logSearchExecution({
|
|
300
|
+
Status: 'Success',
|
|
301
|
+
FailureReason: null,
|
|
302
|
+
Query: params.Query,
|
|
303
|
+
ScopeIDs: params.ScopeIDs,
|
|
304
|
+
StartTime: startTime,
|
|
305
|
+
ResultCount: results.length,
|
|
306
|
+
RerankerName: invocationRerankerName,
|
|
307
|
+
RerankerCostCents: invocationBudgetGuard ? invocationBudgetGuard.Spent : null,
|
|
308
|
+
SourceCounts: sourceCounts,
|
|
309
|
+
ContextUser: contextUser,
|
|
310
|
+
AIAgentID: params.AIAgentID ?? null,
|
|
311
|
+
});
|
|
312
|
+
const finalResult = {
|
|
150
313
|
Success: true,
|
|
151
314
|
Results: results,
|
|
152
315
|
TotalCount: results.length,
|
|
@@ -154,13 +317,200 @@ export class SearchEngine extends BaseSingleton {
|
|
|
154
317
|
SourceCounts: sourceCounts,
|
|
155
318
|
Providers: this.buildProviderInfoList(),
|
|
156
319
|
};
|
|
320
|
+
if (cacheKey) {
|
|
321
|
+
this.cachePut(cacheKey, finalResult);
|
|
322
|
+
}
|
|
323
|
+
return finalResult;
|
|
157
324
|
}
|
|
158
325
|
catch (error) {
|
|
159
326
|
const msg = error instanceof Error ? error.message : String(error);
|
|
160
327
|
LogError(`SearchEngine: Search failed: ${msg}`);
|
|
328
|
+
this.logSearchExecution({
|
|
329
|
+
Status: 'Failure',
|
|
330
|
+
FailureReason: msg,
|
|
331
|
+
Query: params.Query,
|
|
332
|
+
ScopeIDs: params.ScopeIDs,
|
|
333
|
+
StartTime: startTime,
|
|
334
|
+
ResultCount: 0,
|
|
335
|
+
RerankerName: invocationRerankerName,
|
|
336
|
+
RerankerCostCents: invocationBudgetGuard ? invocationBudgetGuard.Spent : null,
|
|
337
|
+
SourceCounts: undefined,
|
|
338
|
+
ContextUser: contextUser,
|
|
339
|
+
AIAgentID: params.AIAgentID ?? null,
|
|
340
|
+
});
|
|
161
341
|
return this.buildErrorResult(msg, startTime);
|
|
162
342
|
}
|
|
163
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* Streaming variant of {@link Search}. Yields events as each pipeline
|
|
346
|
+
* stage produces output so the caller can emit partials to the UI / agent
|
|
347
|
+
* before fusion + reranking complete.
|
|
348
|
+
*
|
|
349
|
+
* **Phase 2C v1 semantics:** runs the same internal pipeline as
|
|
350
|
+
* {@link Search} and emits synthetic events at each transition. This
|
|
351
|
+
* preserves all existing fusion / permission / dedup / enrich behavior
|
|
352
|
+
* — important because those steps have subtle correctness rules that
|
|
353
|
+
* we don't want to re-implement in a parallel code path. Per-provider
|
|
354
|
+
* partials are reconstructed from the final SourceCounts; a future
|
|
355
|
+
* refactor (Phase 2C v2) can split provider emission to true real-time
|
|
356
|
+
* concurrent emission once we measure that the synthetic phase is the
|
|
357
|
+
* actual bottleneck.
|
|
358
|
+
*
|
|
359
|
+
* Cancellation: the consumer can stop iterating at any point — the
|
|
360
|
+
* underlying Search() will run to completion but its result is
|
|
361
|
+
* discarded. AbortSignal-based mid-pipeline cancellation is a Phase 2C
|
|
362
|
+
* v2 concern.
|
|
363
|
+
*
|
|
364
|
+
* Event ordering:
|
|
365
|
+
* 1. Zero or more `provider` events (one per non-empty source)
|
|
366
|
+
* 2. Exactly one `fused` event
|
|
367
|
+
* 3. Optional one `reranked` event (when a reranker is configured)
|
|
368
|
+
* 4. Exactly one `final` event
|
|
369
|
+
* 5. On error: a single `error` event in place of `final`.
|
|
370
|
+
*
|
|
371
|
+
* @example
|
|
372
|
+
* for await (const ev of SearchEngine.Instance.streamSearch(params, user)) {
|
|
373
|
+
* switch (ev.phase) {
|
|
374
|
+
* case 'provider': scratchpad.append(`${ev.providerName}: ${ev.results.length} hits`); break;
|
|
375
|
+
* case 'final': scratchpad.commit(ev.results); break;
|
|
376
|
+
* case 'error': scratchpad.fail(ev.error); break;
|
|
377
|
+
* }
|
|
378
|
+
* }
|
|
379
|
+
*/
|
|
380
|
+
async *streamSearch(params, contextUser) {
|
|
381
|
+
// Phase 2C v2: true concurrent emission. The internal search is run
|
|
382
|
+
// with an `onProviderResolved` callback that pushes a `provider`
|
|
383
|
+
// event into a queue the moment each provider's promise settles.
|
|
384
|
+
// The generator drains the queue while the search keeps running, so
|
|
385
|
+
// `provider` events arrive as fast as their providers resolve. After
|
|
386
|
+
// the search completes (or errors) we emit `fused` + `final` (or
|
|
387
|
+
// `error`) and close the iterator.
|
|
388
|
+
//
|
|
389
|
+
// Cancellation: if the consumer breaks out of `for await`, the
|
|
390
|
+
// generator's `return()` runs and the underlying search is allowed
|
|
391
|
+
// to finish in the background (its result is discarded). Mid-pipeline
|
|
392
|
+
// AbortSignal propagation is a future enhancement.
|
|
393
|
+
const queue = [];
|
|
394
|
+
let resolveNext = null;
|
|
395
|
+
let done = false;
|
|
396
|
+
const push = (ev) => {
|
|
397
|
+
queue.push(ev);
|
|
398
|
+
if (resolveNext) {
|
|
399
|
+
const r = resolveNext;
|
|
400
|
+
resolveNext = null;
|
|
401
|
+
r();
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
const finish = () => {
|
|
405
|
+
done = true;
|
|
406
|
+
if (resolveNext) {
|
|
407
|
+
const r = resolveNext;
|
|
408
|
+
resolveNext = null;
|
|
409
|
+
r();
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
// Source-type → friendly provider label mapping for stable UI display.
|
|
413
|
+
// Keys are lowercased to match what providers report.
|
|
414
|
+
const sourceTypeToLabel = {
|
|
415
|
+
vector: 'Vector',
|
|
416
|
+
fulltext: 'FullText',
|
|
417
|
+
entity: 'Entity',
|
|
418
|
+
storage: 'Storage',
|
|
419
|
+
};
|
|
420
|
+
const onProviderResolved = (ev) => {
|
|
421
|
+
const label = sourceTypeToLabel[ev.sourceType.toLowerCase()] ?? ev.sourceType;
|
|
422
|
+
push({
|
|
423
|
+
phase: 'provider',
|
|
424
|
+
providerName: label,
|
|
425
|
+
results: ev.results,
|
|
426
|
+
durationMs: ev.durationMs,
|
|
427
|
+
});
|
|
428
|
+
};
|
|
429
|
+
// Kick off the search; do NOT await it here — we want the generator
|
|
430
|
+
// loop below to interleave with the provider callbacks.
|
|
431
|
+
const searchPromise = (async () => {
|
|
432
|
+
try {
|
|
433
|
+
const result = await this.searchInternal(params, contextUser, onProviderResolved);
|
|
434
|
+
if (!result.Success) {
|
|
435
|
+
push({ phase: 'error', error: result.ErrorMessage ?? 'Search failed' });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
push({ phase: 'fused', results: result.Results });
|
|
439
|
+
// Reranker emission is intentionally elided here — the engine's
|
|
440
|
+
// post-fusion rerank fires inside `searchInternal` before this
|
|
441
|
+
// point, and observers seeking that signal should look at the
|
|
442
|
+
// final SearchExecutionLog row. Keeping this generator narrow
|
|
443
|
+
// avoids leaking rerank internals into a streaming surface that
|
|
444
|
+
// can't faithfully separate them from fusion.
|
|
445
|
+
push({
|
|
446
|
+
phase: 'final',
|
|
447
|
+
results: result.Results,
|
|
448
|
+
sourceCounts: result.SourceCounts,
|
|
449
|
+
elapsedMs: result.ElapsedMs,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
push({ phase: 'error', error: err instanceof Error ? err.message : String(err) });
|
|
454
|
+
}
|
|
455
|
+
finally {
|
|
456
|
+
finish();
|
|
457
|
+
}
|
|
458
|
+
})();
|
|
459
|
+
// Drain the queue. The loop blocks on `resolveNext` between bursts
|
|
460
|
+
// so that we don't busy-spin while waiting for providers.
|
|
461
|
+
try {
|
|
462
|
+
while (true) {
|
|
463
|
+
if (queue.length > 0) {
|
|
464
|
+
yield queue.shift();
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (done) {
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
await new Promise((resolve) => {
|
|
471
|
+
resolveNext = resolve;
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
finally {
|
|
476
|
+
// If the consumer aborts mid-iteration, surface any background
|
|
477
|
+
// error so it isn't silently swallowed. We don't await the
|
|
478
|
+
// promise on the happy path because `done` already implies it
|
|
479
|
+
// settled.
|
|
480
|
+
if (!done) {
|
|
481
|
+
searchPromise.catch(() => { });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Build a stable cache key for a search. Includes the user identity so RLS
|
|
487
|
+
* scopes never bleed across users, plus the trimmed query, MaxResults,
|
|
488
|
+
* MinScore, and a deterministic projection of Filters.
|
|
489
|
+
*/
|
|
490
|
+
buildCacheKey(trimmed, params, contextUser) {
|
|
491
|
+
const userKey = contextUser?.ID ?? 'anonymous';
|
|
492
|
+
const f = params.Filters ?? {};
|
|
493
|
+
const filterKey = JSON.stringify({
|
|
494
|
+
EntityNames: f.EntityNames ? [...f.EntityNames].sort() : undefined,
|
|
495
|
+
SourceTypes: f.SourceTypes ? [...f.SourceTypes].sort() : undefined,
|
|
496
|
+
Tags: f.Tags ? [...f.Tags].sort() : undefined,
|
|
497
|
+
});
|
|
498
|
+
return `${userKey}|${trimmed}|${params.MaxResults ?? this._defaultMaxResults}|${params.MinScore ?? 0}|${filterKey}`;
|
|
499
|
+
}
|
|
500
|
+
/** Insert into the LRU cache, evicting oldest entries when over capacity. */
|
|
501
|
+
cachePut(key, result) {
|
|
502
|
+
if (this._cache.size >= SearchEngine.CACHE_MAX_ENTRIES) {
|
|
503
|
+
// Map iteration order is insertion order — the first entry is oldest.
|
|
504
|
+
const oldest = this._cache.keys().next().value;
|
|
505
|
+
if (oldest !== undefined)
|
|
506
|
+
this._cache.delete(oldest);
|
|
507
|
+
}
|
|
508
|
+
this._cache.set(key, { result, expires: Date.now() + SearchEngine.CACHE_TTL_MS });
|
|
509
|
+
}
|
|
510
|
+
/** Test / admin hook: clear the result cache. */
|
|
511
|
+
ClearResultCache() {
|
|
512
|
+
this._cache.clear();
|
|
513
|
+
}
|
|
164
514
|
/**
|
|
165
515
|
* Quick preview search optimized for autocomplete / typeahead.
|
|
166
516
|
* Uses preview mode (no enrichment), limited to 8 results by default.
|
|
@@ -179,6 +529,239 @@ export class SearchEngine extends BaseSingleton {
|
|
|
179
529
|
}, contextUser);
|
|
180
530
|
}
|
|
181
531
|
// ────────────────────────────────────────────────────────────────
|
|
532
|
+
// Scope resolution
|
|
533
|
+
// ────────────────────────────────────────────────────────────────
|
|
534
|
+
/**
|
|
535
|
+
* Load `ScopeBundle`s for each requested scope ID, filtering out inactive / expired.
|
|
536
|
+
* Returns an empty array when no scope IDs are supplied (caller treats as Global).
|
|
537
|
+
*/
|
|
538
|
+
resolveScopes(scopeIDs) {
|
|
539
|
+
if (!scopeIDs || scopeIDs.length === 0)
|
|
540
|
+
return [];
|
|
541
|
+
const bundles = [];
|
|
542
|
+
for (const id of scopeIDs) {
|
|
543
|
+
const scope = this.Base.GetActiveScopeByID(id);
|
|
544
|
+
if (!scope) {
|
|
545
|
+
LogStatus(`SearchEngine: Requested scope "${id}" is not active or does not exist — skipping.`);
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
const bundle = this.Base.GetScopeBundle(id);
|
|
549
|
+
if (bundle)
|
|
550
|
+
bundles.push(bundle);
|
|
551
|
+
}
|
|
552
|
+
return bundles;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Execute all scoped providers for a single scope bundle and return per-scope fused results.
|
|
556
|
+
*/
|
|
557
|
+
async executeScopeBundle(query, topK, filters, contextUser, isPreview, bundle, searchContext, agentFusionWeights, onProviderResolved) {
|
|
558
|
+
const scope = bundle.Scope;
|
|
559
|
+
const scopeConfig = this.parseJson(scope.ScopeConfig);
|
|
560
|
+
const constraints = this.buildScopeConstraints(bundle, searchContext);
|
|
561
|
+
const perProviderQueryTransforms = constraints.QueryTransforms ?? {};
|
|
562
|
+
// Determine which providers this scope participates in (SearchScopeProvider rows)
|
|
563
|
+
const scopeProviderIDs = new Set(bundle.Providers.map(p => NormalizeUUID(p.SearchProviderID)));
|
|
564
|
+
const allowAllProviders = scopeProviderIDs.size === 0; // empty = scope is IsGlobal or all-inclusive
|
|
565
|
+
const applicableProviders = this._providerEntries.filter(entry => {
|
|
566
|
+
if (!entry.Provider.IsAvailable())
|
|
567
|
+
return false;
|
|
568
|
+
if (isPreview && !entry.SupportsPreview)
|
|
569
|
+
return false;
|
|
570
|
+
if (allowAllProviders)
|
|
571
|
+
return true;
|
|
572
|
+
return scopeProviderIDs.has(NormalizeUUID(entry.ID));
|
|
573
|
+
});
|
|
574
|
+
if (applicableProviders.length === 0) {
|
|
575
|
+
LogStatus(`SearchEngine: Scope "${scope.Name}" has no applicable providers — skipping.`);
|
|
576
|
+
return {
|
|
577
|
+
scopeID: scope.ID,
|
|
578
|
+
fused: [],
|
|
579
|
+
sourceCounts: { Vector: 0, FullText: 0, Entity: 0, Storage: 0 }
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
// Resolve per-provider `SearchScopeProvider.MaxResultsOverride` if present
|
|
583
|
+
const promises = applicableProviders.map(async (entry) => {
|
|
584
|
+
const providerStart = Date.now();
|
|
585
|
+
try {
|
|
586
|
+
const spRow = bundle.Providers.find(r => UUIDsEqual(r.SearchProviderID, entry.ID));
|
|
587
|
+
const effectiveTopK = spRow?.MaxResultsOverride ?? entry.MaxResultsOverride ?? topK;
|
|
588
|
+
// If this provider has a per-provider QueryTransform override, stash it
|
|
589
|
+
// under the provider's SourceType in QueryTransforms so the provider finds it.
|
|
590
|
+
const perProviderConstraints = {
|
|
591
|
+
...constraints,
|
|
592
|
+
QueryTransforms: { ...perProviderQueryTransforms }
|
|
593
|
+
};
|
|
594
|
+
// (Note: actual Nunjucks-rendered `QueryTransformTemplateID` resolution for
|
|
595
|
+
// stored templates lives in AgentPreExecutionRAG/ScopedSearchAction, not here.
|
|
596
|
+
// This engine only forwards already-rendered strings that the caller provides.)
|
|
597
|
+
const providerResults = await entry.Provider.Search(query, effectiveTopK, filters, contextUser, perProviderConstraints);
|
|
598
|
+
// Stamp provider metadata onto each result
|
|
599
|
+
for (const r of providerResults) {
|
|
600
|
+
r.ProviderId = entry.ID;
|
|
601
|
+
r.ProviderLabel = entry.DisplayName;
|
|
602
|
+
r.ProviderIcon = entry.Icon;
|
|
603
|
+
}
|
|
604
|
+
if (onProviderResolved) {
|
|
605
|
+
try {
|
|
606
|
+
onProviderResolved({
|
|
607
|
+
sourceType: entry.Provider.SourceType,
|
|
608
|
+
results: providerResults,
|
|
609
|
+
durationMs: Date.now() - providerStart,
|
|
610
|
+
scopeID: scope.ID,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
catch (cbErr) {
|
|
614
|
+
const cbMsg = cbErr instanceof Error ? cbErr.message : String(cbErr);
|
|
615
|
+
LogError(`SearchEngine: onProviderResolved callback threw for "${entry.Provider.SourceType}" in scope "${scope.Name}": ${cbMsg}`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return { Source: entry.Provider.SourceType, Results: providerResults };
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
622
|
+
LogError(`SearchEngine: Provider "${entry.Provider.SourceType}" failed in scope "${scope.Name}": ${msg}`);
|
|
623
|
+
if (onProviderResolved) {
|
|
624
|
+
try {
|
|
625
|
+
onProviderResolved({
|
|
626
|
+
sourceType: entry.Provider.SourceType,
|
|
627
|
+
results: [],
|
|
628
|
+
durationMs: Date.now() - providerStart,
|
|
629
|
+
scopeID: scope.ID,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
catch { /* swallow */ }
|
|
633
|
+
}
|
|
634
|
+
return { Source: entry.Provider.SourceType, Results: [] };
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
const labeled = await Promise.all(promises);
|
|
638
|
+
const sourceCounts = this.countSources(labeled);
|
|
639
|
+
// Per-scope fusion with weight resolution:
|
|
640
|
+
// agent fusion weights > scope.ScopeConfig.fusionWeights > engine defaults
|
|
641
|
+
const scopeWeights = scopeConfig && typeof scopeConfig.fusionWeights === 'object'
|
|
642
|
+
? scopeConfig.fusionWeights
|
|
643
|
+
: undefined;
|
|
644
|
+
const fusionWeights = agentFusionWeights ?? scopeWeights;
|
|
645
|
+
const fused = this._fusion.Fuse(labeled, topK, fusionWeights);
|
|
646
|
+
return { scopeID: scope.ID, fused, sourceCounts };
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Assemble a `ScopeConstraints` for a single scope: Nunjucks-render each template
|
|
650
|
+
* field against the `SearchContext`, then hand the rendered values to providers.
|
|
651
|
+
*/
|
|
652
|
+
buildScopeConstraints(bundle, searchContext) {
|
|
653
|
+
const externalIndexes = bundle.ExternalIndexes.map(row => ({
|
|
654
|
+
SearchScopeExternalIndexID: row.ID,
|
|
655
|
+
IndexType: row.IndexType,
|
|
656
|
+
VectorIndexID: row.VectorIndexID ?? undefined,
|
|
657
|
+
ExternalIndexName: row.ExternalIndexName ?? undefined,
|
|
658
|
+
ExternalIndexConfig: this.parseJson(row.ExternalIndexConfig),
|
|
659
|
+
MetadataFilter: RenderScopeJsonTemplate(row.MetadataFilter, searchContext)
|
|
660
|
+
}));
|
|
661
|
+
const entities = bundle.Entities.map(row => ({
|
|
662
|
+
SearchScopeEntityID: row.ID,
|
|
663
|
+
EntityID: row.EntityID,
|
|
664
|
+
EntityName: this.lookupEntityName(row.EntityID),
|
|
665
|
+
ExtraFilter: row.ExtraFilter ? RenderScopeTemplate(row.ExtraFilter, searchContext) : undefined,
|
|
666
|
+
UserSearchString: row.UserSearchString ? RenderScopeTemplate(row.UserSearchString, searchContext) : undefined
|
|
667
|
+
}));
|
|
668
|
+
const storage = bundle.StorageAccounts.map(row => ({
|
|
669
|
+
SearchScopeStorageAccountID: row.ID,
|
|
670
|
+
FileStorageAccountID: row.FileStorageAccountID,
|
|
671
|
+
FolderPath: row.FolderPath ? RenderScopeTemplate(row.FolderPath, searchContext) : undefined
|
|
672
|
+
}));
|
|
673
|
+
// Per-provider query transforms: resolved from SearchScopeProvider.QueryTransformTemplateID
|
|
674
|
+
// For stored template IDs we need the TemplateEngine — that resolution happens in
|
|
675
|
+
// Phase 1C (AgentPreExecutionRAG) before this engine is called. We still honor any
|
|
676
|
+
// pre-rendered transforms that upstream callers placed in the scope config bag.
|
|
677
|
+
const scopeConfig = this.parseJson(bundle.Scope.ScopeConfig);
|
|
678
|
+
const rawTransforms = scopeConfig?.perProviderQueryTransforms;
|
|
679
|
+
const queryTransforms = rawTransforms && typeof rawTransforms === 'object'
|
|
680
|
+
? { ...rawTransforms }
|
|
681
|
+
: undefined;
|
|
682
|
+
return {
|
|
683
|
+
ExternalIndexes: externalIndexes.length ? externalIndexes : undefined,
|
|
684
|
+
Entities: entities.length ? entities : undefined,
|
|
685
|
+
StorageAccounts: storage.length ? storage : undefined,
|
|
686
|
+
Context: searchContext,
|
|
687
|
+
QueryTransforms: queryTransforms,
|
|
688
|
+
ScopeConfig: scopeConfig ?? undefined
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
/** Resolve the EntityID → EntityName via MJ Metadata (for passing to providers that key by name). */
|
|
692
|
+
lookupEntityName(entityID) {
|
|
693
|
+
try {
|
|
694
|
+
const entity = this.ProviderToUse.Entities.find(e => UUIDsEqual(e.ID, entityID));
|
|
695
|
+
return entity?.Name ?? '';
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
return '';
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
// ────────────────────────────────────────────────────────────────
|
|
702
|
+
// Re-ranker
|
|
703
|
+
// ────────────────────────────────────────────────────────────────
|
|
704
|
+
/**
|
|
705
|
+
* Pick a re-ranker config for this search. When multiple scopes are in play, we
|
|
706
|
+
* use the first scope's config (matching task 1B.17: the re-rank stage is one
|
|
707
|
+
* call applied AFTER cross-scope fusion). A future enhancement could merge
|
|
708
|
+
* per-scope re-rankers, but the current plan keeps it simple.
|
|
709
|
+
*/
|
|
710
|
+
pickReRankerConfig(resolvedScopes) {
|
|
711
|
+
for (const bundle of resolvedScopes) {
|
|
712
|
+
const scopeConfig = this.parseJson(bundle.Scope.ScopeConfig);
|
|
713
|
+
const rr = scopeConfig?.reRanker;
|
|
714
|
+
if (rr?.driverClass)
|
|
715
|
+
return rr;
|
|
716
|
+
}
|
|
717
|
+
return undefined;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Pick the first scope's `RerankerBudgetCents` value to apply to the reranker
|
|
721
|
+
* run. Mirrors `pickReRankerConfig` — the leading scope's policy wins. NULL
|
|
722
|
+
* (uncapped) is the default when no scope sets a budget.
|
|
723
|
+
*/
|
|
724
|
+
pickRerankerBudgetCents(resolvedScopes) {
|
|
725
|
+
for (const bundle of resolvedScopes) {
|
|
726
|
+
const cents = bundle.Scope.RerankerBudgetCents;
|
|
727
|
+
if (cents != null)
|
|
728
|
+
return cents;
|
|
729
|
+
}
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
async runReRanker(query, candidates, cfg, contextUser, budgetGuard) {
|
|
733
|
+
if (!cfg.driverClass || candidates.length === 0)
|
|
734
|
+
return candidates;
|
|
735
|
+
try {
|
|
736
|
+
const reRanker = MJGlobal.Instance.ClassFactory.CreateInstance(BaseReRanker, cfg.driverClass) ?? new NoopReRanker();
|
|
737
|
+
const inputTopN = cfg.inputTopN ?? Math.min(100, candidates.length);
|
|
738
|
+
const outputTopN = cfg.outputTopN ?? Math.min(20, inputTopN);
|
|
739
|
+
const trimmed = candidates.slice(0, inputTopN);
|
|
740
|
+
// P2D.6 — pre-call budget short-circuit. When the projected cost would
|
|
741
|
+
// exceed the remaining budget, skip rerank entirely and return the
|
|
742
|
+
// unranked top-N. Reported via LogStatus so observability reflects the
|
|
743
|
+
// skip without surfacing as a failure.
|
|
744
|
+
if (budgetGuard) {
|
|
745
|
+
const estimate = reRanker.EstimateCostCents(trimmed.length);
|
|
746
|
+
if (!budgetGuard.CanSpend(estimate)) {
|
|
747
|
+
LogStatus(`SearchEngine: Re-ranker "${cfg.driverClass}" skipped — projected cost ${estimate.toFixed(4)}¢ exceeds remaining budget ${(budgetGuard.Remaining() ?? 0).toFixed(4)}¢ (Spent ${budgetGuard.Spent.toFixed(4)}¢ / Budget ${budgetGuard.Budget ?? 'uncapped'}¢).`);
|
|
748
|
+
return trimmed.slice(0, outputTopN);
|
|
749
|
+
}
|
|
750
|
+
// Wire post-call cost reporting through the guard so subsequent
|
|
751
|
+
// EstimateCostCents queries reflect accumulated spend.
|
|
752
|
+
reRanker.CostReporter = budgetGuard.AsCostReporter();
|
|
753
|
+
}
|
|
754
|
+
const ranked = await reRanker.ReRank(query, trimmed, outputTopN, contextUser, cfg.config);
|
|
755
|
+
LogStatus(`SearchEngine: Re-ranker "${cfg.driverClass}" returned ${ranked.length} result(s) (input=${trimmed.length}, outputTopN=${outputTopN}${budgetGuard ? `, spent=${budgetGuard.Spent.toFixed(4)}¢` : ''})`);
|
|
756
|
+
return ranked;
|
|
757
|
+
}
|
|
758
|
+
catch (error) {
|
|
759
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
760
|
+
LogError(`SearchEngine: Re-ranker "${cfg.driverClass}" failed, falling back to unranked: ${msg}`);
|
|
761
|
+
return candidates;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
// ────────────────────────────────────────────────────────────────
|
|
182
765
|
// Provider loading and initialization
|
|
183
766
|
// ────────────────────────────────────────────────────────────────
|
|
184
767
|
/**
|
|
@@ -232,6 +815,7 @@ export class SearchEngine extends BaseSingleton {
|
|
|
232
815
|
Priority: record.Priority,
|
|
233
816
|
SupportsPreview: record.SupportsPreview,
|
|
234
817
|
MaxResultsOverride: record.MaxResultsOverride ?? null,
|
|
818
|
+
Record: record,
|
|
235
819
|
});
|
|
236
820
|
LogStatus(`SearchEngine: Provider "${record.Name}" (${driverClass}) enabled`);
|
|
237
821
|
}
|
|
@@ -245,13 +829,13 @@ export class SearchEngine extends BaseSingleton {
|
|
|
245
829
|
}
|
|
246
830
|
}
|
|
247
831
|
// ────────────────────────────────────────────────────────────────
|
|
248
|
-
// Search execution helpers
|
|
832
|
+
// Search execution helpers (unscoped path)
|
|
249
833
|
// ────────────────────────────────────────────────────────────────
|
|
250
834
|
/**
|
|
251
835
|
* Run all available providers in parallel and return labeled result lists.
|
|
252
836
|
* When isPreview is true, only providers with SupportsPreview=true are included.
|
|
253
837
|
*/
|
|
254
|
-
async executeProviders(query, topK, filters, contextUser, isPreview) {
|
|
838
|
+
async executeProviders(query, topK, filters, contextUser, isPreview, scopeConstraints, onProviderResolved) {
|
|
255
839
|
const entries = this._providerEntries.filter(e => {
|
|
256
840
|
if (!e.Provider.IsAvailable())
|
|
257
841
|
return false;
|
|
@@ -264,20 +848,45 @@ export class SearchEngine extends BaseSingleton {
|
|
|
264
848
|
return [];
|
|
265
849
|
}
|
|
266
850
|
const promises = entries.map(async (entry) => {
|
|
851
|
+
const providerStart = Date.now();
|
|
267
852
|
try {
|
|
268
853
|
const providerTopK = entry.MaxResultsOverride ?? topK;
|
|
269
|
-
const results = await entry.Provider.Search(query, providerTopK, filters, contextUser);
|
|
854
|
+
const results = await entry.Provider.Search(query, providerTopK, filters, contextUser, scopeConstraints);
|
|
270
855
|
// Stamp provider metadata onto each result
|
|
271
856
|
for (const r of results) {
|
|
272
857
|
r.ProviderId = entry.ID;
|
|
273
858
|
r.ProviderLabel = entry.DisplayName;
|
|
274
859
|
r.ProviderIcon = entry.Icon;
|
|
275
860
|
}
|
|
861
|
+
if (onProviderResolved) {
|
|
862
|
+
try {
|
|
863
|
+
onProviderResolved({
|
|
864
|
+
sourceType: entry.Provider.SourceType,
|
|
865
|
+
results,
|
|
866
|
+
durationMs: Date.now() - providerStart,
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
catch (cbErr) {
|
|
870
|
+
// Streaming callback throwing must NOT corrupt the result; just log.
|
|
871
|
+
const cbMsg = cbErr instanceof Error ? cbErr.message : String(cbErr);
|
|
872
|
+
LogError(`SearchEngine: onProviderResolved callback threw for "${entry.Provider.SourceType}": ${cbMsg}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
276
875
|
return { Source: entry.Provider.SourceType, Results: results };
|
|
277
876
|
}
|
|
278
877
|
catch (error) {
|
|
279
878
|
const msg = error instanceof Error ? error.message : String(error);
|
|
280
879
|
LogError(`SearchEngine: Provider "${entry.Provider.SourceType}" failed: ${msg}`);
|
|
880
|
+
if (onProviderResolved) {
|
|
881
|
+
try {
|
|
882
|
+
onProviderResolved({
|
|
883
|
+
sourceType: entry.Provider.SourceType,
|
|
884
|
+
results: [],
|
|
885
|
+
durationMs: Date.now() - providerStart,
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
catch { /* swallow */ }
|
|
889
|
+
}
|
|
281
890
|
return { Source: entry.Provider.SourceType, Results: [] };
|
|
282
891
|
}
|
|
283
892
|
});
|
|
@@ -291,27 +900,32 @@ export class SearchEngine extends BaseSingleton {
|
|
|
291
900
|
for (const list of lists) {
|
|
292
901
|
switch (list.Source) {
|
|
293
902
|
case 'vector':
|
|
294
|
-
counts.Vector
|
|
903
|
+
counts.Vector += list.Results.length;
|
|
295
904
|
break;
|
|
296
905
|
case 'fulltext':
|
|
297
|
-
counts.FullText
|
|
906
|
+
counts.FullText += list.Results.length;
|
|
298
907
|
break;
|
|
299
908
|
case 'entity':
|
|
300
|
-
counts.Entity
|
|
909
|
+
counts.Entity += list.Results.length;
|
|
301
910
|
break;
|
|
302
911
|
case 'storage':
|
|
303
|
-
counts.Storage
|
|
912
|
+
counts.Storage += list.Results.length;
|
|
304
913
|
break;
|
|
305
914
|
}
|
|
306
915
|
}
|
|
307
916
|
return counts;
|
|
308
917
|
}
|
|
309
918
|
// ────────────────────────────────────────────────────────────────
|
|
310
|
-
// Permission filtering
|
|
919
|
+
// Permission filtering (residual late safety net)
|
|
311
920
|
// ────────────────────────────────────────────────────────────────
|
|
312
921
|
/**
|
|
313
922
|
* Filter search results by entity-level and row-level security permissions.
|
|
314
923
|
*
|
|
924
|
+
* **This is a safety net.** Providers are expected to do per-provider permission
|
|
925
|
+
* push-down (Section 3.6 of plans/search-scopes-rag-plus.md). If this filter is
|
|
926
|
+
* removing more than a handful of results in practice, the responsible provider's
|
|
927
|
+
* push-down is incomplete and should be fixed.
|
|
928
|
+
*
|
|
315
929
|
* Groups results by entity for efficient permission checking:
|
|
316
930
|
* 1. Unknown entities are excluded (fail closed).
|
|
317
931
|
* 2. If the user lacks entity-level CanRead, all results for that entity are dropped.
|
|
@@ -332,6 +946,12 @@ export class SearchEngine extends BaseSingleton {
|
|
|
332
946
|
promises.push(this.filterEntityResults(entityName, groupResults, contextUser, permitted));
|
|
333
947
|
}
|
|
334
948
|
await Promise.all(promises);
|
|
949
|
+
// Preserve the input order (which is the RRF/re-rank order). groupResultsByEntity
|
|
950
|
+
// scrambles by entity; re-sort by original position so consumers still see the
|
|
951
|
+
// best-ranked result first.
|
|
952
|
+
const inputIndex = new Map();
|
|
953
|
+
results.forEach((r, i) => inputIndex.set(r, i));
|
|
954
|
+
permitted.sort((a, b) => (inputIndex.get(a) ?? 0) - (inputIndex.get(b) ?? 0));
|
|
335
955
|
return permitted;
|
|
336
956
|
}
|
|
337
957
|
/**
|
|
@@ -428,6 +1048,74 @@ export class SearchEngine extends BaseSingleton {
|
|
|
428
1048
|
}
|
|
429
1049
|
}
|
|
430
1050
|
/** Build an error SearchResult */
|
|
1051
|
+
/**
|
|
1052
|
+
* Public hook for callers (e.g. the GraphQL resolver) to emit a
|
|
1053
|
+
* Status='Forbidden' SearchExecutionLog row when they reject a request
|
|
1054
|
+
* before delegating to {@link Search}. Without this, forbidden invocations
|
|
1055
|
+
* never reach the analytics dashboard — exactly the signal admins need
|
|
1056
|
+
* to spot users / agents trying to access scopes they shouldn't.
|
|
1057
|
+
*/
|
|
1058
|
+
async LogForbiddenSearch(input) {
|
|
1059
|
+
await this.logSearchExecution({
|
|
1060
|
+
Status: 'Forbidden',
|
|
1061
|
+
FailureReason: input.FailureReason,
|
|
1062
|
+
Query: input.Query,
|
|
1063
|
+
ScopeIDs: input.ScopeIDs,
|
|
1064
|
+
StartTime: input.StartTime,
|
|
1065
|
+
ResultCount: 0,
|
|
1066
|
+
RerankerName: null,
|
|
1067
|
+
RerankerCostCents: null,
|
|
1068
|
+
SourceCounts: undefined,
|
|
1069
|
+
ContextUser: input.ContextUser,
|
|
1070
|
+
AIAgentID: input.AIAgentID ?? null,
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Best-effort hook (P3.2) that writes one MJSearchExecutionLog row per
|
|
1075
|
+
* SearchEngine.Search call. Captures query, timing, scope, result count,
|
|
1076
|
+
* reranker info, status, and a per-source-count breakdown for the analytics
|
|
1077
|
+
* dashboard (P3.3) and tuning CSV export (P3.4).
|
|
1078
|
+
*
|
|
1079
|
+
* Errors during the write are swallowed and logged — observability is the
|
|
1080
|
+
* point of this hook, not a load-bearing dependency. A logger that brings
|
|
1081
|
+
* down search would be the worst possible outcome.
|
|
1082
|
+
*/
|
|
1083
|
+
async logSearchExecution(input) {
|
|
1084
|
+
try {
|
|
1085
|
+
const log = await this.ProviderToUse.GetEntityObject('MJ: Search Execution Logs', input.ContextUser);
|
|
1086
|
+
log.SearchScopeID = input.ScopeIDs && input.ScopeIDs.length > 0 ? input.ScopeIDs[0] : null;
|
|
1087
|
+
log.UserID = input.ContextUser.ID ?? null;
|
|
1088
|
+
log.AIAgentID = input.AIAgentID ?? null;
|
|
1089
|
+
log.Query = input.Query;
|
|
1090
|
+
log.TotalDurationMs = Date.now() - input.StartTime;
|
|
1091
|
+
log.ResultCount = input.ResultCount;
|
|
1092
|
+
log.RerankerName = input.RerankerName;
|
|
1093
|
+
log.RerankerCostCents = input.RerankerCostCents;
|
|
1094
|
+
log.Status = input.Status;
|
|
1095
|
+
log.FailureReason = input.FailureReason;
|
|
1096
|
+
// ProvidersJSON: per-source breakdown. Per-provider per-call timings
|
|
1097
|
+
// require deeper plumbing through the provider-run loop — deferred to a
|
|
1098
|
+
// later Phase 3 pass. For now, capture the source counts which the
|
|
1099
|
+
// dashboard's hit-rate / volume charts already need.
|
|
1100
|
+
log.ProvidersJSON = input.SourceCounts
|
|
1101
|
+
? JSON.stringify({
|
|
1102
|
+
Vector: { ResultCount: input.SourceCounts.Vector },
|
|
1103
|
+
FullText: { ResultCount: input.SourceCounts.FullText },
|
|
1104
|
+
Entity: { ResultCount: input.SourceCounts.Entity },
|
|
1105
|
+
Storage: { ResultCount: input.SourceCounts.Storage },
|
|
1106
|
+
})
|
|
1107
|
+
: null;
|
|
1108
|
+
const saved = await log.Save();
|
|
1109
|
+
if (!saved) {
|
|
1110
|
+
LogError(`SearchEngine: SearchExecutionLog write returned false: ${log.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
catch (err) {
|
|
1114
|
+
// Swallow — this is best-effort observability and must never affect search latency / availability.
|
|
1115
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1116
|
+
LogError(`SearchEngine: SearchExecutionLog write threw: ${msg}`);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
431
1119
|
buildErrorResult(message, startTime) {
|
|
432
1120
|
return {
|
|
433
1121
|
Success: false,
|
|
@@ -450,5 +1138,19 @@ export class SearchEngine extends BaseSingleton {
|
|
|
450
1138
|
Priority: e.Priority,
|
|
451
1139
|
}));
|
|
452
1140
|
}
|
|
1141
|
+
/** Defensive JSON parse that never throws. Returns `null` on any failure. */
|
|
1142
|
+
parseJson(value) {
|
|
1143
|
+
if (!value)
|
|
1144
|
+
return null;
|
|
1145
|
+
try {
|
|
1146
|
+
const parsed = JSON.parse(value);
|
|
1147
|
+
if (parsed && typeof parsed === 'object')
|
|
1148
|
+
return parsed;
|
|
1149
|
+
return null;
|
|
1150
|
+
}
|
|
1151
|
+
catch {
|
|
1152
|
+
return null;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
453
1155
|
}
|
|
454
1156
|
//# sourceMappingURL=SearchEngine.js.map
|