graphile-search 1.15.4 → 1.17.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
  })),
@@ -535,6 +563,40 @@ export function createUnifiedSearchPlugin(options) {
535
563
  GraphQLInputObjectType_fields(fields, build, context) {
536
564
  const { inflection, sql } = build;
537
565
  const { scope: { isPgConnectionFilter, pgCodec } = {}, fieldWithHooks, } = context;
566
+ /**
567
+ * Inject a single adapter's score expression + rank window function
568
+ * into the query builder, and apply any pending ORDER BY direction.
569
+ *
570
+ * Shared by per-adapter filter fields AND the unifiedSearch
571
+ * composite filter (both text and vector adapter branches).
572
+ */
573
+ function injectScoreAndRank(qb, adapter, column, result, codec_, $condition) {
574
+ if (!qb || qb.mode !== 'normal')
575
+ return;
576
+ const baseFieldName = inflection.attribute({
577
+ codec: codec_,
578
+ attributeName: column.attributeName,
579
+ });
580
+ const scoreMetaKey = `__unified_search_${adapter.name}_${baseFieldName}`;
581
+ const wrappedScoreSql = sql `${sql.parens(result.scoreExpression)}::text`;
582
+ const scoreIndex = qb.selectAndReturnIndex(wrappedScoreSql);
583
+ qb.setMeta(scoreMetaKey, { selectIndex: scoreIndex });
584
+ const rankMetaKey = `${scoreMetaKey}__rank`;
585
+ const orderDirection = adapter.scoreSemantics.lowerIsBetter ? 'ASC' : 'DESC';
586
+ const rankSql = sql `(ROW_NUMBER() OVER (ORDER BY ${sql.parens(result.scoreExpression)} ${orderDirection === 'ASC' ? sql.fragment `ASC` : sql.fragment `DESC`} NULLS LAST))::text`;
587
+ const rankIndex = qb.selectAndReturnIndex(rankSql);
588
+ qb.setMeta(rankMetaKey, { selectIndex: rankIndex });
589
+ const orderKey = `unified_order_${adapter.name}_${baseFieldName}`;
590
+ const dirs = _pendingOrderDirections.get($condition.alias);
591
+ const explicitDir = dirs?.[orderKey];
592
+ if (explicitDir) {
593
+ qb.orderBy({
594
+ fragment: result.scoreExpression,
595
+ codec: TYPES.float,
596
+ direction: explicitDir,
597
+ });
598
+ }
599
+ }
538
600
  if (!isPgConnectionFilter ||
539
601
  !pgCodec ||
540
602
  !pgCodec.attributes ||
@@ -592,29 +654,8 @@ export function createUnifiedSearchPlugin(options) {
592
654
  if (result.whereClause) {
593
655
  $condition.where(result.whereClause);
594
656
  }
595
- // Get the query builder for SELECT/ORDER BY injection
596
657
  const qb = getQueryBuilder(build, $condition);
597
- if (qb && qb.mode === 'normal') {
598
- // Add score to the SELECT list
599
- const wrappedScoreSql = sql `${sql.parens(result.scoreExpression)}::text`;
600
- const scoreIndex = qb.selectAndReturnIndex(wrappedScoreSql);
601
- // Store the select index in meta for the output field plan
602
- qb.setMeta(scoreMetaKey, {
603
- selectIndex: scoreIndex,
604
- });
605
- // ORDER BY: read the direction stored by the orderBy
606
- // enum (which ran first) via the shared alias key.
607
- const orderKey = `unified_order_${adapter.name}_${baseFieldName}`;
608
- const dirs = _pendingOrderDirections.get($condition.alias);
609
- const explicitDir = dirs?.[orderKey];
610
- if (explicitDir) {
611
- qb.orderBy({
612
- fragment: result.scoreExpression,
613
- codec: TYPES.float,
614
- direction: explicitDir,
615
- });
616
- }
617
- }
658
+ injectScoreAndRank(qb, adapter, column, result, codec, $condition);
618
659
  },
619
660
  }),
620
661
  }, `UnifiedSearchPlugin adding ${adapter.name} filter field '${fieldName}' for '${column.attributeName}' on '${codec.name}'`);
@@ -624,9 +665,14 @@ export function createUnifiedSearchPlugin(options) {
624
665
  // Adds a single `unifiedSearch: String` field that fans out the same
625
666
  // text query to all adapters where supportsTextSearch is true.
626
667
  // WHERE clauses are combined with OR (match ANY algorithm).
668
+ //
669
+ // When graphile-llm is active, the resolver wrapper transforms the
670
+ // String value into { __text, __vector } so pgvector also participates.
627
671
  if (enableUnifiedSearch) {
628
672
  // Collect text-compatible adapters and their columns for this codec
629
673
  const textAdapterColumns = adapterColumns.filter((ac) => ac.adapter.supportsTextSearch && ac.adapter.buildTextSearchInput);
674
+ // Collect vector adapters (participate when __vector is injected by LLM plugin)
675
+ const vectorAdapterColumns = adapterColumns.filter((ac) => ac.adapter.name === 'vector');
630
676
  if (textAdapterColumns.length > 0) {
631
677
  const fieldName = 'unifiedSearch';
632
678
  newFields = build.extend(newFields, {
@@ -636,15 +682,28 @@ export function createUnifiedSearchPlugin(options) {
636
682
  }, {
637
683
  description: build.wrapDescription('Composite unified search. Provide a search string and it will be dispatched ' +
638
684
  'to all text-compatible search algorithms (tsvector, BM25, pg_trgm) simultaneously. ' +
685
+ 'When the LLM plugin is active, pgvector also participates via auto-embedding. ' +
639
686
  'Rows matching ANY algorithm are returned. All matching score fields are populated.', 'field'),
640
687
  type: build.graphql.GraphQLString,
641
688
  apply: function plan($condition, val) {
642
- if (val == null || (typeof val === 'string' && val.trim().length === 0))
689
+ if (val == null)
643
690
  return;
644
- const text = typeof val === 'string' ? val : String(val);
691
+ // Support both plain string and { __text, __vector } from LLM plugin
692
+ let text;
693
+ let vector = null;
694
+ if (typeof val === 'object' && val.__text) {
695
+ text = val.__text;
696
+ vector = val.__vector ?? null;
697
+ }
698
+ else {
699
+ text = typeof val === 'string' ? val : String(val);
700
+ if (text.trim().length === 0)
701
+ return;
702
+ }
645
703
  const qb = getQueryBuilder(build, $condition);
646
704
  // Collect all WHERE clauses (combined with OR)
647
705
  const whereClauses = [];
706
+ // Text adapters (tsvector, BM25, trgm)
648
707
  for (const { adapter, columns } of textAdapterColumns) {
649
708
  for (const column of columns) {
650
709
  // Convert text to adapter-specific filter input
@@ -652,34 +711,23 @@ export function createUnifiedSearchPlugin(options) {
652
711
  const result = adapter.buildFilterApply(sql, $condition.alias, column, filterInput, build);
653
712
  if (!result)
654
713
  continue;
655
- // Collect WHERE clause for OR combination
656
714
  if (result.whereClause) {
657
715
  whereClauses.push(result.whereClause);
658
716
  }
659
- // Still inject score into SELECT so score fields are populated
660
- if (qb && qb.mode === 'normal') {
661
- const baseFieldName = inflection.attribute({
662
- codec: pgCodec,
663
- attributeName: column.attributeName,
664
- });
665
- const scoreMetaKey = `__unified_search_${adapter.name}_${baseFieldName}`;
666
- const wrappedScoreSql = sql `${sql.parens(result.scoreExpression)}::text`;
667
- const scoreIndex = qb.selectAndReturnIndex(wrappedScoreSql);
668
- qb.setMeta(scoreMetaKey, {
669
- selectIndex: scoreIndex,
670
- });
671
- // ORDER BY: read the direction stored by the orderBy
672
- // enum (which ran first) via the shared alias key.
673
- const orderKey = `unified_order_${adapter.name}_${baseFieldName}`;
674
- const dirs = _pendingOrderDirections.get($condition.alias);
675
- const explicitDir = dirs?.[orderKey];
676
- if (explicitDir) {
677
- qb.orderBy({
678
- fragment: result.scoreExpression,
679
- codec: TYPES.float,
680
- direction: explicitDir,
681
- });
717
+ injectScoreAndRank(qb, adapter, column, result, codec, $condition);
718
+ }
719
+ }
720
+ // Vector adapters (pgvector) — only when __vector is injected
721
+ if (vector && vectorAdapterColumns.length > 0) {
722
+ for (const { adapter, columns } of vectorAdapterColumns) {
723
+ for (const column of columns) {
724
+ const result = adapter.buildFilterApply(sql, $condition.alias, column, { vector, metric: 'COSINE' }, build);
725
+ if (!result)
726
+ continue;
727
+ if (result.whereClause) {
728
+ whereClauses.push(result.whereClause);
682
729
  }
730
+ injectScoreAndRank(qb, adapter, column, result, codec, $condition);
683
731
  }
684
732
  }
685
733
  }
@@ -693,6 +741,12 @@ export function createUnifiedSearchPlugin(options) {
693
741
  $condition.where(combined);
694
742
  }
695
743
  }
744
+ else if (vector && textAdapterColumns.length === 0) {
745
+ // Vector-only table with no text adapters and embedding failed
746
+ // This shouldn't happen (caught in resolver wrapper) but safety net
747
+ throw new Error('unifiedSearch: no text adapters available and vector search requires an embedding. ' +
748
+ 'Ensure the LLM plugin is configured or add text search columns.');
749
+ }
696
750
  },
697
751
  }),
698
752
  }, `UnifiedSearchPlugin adding unifiedSearch composite filter on '${codec.name}'`);
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.4",
3
+ "version": "1.17.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": "659c7776183c8dc497e640c384e8d1af45965065"
65
+ "gitHead": "16c2ae1c30850db1d71c021346c9d34361361d8e"
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
  })),
@@ -538,6 +566,40 @@ function createUnifiedSearchPlugin(options) {
538
566
  GraphQLInputObjectType_fields(fields, build, context) {
539
567
  const { inflection, sql } = build;
540
568
  const { scope: { isPgConnectionFilter, pgCodec } = {}, fieldWithHooks, } = context;
569
+ /**
570
+ * Inject a single adapter's score expression + rank window function
571
+ * into the query builder, and apply any pending ORDER BY direction.
572
+ *
573
+ * Shared by per-adapter filter fields AND the unifiedSearch
574
+ * composite filter (both text and vector adapter branches).
575
+ */
576
+ function injectScoreAndRank(qb, adapter, column, result, codec_, $condition) {
577
+ if (!qb || qb.mode !== 'normal')
578
+ return;
579
+ const baseFieldName = inflection.attribute({
580
+ codec: codec_,
581
+ attributeName: column.attributeName,
582
+ });
583
+ const scoreMetaKey = `__unified_search_${adapter.name}_${baseFieldName}`;
584
+ const wrappedScoreSql = sql `${sql.parens(result.scoreExpression)}::text`;
585
+ const scoreIndex = qb.selectAndReturnIndex(wrappedScoreSql);
586
+ qb.setMeta(scoreMetaKey, { selectIndex: scoreIndex });
587
+ const rankMetaKey = `${scoreMetaKey}__rank`;
588
+ const orderDirection = adapter.scoreSemantics.lowerIsBetter ? 'ASC' : 'DESC';
589
+ const rankSql = sql `(ROW_NUMBER() OVER (ORDER BY ${sql.parens(result.scoreExpression)} ${orderDirection === 'ASC' ? sql.fragment `ASC` : sql.fragment `DESC`} NULLS LAST))::text`;
590
+ const rankIndex = qb.selectAndReturnIndex(rankSql);
591
+ qb.setMeta(rankMetaKey, { selectIndex: rankIndex });
592
+ const orderKey = `unified_order_${adapter.name}_${baseFieldName}`;
593
+ const dirs = _pendingOrderDirections.get($condition.alias);
594
+ const explicitDir = dirs?.[orderKey];
595
+ if (explicitDir) {
596
+ qb.orderBy({
597
+ fragment: result.scoreExpression,
598
+ codec: pg_1.TYPES.float,
599
+ direction: explicitDir,
600
+ });
601
+ }
602
+ }
541
603
  if (!isPgConnectionFilter ||
542
604
  !pgCodec ||
543
605
  !pgCodec.attributes ||
@@ -595,29 +657,8 @@ function createUnifiedSearchPlugin(options) {
595
657
  if (result.whereClause) {
596
658
  $condition.where(result.whereClause);
597
659
  }
598
- // Get the query builder for SELECT/ORDER BY injection
599
660
  const qb = (0, graphile_connection_filter_1.getQueryBuilder)(build, $condition);
600
- if (qb && qb.mode === 'normal') {
601
- // Add score to the SELECT list
602
- const wrappedScoreSql = sql `${sql.parens(result.scoreExpression)}::text`;
603
- const scoreIndex = qb.selectAndReturnIndex(wrappedScoreSql);
604
- // Store the select index in meta for the output field plan
605
- qb.setMeta(scoreMetaKey, {
606
- selectIndex: scoreIndex,
607
- });
608
- // ORDER BY: read the direction stored by the orderBy
609
- // enum (which ran first) via the shared alias key.
610
- const orderKey = `unified_order_${adapter.name}_${baseFieldName}`;
611
- const dirs = _pendingOrderDirections.get($condition.alias);
612
- const explicitDir = dirs?.[orderKey];
613
- if (explicitDir) {
614
- qb.orderBy({
615
- fragment: result.scoreExpression,
616
- codec: pg_1.TYPES.float,
617
- direction: explicitDir,
618
- });
619
- }
620
- }
661
+ injectScoreAndRank(qb, adapter, column, result, codec, $condition);
621
662
  },
622
663
  }),
623
664
  }, `UnifiedSearchPlugin adding ${adapter.name} filter field '${fieldName}' for '${column.attributeName}' on '${codec.name}'`);
@@ -627,9 +668,14 @@ function createUnifiedSearchPlugin(options) {
627
668
  // Adds a single `unifiedSearch: String` field that fans out the same
628
669
  // text query to all adapters where supportsTextSearch is true.
629
670
  // WHERE clauses are combined with OR (match ANY algorithm).
671
+ //
672
+ // When graphile-llm is active, the resolver wrapper transforms the
673
+ // String value into { __text, __vector } so pgvector also participates.
630
674
  if (enableUnifiedSearch) {
631
675
  // Collect text-compatible adapters and their columns for this codec
632
676
  const textAdapterColumns = adapterColumns.filter((ac) => ac.adapter.supportsTextSearch && ac.adapter.buildTextSearchInput);
677
+ // Collect vector adapters (participate when __vector is injected by LLM plugin)
678
+ const vectorAdapterColumns = adapterColumns.filter((ac) => ac.adapter.name === 'vector');
633
679
  if (textAdapterColumns.length > 0) {
634
680
  const fieldName = 'unifiedSearch';
635
681
  newFields = build.extend(newFields, {
@@ -639,15 +685,28 @@ function createUnifiedSearchPlugin(options) {
639
685
  }, {
640
686
  description: build.wrapDescription('Composite unified search. Provide a search string and it will be dispatched ' +
641
687
  'to all text-compatible search algorithms (tsvector, BM25, pg_trgm) simultaneously. ' +
688
+ 'When the LLM plugin is active, pgvector also participates via auto-embedding. ' +
642
689
  'Rows matching ANY algorithm are returned. All matching score fields are populated.', 'field'),
643
690
  type: build.graphql.GraphQLString,
644
691
  apply: function plan($condition, val) {
645
- if (val == null || (typeof val === 'string' && val.trim().length === 0))
692
+ if (val == null)
646
693
  return;
647
- const text = typeof val === 'string' ? val : String(val);
694
+ // Support both plain string and { __text, __vector } from LLM plugin
695
+ let text;
696
+ let vector = null;
697
+ if (typeof val === 'object' && val.__text) {
698
+ text = val.__text;
699
+ vector = val.__vector ?? null;
700
+ }
701
+ else {
702
+ text = typeof val === 'string' ? val : String(val);
703
+ if (text.trim().length === 0)
704
+ return;
705
+ }
648
706
  const qb = (0, graphile_connection_filter_1.getQueryBuilder)(build, $condition);
649
707
  // Collect all WHERE clauses (combined with OR)
650
708
  const whereClauses = [];
709
+ // Text adapters (tsvector, BM25, trgm)
651
710
  for (const { adapter, columns } of textAdapterColumns) {
652
711
  for (const column of columns) {
653
712
  // Convert text to adapter-specific filter input
@@ -655,34 +714,23 @@ function createUnifiedSearchPlugin(options) {
655
714
  const result = adapter.buildFilterApply(sql, $condition.alias, column, filterInput, build);
656
715
  if (!result)
657
716
  continue;
658
- // Collect WHERE clause for OR combination
659
717
  if (result.whereClause) {
660
718
  whereClauses.push(result.whereClause);
661
719
  }
662
- // Still inject score into SELECT so score fields are populated
663
- if (qb && qb.mode === 'normal') {
664
- const baseFieldName = inflection.attribute({
665
- codec: pgCodec,
666
- attributeName: column.attributeName,
667
- });
668
- const scoreMetaKey = `__unified_search_${adapter.name}_${baseFieldName}`;
669
- const wrappedScoreSql = sql `${sql.parens(result.scoreExpression)}::text`;
670
- const scoreIndex = qb.selectAndReturnIndex(wrappedScoreSql);
671
- qb.setMeta(scoreMetaKey, {
672
- selectIndex: scoreIndex,
673
- });
674
- // ORDER BY: read the direction stored by the orderBy
675
- // enum (which ran first) via the shared alias key.
676
- const orderKey = `unified_order_${adapter.name}_${baseFieldName}`;
677
- const dirs = _pendingOrderDirections.get($condition.alias);
678
- const explicitDir = dirs?.[orderKey];
679
- if (explicitDir) {
680
- qb.orderBy({
681
- fragment: result.scoreExpression,
682
- codec: pg_1.TYPES.float,
683
- direction: explicitDir,
684
- });
720
+ injectScoreAndRank(qb, adapter, column, result, codec, $condition);
721
+ }
722
+ }
723
+ // Vector adapters (pgvector) — only when __vector is injected
724
+ if (vector && vectorAdapterColumns.length > 0) {
725
+ for (const { adapter, columns } of vectorAdapterColumns) {
726
+ for (const column of columns) {
727
+ const result = adapter.buildFilterApply(sql, $condition.alias, column, { vector, metric: 'COSINE' }, build);
728
+ if (!result)
729
+ continue;
730
+ if (result.whereClause) {
731
+ whereClauses.push(result.whereClause);
685
732
  }
733
+ injectScoreAndRank(qb, adapter, column, result, codec, $condition);
686
734
  }
687
735
  }
688
736
  }
@@ -696,6 +744,12 @@ function createUnifiedSearchPlugin(options) {
696
744
  $condition.where(combined);
697
745
  }
698
746
  }
747
+ else if (vector && textAdapterColumns.length === 0) {
748
+ // Vector-only table with no text adapters and embedding failed
749
+ // This shouldn't happen (caught in resolver wrapper) but safety net
750
+ throw new Error('unifiedSearch: no text adapters available and vector search requires an embedding. ' +
751
+ 'Ensure the LLM plugin is configured or add text search columns.');
752
+ }
699
753
  },
700
754
  }),
701
755
  }, `UnifiedSearchPlugin adding unifiedSearch composite filter on '${codec.name}'`);
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
  }