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 +141 -87
- 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 +141 -87
- 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
|
})),
|
|
@@ -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
|
-
|
|
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
|
|
689
|
+
if (val == null)
|
|
643
690
|
return;
|
|
644
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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.
|
|
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": "
|
|
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
|
-
*
|
|
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
|
})),
|
|
@@ -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
|
-
|
|
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
|
|
692
|
+
if (val == null)
|
|
646
693
|
return;
|
|
647
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
const
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
}
|