graphile-search 1.15.4 → 1.16.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/esm/plugin.js +83 -39
- package/esm/preset.d.ts +6 -0
- package/esm/preset.js +2 -1
- package/esm/types.d.ts +10 -0
- package/package.json +2 -2
- package/plugin.js +83 -39
- package/preset.d.ts +6 -0
- package/preset.js +2 -1
- package/types.d.ts +10 -0
package/esm/plugin.js
CHANGED
|
@@ -46,30 +46,22 @@ function getSearchConfig(codec) {
|
|
|
46
46
|
return undefined;
|
|
47
47
|
}
|
|
48
48
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* When strategy is 'sigmoid', sigmoid normalization is used for ALL adapters
|
|
52
|
-
* (both bounded and unbounded). When strategy is 'linear' (default),
|
|
53
|
-
* known-range adapters use linear normalization and unbounded adapters
|
|
54
|
-
* use sigmoid normalization as fallback.
|
|
49
|
+
* Map a raw score to 0..1 for effective rank estimation in the RRF fallback path.
|
|
50
|
+
* Used only when a rank window function is not available for an adapter.
|
|
55
51
|
*/
|
|
56
|
-
function normalizeScore(score, lowerIsBetter, range
|
|
52
|
+
function normalizeScore(score, lowerIsBetter, range) {
|
|
57
53
|
let normalized;
|
|
58
|
-
if (range
|
|
59
|
-
// Known range + linear strategy: linear normalization
|
|
54
|
+
if (range) {
|
|
60
55
|
const [min, max] = range;
|
|
61
56
|
normalized = lowerIsBetter
|
|
62
57
|
? 1 - (score - min) / (max - min)
|
|
63
58
|
: (score - min) / (max - min);
|
|
64
59
|
}
|
|
65
60
|
else {
|
|
66
|
-
// Unbounded range, or explicit sigmoid strategy: sigmoid normalization
|
|
67
61
|
if (lowerIsBetter) {
|
|
68
|
-
// BM25: negative scores, more negative = better
|
|
69
62
|
normalized = 1 / (1 + Math.abs(score));
|
|
70
63
|
}
|
|
71
64
|
else {
|
|
72
|
-
// Higher-is-better: map via sigmoid
|
|
73
65
|
normalized = score / (1 + score);
|
|
74
66
|
}
|
|
75
67
|
}
|
|
@@ -101,7 +93,7 @@ function applyRecencyBoost(normalizedScore, recencyValue, decay) {
|
|
|
101
93
|
* Creates the unified search plugin with the given options.
|
|
102
94
|
*/
|
|
103
95
|
export function createUnifiedSearchPlugin(options) {
|
|
104
|
-
const { adapters, enableSearchScore = true, enableUnifiedSearch = true } = options;
|
|
96
|
+
const { adapters, enableSearchScore = true, enableUnifiedSearch = true, rrfK = 60 } = options;
|
|
105
97
|
// Per-codec cache of discovered columns, keyed by codec name
|
|
106
98
|
const codecCache = new Map();
|
|
107
99
|
// Bridge between orderBy enum apply and filter apply.
|
|
@@ -330,7 +322,7 @@ export function createUnifiedSearchPlugin(options) {
|
|
|
330
322
|
}, `UnifiedSearchPlugin adding ${adapter.name} ${adapter.scoreSemantics.metric} field '${fieldName}' for '${column.attributeName}' on '${codec.name}'`);
|
|
331
323
|
}
|
|
332
324
|
}
|
|
333
|
-
// ── Composite searchScore field ──
|
|
325
|
+
// ── Composite searchScore field (RRF — Reciprocal Rank Fusion) ──
|
|
334
326
|
if (enableSearchScore && adapterColumns.length > 0) {
|
|
335
327
|
// Collect all meta keys for all adapters/columns so the
|
|
336
328
|
// composite field can read them at execution time
|
|
@@ -354,8 +346,6 @@ export function createUnifiedSearchPlugin(options) {
|
|
|
354
346
|
const tableSearchConfig = getSearchConfig(codec);
|
|
355
347
|
// Resolve effective weights: per-table > global > equal (undefined)
|
|
356
348
|
const effectiveWeights = tableSearchConfig?.weights ?? options.searchScoreWeights;
|
|
357
|
-
// Resolve normalization strategy: per-table > default 'linear'
|
|
358
|
-
const normalizationStrategy = tableSearchConfig?.normalization ?? 'linear';
|
|
359
349
|
// Recency boost config from per-table smart tag
|
|
360
350
|
let boostRecent = tableSearchConfig?.boost_recent ?? false;
|
|
361
351
|
const boostRecencyField = tableSearchConfig?.boost_recency_field ?? 'updated_at';
|
|
@@ -373,7 +363,7 @@ export function createUnifiedSearchPlugin(options) {
|
|
|
373
363
|
isUnifiedSearchScoreField: true,
|
|
374
364
|
}, () => ({
|
|
375
365
|
description: 'Composite search relevance score (0..1, higher = more relevant). ' +
|
|
376
|
-
'Computed
|
|
366
|
+
'Computed using Reciprocal Rank Fusion (RRF) across all active search signals. ' +
|
|
377
367
|
'Supports per-table weight customization via @searchConfig smart tag. ' +
|
|
378
368
|
'Returns null when no search filters are active.',
|
|
379
369
|
type: GraphQLFloat,
|
|
@@ -398,40 +388,78 @@ export function createUnifiedSearchPlugin(options) {
|
|
|
398
388
|
}
|
|
399
389
|
// Capture the index in a local const for the lambda closure
|
|
400
390
|
const capturedRecencyIndex = recencySelectIndex;
|
|
401
|
-
|
|
391
|
+
// For RRF we also need rank expressions. Inject ROW_NUMBER()
|
|
392
|
+
// window functions for each adapter score into the SELECT.
|
|
393
|
+
// These will be populated at filter-apply time via meta.
|
|
394
|
+
// We store a meta key suffix "__rank" alongside the score.
|
|
395
|
+
const rankMetaKeys = allMetaKeys.map((mk) => `${mk.metaKey}__rank`);
|
|
396
|
+
const $rankMetaSteps = rankMetaKeys.map((key) => $select.getMeta(key));
|
|
397
|
+
return lambda([...$metaSteps, ...$rankMetaSteps, $row], (args) => {
|
|
402
398
|
const row = args[args.length - 1];
|
|
403
399
|
if (row == null)
|
|
404
400
|
return null;
|
|
405
|
-
|
|
406
|
-
let
|
|
401
|
+
const numAdapters = allMetaKeys.length;
|
|
402
|
+
let rrfSum = 0;
|
|
403
|
+
let maxPossibleRrf = 0;
|
|
404
|
+
let hasAnyScore = false;
|
|
407
405
|
// Read recency value from the injected SELECT column
|
|
408
406
|
const recencyValue = (boostRecent && capturedRecencyIndex != null)
|
|
409
407
|
? row[capturedRecencyIndex]
|
|
410
408
|
: null;
|
|
411
|
-
for (let i = 0; i <
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
continue;
|
|
415
|
-
const rawValue = row[details.selectIndex];
|
|
416
|
-
if (rawValue == null)
|
|
417
|
-
continue;
|
|
418
|
-
const score = TYPES.float.fromPg(rawValue);
|
|
419
|
-
if (typeof score !== 'number' || isNaN(score))
|
|
420
|
-
continue;
|
|
409
|
+
for (let i = 0; i < numAdapters; i++) {
|
|
410
|
+
const scoreDetails = args[i];
|
|
411
|
+
const rankDetails = args[numAdapters + i];
|
|
421
412
|
const mk = allMetaKeys[i];
|
|
422
413
|
const weight = effectiveWeights?.[mk.adapterName] ?? 1;
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
if (
|
|
427
|
-
|
|
414
|
+
// Determine if this adapter is active (has meta set by a filter)
|
|
415
|
+
const adapterHasMeta = (rankDetails != null && rankDetails.selectIndex != null)
|
|
416
|
+
|| (scoreDetails != null && scoreDetails.selectIndex != null);
|
|
417
|
+
if (!adapterHasMeta)
|
|
418
|
+
continue;
|
|
419
|
+
// Only include active adapters in normalization denominator
|
|
420
|
+
maxPossibleRrf += weight / (rrfK + 1);
|
|
421
|
+
// Try to use rank-based RRF (preferred)
|
|
422
|
+
if (rankDetails != null && rankDetails.selectIndex != null) {
|
|
423
|
+
const rawRank = row[rankDetails.selectIndex];
|
|
424
|
+
if (rawRank != null) {
|
|
425
|
+
const rank = TYPES.float.fromPg(rawRank);
|
|
426
|
+
if (typeof rank === 'number' && !isNaN(rank) && rank > 0) {
|
|
427
|
+
hasAnyScore = true;
|
|
428
|
+
let contribution = weight / (rrfK + rank);
|
|
429
|
+
// Apply recency boost if configured
|
|
430
|
+
if (boostRecent && recencyValue != null) {
|
|
431
|
+
contribution = applyRecencyBoost(contribution, recencyValue, boostRecencyDecay);
|
|
432
|
+
}
|
|
433
|
+
rrfSum += contribution;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// Fallback: if rank is not available but score exists,
|
|
439
|
+
// use score-based rank estimation.
|
|
440
|
+
if (scoreDetails != null && scoreDetails.selectIndex != null) {
|
|
441
|
+
const rawValue = row[scoreDetails.selectIndex];
|
|
442
|
+
if (rawValue != null) {
|
|
443
|
+
const score = TYPES.float.fromPg(rawValue);
|
|
444
|
+
if (typeof score === 'number' && !isNaN(score)) {
|
|
445
|
+
hasAnyScore = true;
|
|
446
|
+
const normalizedScore = normalizeScore(score, mk.lowerIsBetter, mk.range);
|
|
447
|
+
// Map normalized score to an effective rank:
|
|
448
|
+
// score=1.0 → rank=1, score=0.5 → rank=rrfK, score→0 → rank=very high
|
|
449
|
+
const effectiveRank = Math.max(1, Math.round(1 + (1 - normalizedScore) * (rrfK * 2)));
|
|
450
|
+
let contribution = weight / (rrfK + effectiveRank);
|
|
451
|
+
if (boostRecent && recencyValue != null) {
|
|
452
|
+
contribution = applyRecencyBoost(contribution, recencyValue, boostRecencyDecay);
|
|
453
|
+
}
|
|
454
|
+
rrfSum += contribution;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
428
457
|
}
|
|
429
|
-
weightedSum += normalized * weight;
|
|
430
|
-
totalWeight += weight;
|
|
431
458
|
}
|
|
432
|
-
if (
|
|
459
|
+
if (!hasAnyScore || maxPossibleRrf === 0)
|
|
433
460
|
return null;
|
|
434
|
-
|
|
461
|
+
// Normalize to 0..1 by dividing by max possible RRF score
|
|
462
|
+
return Math.min(1, rrfSum / maxPossibleRrf);
|
|
435
463
|
});
|
|
436
464
|
},
|
|
437
465
|
})),
|
|
@@ -602,6 +630,14 @@ export function createUnifiedSearchPlugin(options) {
|
|
|
602
630
|
qb.setMeta(scoreMetaKey, {
|
|
603
631
|
selectIndex: scoreIndex,
|
|
604
632
|
});
|
|
633
|
+
// Add rank (ROW_NUMBER window function) for RRF scoring
|
|
634
|
+
const rankMetaKey = `${scoreMetaKey}__rank`;
|
|
635
|
+
const orderDirection = adapter.scoreSemantics.lowerIsBetter ? 'ASC' : 'DESC';
|
|
636
|
+
const rankSql = sql `(ROW_NUMBER() OVER (ORDER BY ${sql.parens(result.scoreExpression)} ${orderDirection === 'ASC' ? sql.fragment `ASC` : sql.fragment `DESC`} NULLS LAST))::text`;
|
|
637
|
+
const rankIndex = qb.selectAndReturnIndex(rankSql);
|
|
638
|
+
qb.setMeta(rankMetaKey, {
|
|
639
|
+
selectIndex: rankIndex,
|
|
640
|
+
});
|
|
605
641
|
// ORDER BY: read the direction stored by the orderBy
|
|
606
642
|
// enum (which ran first) via the shared alias key.
|
|
607
643
|
const orderKey = `unified_order_${adapter.name}_${baseFieldName}`;
|
|
@@ -668,6 +704,14 @@ export function createUnifiedSearchPlugin(options) {
|
|
|
668
704
|
qb.setMeta(scoreMetaKey, {
|
|
669
705
|
selectIndex: scoreIndex,
|
|
670
706
|
});
|
|
707
|
+
// Add rank (ROW_NUMBER window function) for RRF scoring
|
|
708
|
+
const rankMetaKey = `${scoreMetaKey}__rank`;
|
|
709
|
+
const orderDirection = adapter.scoreSemantics.lowerIsBetter ? 'ASC' : 'DESC';
|
|
710
|
+
const rankSql = sql `(ROW_NUMBER() OVER (ORDER BY ${sql.parens(result.scoreExpression)} ${orderDirection === 'ASC' ? sql.fragment `ASC` : sql.fragment `DESC`} NULLS LAST))::text`;
|
|
711
|
+
const rankIndex = qb.selectAndReturnIndex(rankSql);
|
|
712
|
+
qb.setMeta(rankMetaKey, {
|
|
713
|
+
selectIndex: rankIndex,
|
|
714
|
+
});
|
|
671
715
|
// ORDER BY: read the direction stored by the orderBy
|
|
672
716
|
// enum (which ran first) via the shared alias key.
|
|
673
717
|
const orderKey = `unified_order_${adapter.name}_${baseFieldName}`;
|
package/esm/preset.d.ts
CHANGED
|
@@ -71,6 +71,12 @@ export interface UnifiedSearchPresetOptions {
|
|
|
71
71
|
* @default 'english'
|
|
72
72
|
*/
|
|
73
73
|
tsConfig?: string;
|
|
74
|
+
/**
|
|
75
|
+
* RRF (Reciprocal Rank Fusion) smoothing constant for composite searchScore.
|
|
76
|
+
* Higher values make scoring more democratic across rank positions.
|
|
77
|
+
* @default 60
|
|
78
|
+
*/
|
|
79
|
+
rrfK?: number;
|
|
74
80
|
}
|
|
75
81
|
/**
|
|
76
82
|
* Creates a preset that includes the unified search plugin with all enabled adapters.
|
package/esm/preset.js
CHANGED
|
@@ -29,7 +29,7 @@ import { createMatchesOperatorFactory, createTrgmOperatorFactories } from './cod
|
|
|
29
29
|
* Creates a preset that includes the unified search plugin with all enabled adapters.
|
|
30
30
|
*/
|
|
31
31
|
export function UnifiedSearchPreset(options = {}) {
|
|
32
|
-
const { tsvector = true, bm25 = true, trgm = true, pgvector = true, enableSearchScore = true, enableUnifiedSearch = true, searchScoreWeights, fullTextScalarName = 'FullText', tsConfig = 'english', } = options;
|
|
32
|
+
const { tsvector = true, bm25 = true, trgm = true, pgvector = true, enableSearchScore = true, enableUnifiedSearch = true, searchScoreWeights, fullTextScalarName = 'FullText', tsConfig = 'english', rrfK, } = options;
|
|
33
33
|
const adapters = [];
|
|
34
34
|
if (tsvector) {
|
|
35
35
|
const opts = typeof tsvector === 'object' ? tsvector : {};
|
|
@@ -52,6 +52,7 @@ export function UnifiedSearchPreset(options = {}) {
|
|
|
52
52
|
enableSearchScore,
|
|
53
53
|
enableUnifiedSearch,
|
|
54
54
|
searchScoreWeights,
|
|
55
|
+
rrfK,
|
|
55
56
|
};
|
|
56
57
|
// Collect codec plugins based on which adapters are enabled
|
|
57
58
|
const codecPlugins = [];
|
package/esm/types.d.ts
CHANGED
|
@@ -195,4 +195,14 @@ export interface UnifiedSearchOptions {
|
|
|
195
195
|
* @example { bm25: 0.5, trgm: 0.3, tsv: 0.2 }
|
|
196
196
|
*/
|
|
197
197
|
searchScoreWeights?: Record<string, number>;
|
|
198
|
+
/**
|
|
199
|
+
* RRF (Reciprocal Rank Fusion) smoothing constant. Controls how much
|
|
200
|
+
* top-ranked items dominate the composite score. Higher values make the
|
|
201
|
+
* scoring more democratic across rank positions.
|
|
202
|
+
*
|
|
203
|
+
* The RRF contribution of each adapter is: weight / (rrfK + rank)
|
|
204
|
+
*
|
|
205
|
+
* @default 60
|
|
206
|
+
*/
|
|
207
|
+
rrfK?: number;
|
|
198
208
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphile-search",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"description": "Unified PostGraphile v5 search plugin — abstracts tsvector, BM25, pg_trgm, and pgvector behind a single adapter-based architecture with composite searchScore",
|
|
5
5
|
"author": "Constructive <developers@constructive.io>",
|
|
6
6
|
"homepage": "https://github.com/constructive-io/constructive",
|
|
@@ -62,5 +62,5 @@
|
|
|
62
62
|
"hybrid-search",
|
|
63
63
|
"searchScore"
|
|
64
64
|
],
|
|
65
|
-
"gitHead": "
|
|
65
|
+
"gitHead": "2558a0cdb58c815933af8bfe6769cd59014c77cf"
|
|
66
66
|
}
|
package/plugin.js
CHANGED
|
@@ -49,30 +49,22 @@ function getSearchConfig(codec) {
|
|
|
49
49
|
return undefined;
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* When strategy is 'sigmoid', sigmoid normalization is used for ALL adapters
|
|
55
|
-
* (both bounded and unbounded). When strategy is 'linear' (default),
|
|
56
|
-
* known-range adapters use linear normalization and unbounded adapters
|
|
57
|
-
* use sigmoid normalization as fallback.
|
|
52
|
+
* Map a raw score to 0..1 for effective rank estimation in the RRF fallback path.
|
|
53
|
+
* Used only when a rank window function is not available for an adapter.
|
|
58
54
|
*/
|
|
59
|
-
function normalizeScore(score, lowerIsBetter, range
|
|
55
|
+
function normalizeScore(score, lowerIsBetter, range) {
|
|
60
56
|
let normalized;
|
|
61
|
-
if (range
|
|
62
|
-
// Known range + linear strategy: linear normalization
|
|
57
|
+
if (range) {
|
|
63
58
|
const [min, max] = range;
|
|
64
59
|
normalized = lowerIsBetter
|
|
65
60
|
? 1 - (score - min) / (max - min)
|
|
66
61
|
: (score - min) / (max - min);
|
|
67
62
|
}
|
|
68
63
|
else {
|
|
69
|
-
// Unbounded range, or explicit sigmoid strategy: sigmoid normalization
|
|
70
64
|
if (lowerIsBetter) {
|
|
71
|
-
// BM25: negative scores, more negative = better
|
|
72
65
|
normalized = 1 / (1 + Math.abs(score));
|
|
73
66
|
}
|
|
74
67
|
else {
|
|
75
|
-
// Higher-is-better: map via sigmoid
|
|
76
68
|
normalized = score / (1 + score);
|
|
77
69
|
}
|
|
78
70
|
}
|
|
@@ -104,7 +96,7 @@ function applyRecencyBoost(normalizedScore, recencyValue, decay) {
|
|
|
104
96
|
* Creates the unified search plugin with the given options.
|
|
105
97
|
*/
|
|
106
98
|
function createUnifiedSearchPlugin(options) {
|
|
107
|
-
const { adapters, enableSearchScore = true, enableUnifiedSearch = true } = options;
|
|
99
|
+
const { adapters, enableSearchScore = true, enableUnifiedSearch = true, rrfK = 60 } = options;
|
|
108
100
|
// Per-codec cache of discovered columns, keyed by codec name
|
|
109
101
|
const codecCache = new Map();
|
|
110
102
|
// Bridge between orderBy enum apply and filter apply.
|
|
@@ -333,7 +325,7 @@ function createUnifiedSearchPlugin(options) {
|
|
|
333
325
|
}, `UnifiedSearchPlugin adding ${adapter.name} ${adapter.scoreSemantics.metric} field '${fieldName}' for '${column.attributeName}' on '${codec.name}'`);
|
|
334
326
|
}
|
|
335
327
|
}
|
|
336
|
-
// ── Composite searchScore field ──
|
|
328
|
+
// ── Composite searchScore field (RRF — Reciprocal Rank Fusion) ──
|
|
337
329
|
if (enableSearchScore && adapterColumns.length > 0) {
|
|
338
330
|
// Collect all meta keys for all adapters/columns so the
|
|
339
331
|
// composite field can read them at execution time
|
|
@@ -357,8 +349,6 @@ function createUnifiedSearchPlugin(options) {
|
|
|
357
349
|
const tableSearchConfig = getSearchConfig(codec);
|
|
358
350
|
// Resolve effective weights: per-table > global > equal (undefined)
|
|
359
351
|
const effectiveWeights = tableSearchConfig?.weights ?? options.searchScoreWeights;
|
|
360
|
-
// Resolve normalization strategy: per-table > default 'linear'
|
|
361
|
-
const normalizationStrategy = tableSearchConfig?.normalization ?? 'linear';
|
|
362
352
|
// Recency boost config from per-table smart tag
|
|
363
353
|
let boostRecent = tableSearchConfig?.boost_recent ?? false;
|
|
364
354
|
const boostRecencyField = tableSearchConfig?.boost_recency_field ?? 'updated_at';
|
|
@@ -376,7 +366,7 @@ function createUnifiedSearchPlugin(options) {
|
|
|
376
366
|
isUnifiedSearchScoreField: true,
|
|
377
367
|
}, () => ({
|
|
378
368
|
description: 'Composite search relevance score (0..1, higher = more relevant). ' +
|
|
379
|
-
'Computed
|
|
369
|
+
'Computed using Reciprocal Rank Fusion (RRF) across all active search signals. ' +
|
|
380
370
|
'Supports per-table weight customization via @searchConfig smart tag. ' +
|
|
381
371
|
'Returns null when no search filters are active.',
|
|
382
372
|
type: GraphQLFloat,
|
|
@@ -401,40 +391,78 @@ function createUnifiedSearchPlugin(options) {
|
|
|
401
391
|
}
|
|
402
392
|
// Capture the index in a local const for the lambda closure
|
|
403
393
|
const capturedRecencyIndex = recencySelectIndex;
|
|
404
|
-
|
|
394
|
+
// For RRF we also need rank expressions. Inject ROW_NUMBER()
|
|
395
|
+
// window functions for each adapter score into the SELECT.
|
|
396
|
+
// These will be populated at filter-apply time via meta.
|
|
397
|
+
// We store a meta key suffix "__rank" alongside the score.
|
|
398
|
+
const rankMetaKeys = allMetaKeys.map((mk) => `${mk.metaKey}__rank`);
|
|
399
|
+
const $rankMetaSteps = rankMetaKeys.map((key) => $select.getMeta(key));
|
|
400
|
+
return lambda([...$metaSteps, ...$rankMetaSteps, $row], (args) => {
|
|
405
401
|
const row = args[args.length - 1];
|
|
406
402
|
if (row == null)
|
|
407
403
|
return null;
|
|
408
|
-
|
|
409
|
-
let
|
|
404
|
+
const numAdapters = allMetaKeys.length;
|
|
405
|
+
let rrfSum = 0;
|
|
406
|
+
let maxPossibleRrf = 0;
|
|
407
|
+
let hasAnyScore = false;
|
|
410
408
|
// Read recency value from the injected SELECT column
|
|
411
409
|
const recencyValue = (boostRecent && capturedRecencyIndex != null)
|
|
412
410
|
? row[capturedRecencyIndex]
|
|
413
411
|
: null;
|
|
414
|
-
for (let i = 0; i <
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
continue;
|
|
418
|
-
const rawValue = row[details.selectIndex];
|
|
419
|
-
if (rawValue == null)
|
|
420
|
-
continue;
|
|
421
|
-
const score = pg_1.TYPES.float.fromPg(rawValue);
|
|
422
|
-
if (typeof score !== 'number' || isNaN(score))
|
|
423
|
-
continue;
|
|
412
|
+
for (let i = 0; i < numAdapters; i++) {
|
|
413
|
+
const scoreDetails = args[i];
|
|
414
|
+
const rankDetails = args[numAdapters + i];
|
|
424
415
|
const mk = allMetaKeys[i];
|
|
425
416
|
const weight = effectiveWeights?.[mk.adapterName] ?? 1;
|
|
426
|
-
//
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (
|
|
430
|
-
|
|
417
|
+
// Determine if this adapter is active (has meta set by a filter)
|
|
418
|
+
const adapterHasMeta = (rankDetails != null && rankDetails.selectIndex != null)
|
|
419
|
+
|| (scoreDetails != null && scoreDetails.selectIndex != null);
|
|
420
|
+
if (!adapterHasMeta)
|
|
421
|
+
continue;
|
|
422
|
+
// Only include active adapters in normalization denominator
|
|
423
|
+
maxPossibleRrf += weight / (rrfK + 1);
|
|
424
|
+
// Try to use rank-based RRF (preferred)
|
|
425
|
+
if (rankDetails != null && rankDetails.selectIndex != null) {
|
|
426
|
+
const rawRank = row[rankDetails.selectIndex];
|
|
427
|
+
if (rawRank != null) {
|
|
428
|
+
const rank = pg_1.TYPES.float.fromPg(rawRank);
|
|
429
|
+
if (typeof rank === 'number' && !isNaN(rank) && rank > 0) {
|
|
430
|
+
hasAnyScore = true;
|
|
431
|
+
let contribution = weight / (rrfK + rank);
|
|
432
|
+
// Apply recency boost if configured
|
|
433
|
+
if (boostRecent && recencyValue != null) {
|
|
434
|
+
contribution = applyRecencyBoost(contribution, recencyValue, boostRecencyDecay);
|
|
435
|
+
}
|
|
436
|
+
rrfSum += contribution;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Fallback: if rank is not available but score exists,
|
|
442
|
+
// use score-based rank estimation.
|
|
443
|
+
if (scoreDetails != null && scoreDetails.selectIndex != null) {
|
|
444
|
+
const rawValue = row[scoreDetails.selectIndex];
|
|
445
|
+
if (rawValue != null) {
|
|
446
|
+
const score = pg_1.TYPES.float.fromPg(rawValue);
|
|
447
|
+
if (typeof score === 'number' && !isNaN(score)) {
|
|
448
|
+
hasAnyScore = true;
|
|
449
|
+
const normalizedScore = normalizeScore(score, mk.lowerIsBetter, mk.range);
|
|
450
|
+
// Map normalized score to an effective rank:
|
|
451
|
+
// score=1.0 → rank=1, score=0.5 → rank=rrfK, score→0 → rank=very high
|
|
452
|
+
const effectiveRank = Math.max(1, Math.round(1 + (1 - normalizedScore) * (rrfK * 2)));
|
|
453
|
+
let contribution = weight / (rrfK + effectiveRank);
|
|
454
|
+
if (boostRecent && recencyValue != null) {
|
|
455
|
+
contribution = applyRecencyBoost(contribution, recencyValue, boostRecencyDecay);
|
|
456
|
+
}
|
|
457
|
+
rrfSum += contribution;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
431
460
|
}
|
|
432
|
-
weightedSum += normalized * weight;
|
|
433
|
-
totalWeight += weight;
|
|
434
461
|
}
|
|
435
|
-
if (
|
|
462
|
+
if (!hasAnyScore || maxPossibleRrf === 0)
|
|
436
463
|
return null;
|
|
437
|
-
|
|
464
|
+
// Normalize to 0..1 by dividing by max possible RRF score
|
|
465
|
+
return Math.min(1, rrfSum / maxPossibleRrf);
|
|
438
466
|
});
|
|
439
467
|
},
|
|
440
468
|
})),
|
|
@@ -605,6 +633,14 @@ function createUnifiedSearchPlugin(options) {
|
|
|
605
633
|
qb.setMeta(scoreMetaKey, {
|
|
606
634
|
selectIndex: scoreIndex,
|
|
607
635
|
});
|
|
636
|
+
// Add rank (ROW_NUMBER window function) for RRF scoring
|
|
637
|
+
const rankMetaKey = `${scoreMetaKey}__rank`;
|
|
638
|
+
const orderDirection = adapter.scoreSemantics.lowerIsBetter ? 'ASC' : 'DESC';
|
|
639
|
+
const rankSql = sql `(ROW_NUMBER() OVER (ORDER BY ${sql.parens(result.scoreExpression)} ${orderDirection === 'ASC' ? sql.fragment `ASC` : sql.fragment `DESC`} NULLS LAST))::text`;
|
|
640
|
+
const rankIndex = qb.selectAndReturnIndex(rankSql);
|
|
641
|
+
qb.setMeta(rankMetaKey, {
|
|
642
|
+
selectIndex: rankIndex,
|
|
643
|
+
});
|
|
608
644
|
// ORDER BY: read the direction stored by the orderBy
|
|
609
645
|
// enum (which ran first) via the shared alias key.
|
|
610
646
|
const orderKey = `unified_order_${adapter.name}_${baseFieldName}`;
|
|
@@ -671,6 +707,14 @@ function createUnifiedSearchPlugin(options) {
|
|
|
671
707
|
qb.setMeta(scoreMetaKey, {
|
|
672
708
|
selectIndex: scoreIndex,
|
|
673
709
|
});
|
|
710
|
+
// Add rank (ROW_NUMBER window function) for RRF scoring
|
|
711
|
+
const rankMetaKey = `${scoreMetaKey}__rank`;
|
|
712
|
+
const orderDirection = adapter.scoreSemantics.lowerIsBetter ? 'ASC' : 'DESC';
|
|
713
|
+
const rankSql = sql `(ROW_NUMBER() OVER (ORDER BY ${sql.parens(result.scoreExpression)} ${orderDirection === 'ASC' ? sql.fragment `ASC` : sql.fragment `DESC`} NULLS LAST))::text`;
|
|
714
|
+
const rankIndex = qb.selectAndReturnIndex(rankSql);
|
|
715
|
+
qb.setMeta(rankMetaKey, {
|
|
716
|
+
selectIndex: rankIndex,
|
|
717
|
+
});
|
|
674
718
|
// ORDER BY: read the direction stored by the orderBy
|
|
675
719
|
// enum (which ran first) via the shared alias key.
|
|
676
720
|
const orderKey = `unified_order_${adapter.name}_${baseFieldName}`;
|
package/preset.d.ts
CHANGED
|
@@ -71,6 +71,12 @@ export interface UnifiedSearchPresetOptions {
|
|
|
71
71
|
* @default 'english'
|
|
72
72
|
*/
|
|
73
73
|
tsConfig?: string;
|
|
74
|
+
/**
|
|
75
|
+
* RRF (Reciprocal Rank Fusion) smoothing constant for composite searchScore.
|
|
76
|
+
* Higher values make scoring more democratic across rank positions.
|
|
77
|
+
* @default 60
|
|
78
|
+
*/
|
|
79
|
+
rrfK?: number;
|
|
74
80
|
}
|
|
75
81
|
/**
|
|
76
82
|
* Creates a preset that includes the unified search plugin with all enabled adapters.
|
package/preset.js
CHANGED
|
@@ -32,7 +32,7 @@ const operator_factories_1 = require("./codecs/operator-factories");
|
|
|
32
32
|
* Creates a preset that includes the unified search plugin with all enabled adapters.
|
|
33
33
|
*/
|
|
34
34
|
function UnifiedSearchPreset(options = {}) {
|
|
35
|
-
const { tsvector = true, bm25 = true, trgm = true, pgvector = true, enableSearchScore = true, enableUnifiedSearch = true, searchScoreWeights, fullTextScalarName = 'FullText', tsConfig = 'english', } = options;
|
|
35
|
+
const { tsvector = true, bm25 = true, trgm = true, pgvector = true, enableSearchScore = true, enableUnifiedSearch = true, searchScoreWeights, fullTextScalarName = 'FullText', tsConfig = 'english', rrfK, } = options;
|
|
36
36
|
const adapters = [];
|
|
37
37
|
if (tsvector) {
|
|
38
38
|
const opts = typeof tsvector === 'object' ? tsvector : {};
|
|
@@ -55,6 +55,7 @@ function UnifiedSearchPreset(options = {}) {
|
|
|
55
55
|
enableSearchScore,
|
|
56
56
|
enableUnifiedSearch,
|
|
57
57
|
searchScoreWeights,
|
|
58
|
+
rrfK,
|
|
58
59
|
};
|
|
59
60
|
// Collect codec plugins based on which adapters are enabled
|
|
60
61
|
const codecPlugins = [];
|
package/types.d.ts
CHANGED
|
@@ -195,4 +195,14 @@ export interface UnifiedSearchOptions {
|
|
|
195
195
|
* @example { bm25: 0.5, trgm: 0.3, tsv: 0.2 }
|
|
196
196
|
*/
|
|
197
197
|
searchScoreWeights?: Record<string, number>;
|
|
198
|
+
/**
|
|
199
|
+
* RRF (Reciprocal Rank Fusion) smoothing constant. Controls how much
|
|
200
|
+
* top-ranked items dominate the composite score. Higher values make the
|
|
201
|
+
* scoring more democratic across rank positions.
|
|
202
|
+
*
|
|
203
|
+
* The RRF contribution of each adapter is: weight / (rrfK + rank)
|
|
204
|
+
*
|
|
205
|
+
* @default 60
|
|
206
|
+
*/
|
|
207
|
+
rrfK?: number;
|
|
198
208
|
}
|