graphile-search 1.15.3 → 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 CHANGED
@@ -46,30 +46,22 @@ function getSearchConfig(codec) {
46
46
  return undefined;
47
47
  }
48
48
  /**
49
- * Normalize a raw score to 0..1 using the specified strategy.
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, strategy = 'linear') {
52
+ function normalizeScore(score, lowerIsBetter, range) {
57
53
  let normalized;
58
- if (range && strategy === 'linear') {
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 by normalizing and averaging all active search signals. ' +
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
- return lambda([...$metaSteps, $row], (args) => {
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
- let weightedSum = 0;
406
- let totalWeight = 0;
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 < allMetaKeys.length; i++) {
412
- const details = args[i];
413
- if (details == null || details.selectIndex == null)
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
- // Normalize using the resolved strategy
424
- let normalized = normalizeScore(score, mk.lowerIsBetter, mk.range, normalizationStrategy);
425
- // Apply recency boost if configured
426
- if (boostRecent && recencyValue != null) {
427
- normalized = applyRecencyBoost(normalized, recencyValue, boostRecencyDecay);
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 (totalWeight === 0)
459
+ if (!hasAnyScore || maxPossibleRrf === 0)
433
460
  return null;
434
- return weightedSum / totalWeight;
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.15.3",
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",
@@ -31,11 +31,11 @@
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.19.11",
33
33
  "@types/pg": "^8.20.0",
34
- "graphile-connection-filter": "^1.12.3",
35
- "graphile-test": "^4.17.3",
34
+ "graphile-connection-filter": "^1.12.4",
35
+ "graphile-test": "^4.17.4",
36
36
  "makage": "^0.3.0",
37
37
  "pg": "^8.21.0",
38
- "pgsql-test": "^4.16.3"
38
+ "pgsql-test": "^4.16.4"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@dataplan/pg": "1.0.3",
@@ -62,5 +62,5 @@
62
62
  "hybrid-search",
63
63
  "searchScore"
64
64
  ],
65
- "gitHead": "30fdaaacfc61f86b3e466f0c4c3768550fd77a55"
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
- * Normalize a raw score to 0..1 using the specified strategy.
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, strategy = 'linear') {
55
+ function normalizeScore(score, lowerIsBetter, range) {
60
56
  let normalized;
61
- if (range && strategy === 'linear') {
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 by normalizing and averaging all active search signals. ' +
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
- return lambda([...$metaSteps, $row], (args) => {
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
- let weightedSum = 0;
409
- let totalWeight = 0;
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 < allMetaKeys.length; i++) {
415
- const details = args[i];
416
- if (details == null || details.selectIndex == null)
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
- // Normalize using the resolved strategy
427
- let normalized = normalizeScore(score, mk.lowerIsBetter, mk.range, normalizationStrategy);
428
- // Apply recency boost if configured
429
- if (boostRecent && recencyValue != null) {
430
- normalized = applyRecencyBoost(normalized, recencyValue, boostRecencyDecay);
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 (totalWeight === 0)
462
+ if (!hasAnyScore || maxPossibleRrf === 0)
436
463
  return null;
437
- return weightedSum / totalWeight;
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
  }