graphile-search 1.16.0 → 1.17.1
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 +74 -64
- package/package.json +5 -5
- package/plugin.js +74 -64
package/esm/plugin.js
CHANGED
|
@@ -563,6 +563,40 @@ export function createUnifiedSearchPlugin(options) {
|
|
|
563
563
|
GraphQLInputObjectType_fields(fields, build, context) {
|
|
564
564
|
const { inflection, sql } = build;
|
|
565
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
|
+
}
|
|
566
600
|
if (!isPgConnectionFilter ||
|
|
567
601
|
!pgCodec ||
|
|
568
602
|
!pgCodec.attributes ||
|
|
@@ -620,37 +654,8 @@ export function createUnifiedSearchPlugin(options) {
|
|
|
620
654
|
if (result.whereClause) {
|
|
621
655
|
$condition.where(result.whereClause);
|
|
622
656
|
}
|
|
623
|
-
// Get the query builder for SELECT/ORDER BY injection
|
|
624
657
|
const qb = getQueryBuilder(build, $condition);
|
|
625
|
-
|
|
626
|
-
// Add score to the SELECT list
|
|
627
|
-
const wrappedScoreSql = sql `${sql.parens(result.scoreExpression)}::text`;
|
|
628
|
-
const scoreIndex = qb.selectAndReturnIndex(wrappedScoreSql);
|
|
629
|
-
// Store the select index in meta for the output field plan
|
|
630
|
-
qb.setMeta(scoreMetaKey, {
|
|
631
|
-
selectIndex: scoreIndex,
|
|
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
|
-
});
|
|
641
|
-
// ORDER BY: read the direction stored by the orderBy
|
|
642
|
-
// enum (which ran first) via the shared alias key.
|
|
643
|
-
const orderKey = `unified_order_${adapter.name}_${baseFieldName}`;
|
|
644
|
-
const dirs = _pendingOrderDirections.get($condition.alias);
|
|
645
|
-
const explicitDir = dirs?.[orderKey];
|
|
646
|
-
if (explicitDir) {
|
|
647
|
-
qb.orderBy({
|
|
648
|
-
fragment: result.scoreExpression,
|
|
649
|
-
codec: TYPES.float,
|
|
650
|
-
direction: explicitDir,
|
|
651
|
-
});
|
|
652
|
-
}
|
|
653
|
-
}
|
|
658
|
+
injectScoreAndRank(qb, adapter, column, result, codec, $condition);
|
|
654
659
|
},
|
|
655
660
|
}),
|
|
656
661
|
}, `UnifiedSearchPlugin adding ${adapter.name} filter field '${fieldName}' for '${column.attributeName}' on '${codec.name}'`);
|
|
@@ -660,9 +665,14 @@ export function createUnifiedSearchPlugin(options) {
|
|
|
660
665
|
// Adds a single `unifiedSearch: String` field that fans out the same
|
|
661
666
|
// text query to all adapters where supportsTextSearch is true.
|
|
662
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.
|
|
663
671
|
if (enableUnifiedSearch) {
|
|
664
672
|
// Collect text-compatible adapters and their columns for this codec
|
|
665
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');
|
|
666
676
|
if (textAdapterColumns.length > 0) {
|
|
667
677
|
const fieldName = 'unifiedSearch';
|
|
668
678
|
newFields = build.extend(newFields, {
|
|
@@ -672,15 +682,28 @@ export function createUnifiedSearchPlugin(options) {
|
|
|
672
682
|
}, {
|
|
673
683
|
description: build.wrapDescription('Composite unified search. Provide a search string and it will be dispatched ' +
|
|
674
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. ' +
|
|
675
686
|
'Rows matching ANY algorithm are returned. All matching score fields are populated.', 'field'),
|
|
676
687
|
type: build.graphql.GraphQLString,
|
|
677
688
|
apply: function plan($condition, val) {
|
|
678
|
-
if (val == null
|
|
689
|
+
if (val == null)
|
|
679
690
|
return;
|
|
680
|
-
|
|
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
|
+
}
|
|
681
703
|
const qb = getQueryBuilder(build, $condition);
|
|
682
704
|
// Collect all WHERE clauses (combined with OR)
|
|
683
705
|
const whereClauses = [];
|
|
706
|
+
// Text adapters (tsvector, BM25, trgm)
|
|
684
707
|
for (const { adapter, columns } of textAdapterColumns) {
|
|
685
708
|
for (const column of columns) {
|
|
686
709
|
// Convert text to adapter-specific filter input
|
|
@@ -688,42 +711,23 @@ export function createUnifiedSearchPlugin(options) {
|
|
|
688
711
|
const result = adapter.buildFilterApply(sql, $condition.alias, column, filterInput, build);
|
|
689
712
|
if (!result)
|
|
690
713
|
continue;
|
|
691
|
-
// Collect WHERE clause for OR combination
|
|
692
714
|
if (result.whereClause) {
|
|
693
715
|
whereClauses.push(result.whereClause);
|
|
694
716
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
});
|
|
715
|
-
// ORDER BY: read the direction stored by the orderBy
|
|
716
|
-
// enum (which ran first) via the shared alias key.
|
|
717
|
-
const orderKey = `unified_order_${adapter.name}_${baseFieldName}`;
|
|
718
|
-
const dirs = _pendingOrderDirections.get($condition.alias);
|
|
719
|
-
const explicitDir = dirs?.[orderKey];
|
|
720
|
-
if (explicitDir) {
|
|
721
|
-
qb.orderBy({
|
|
722
|
-
fragment: result.scoreExpression,
|
|
723
|
-
codec: TYPES.float,
|
|
724
|
-
direction: explicitDir,
|
|
725
|
-
});
|
|
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);
|
|
726
729
|
}
|
|
730
|
+
injectScoreAndRank(qb, adapter, column, result, codec, $condition);
|
|
727
731
|
}
|
|
728
732
|
}
|
|
729
733
|
}
|
|
@@ -737,6 +741,12 @@ export function createUnifiedSearchPlugin(options) {
|
|
|
737
741
|
$condition.where(combined);
|
|
738
742
|
}
|
|
739
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
|
+
}
|
|
740
750
|
},
|
|
741
751
|
}),
|
|
742
752
|
}, `UnifiedSearchPlugin adding unifiedSearch composite filter on '${codec.name}'`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphile-search",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.1",
|
|
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.
|
|
35
|
-
"graphile-test": "^4.17.
|
|
34
|
+
"graphile-connection-filter": "^1.12.5",
|
|
35
|
+
"graphile-test": "^4.17.5",
|
|
36
36
|
"makage": "^0.3.0",
|
|
37
37
|
"pg": "^8.21.0",
|
|
38
|
-
"pgsql-test": "^4.16.
|
|
38
|
+
"pgsql-test": "^4.16.5"
|
|
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": "
|
|
65
|
+
"gitHead": "00ebac5e8314330aa47564ed71758a4bcc8f8f8e"
|
|
66
66
|
}
|
package/plugin.js
CHANGED
|
@@ -566,6 +566,40 @@ function createUnifiedSearchPlugin(options) {
|
|
|
566
566
|
GraphQLInputObjectType_fields(fields, build, context) {
|
|
567
567
|
const { inflection, sql } = build;
|
|
568
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
|
+
}
|
|
569
603
|
if (!isPgConnectionFilter ||
|
|
570
604
|
!pgCodec ||
|
|
571
605
|
!pgCodec.attributes ||
|
|
@@ -623,37 +657,8 @@ function createUnifiedSearchPlugin(options) {
|
|
|
623
657
|
if (result.whereClause) {
|
|
624
658
|
$condition.where(result.whereClause);
|
|
625
659
|
}
|
|
626
|
-
// Get the query builder for SELECT/ORDER BY injection
|
|
627
660
|
const qb = (0, graphile_connection_filter_1.getQueryBuilder)(build, $condition);
|
|
628
|
-
|
|
629
|
-
// Add score to the SELECT list
|
|
630
|
-
const wrappedScoreSql = sql `${sql.parens(result.scoreExpression)}::text`;
|
|
631
|
-
const scoreIndex = qb.selectAndReturnIndex(wrappedScoreSql);
|
|
632
|
-
// Store the select index in meta for the output field plan
|
|
633
|
-
qb.setMeta(scoreMetaKey, {
|
|
634
|
-
selectIndex: scoreIndex,
|
|
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
|
-
});
|
|
644
|
-
// ORDER BY: read the direction stored by the orderBy
|
|
645
|
-
// enum (which ran first) via the shared alias key.
|
|
646
|
-
const orderKey = `unified_order_${adapter.name}_${baseFieldName}`;
|
|
647
|
-
const dirs = _pendingOrderDirections.get($condition.alias);
|
|
648
|
-
const explicitDir = dirs?.[orderKey];
|
|
649
|
-
if (explicitDir) {
|
|
650
|
-
qb.orderBy({
|
|
651
|
-
fragment: result.scoreExpression,
|
|
652
|
-
codec: pg_1.TYPES.float,
|
|
653
|
-
direction: explicitDir,
|
|
654
|
-
});
|
|
655
|
-
}
|
|
656
|
-
}
|
|
661
|
+
injectScoreAndRank(qb, adapter, column, result, codec, $condition);
|
|
657
662
|
},
|
|
658
663
|
}),
|
|
659
664
|
}, `UnifiedSearchPlugin adding ${adapter.name} filter field '${fieldName}' for '${column.attributeName}' on '${codec.name}'`);
|
|
@@ -663,9 +668,14 @@ function createUnifiedSearchPlugin(options) {
|
|
|
663
668
|
// Adds a single `unifiedSearch: String` field that fans out the same
|
|
664
669
|
// text query to all adapters where supportsTextSearch is true.
|
|
665
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.
|
|
666
674
|
if (enableUnifiedSearch) {
|
|
667
675
|
// Collect text-compatible adapters and their columns for this codec
|
|
668
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');
|
|
669
679
|
if (textAdapterColumns.length > 0) {
|
|
670
680
|
const fieldName = 'unifiedSearch';
|
|
671
681
|
newFields = build.extend(newFields, {
|
|
@@ -675,15 +685,28 @@ function createUnifiedSearchPlugin(options) {
|
|
|
675
685
|
}, {
|
|
676
686
|
description: build.wrapDescription('Composite unified search. Provide a search string and it will be dispatched ' +
|
|
677
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. ' +
|
|
678
689
|
'Rows matching ANY algorithm are returned. All matching score fields are populated.', 'field'),
|
|
679
690
|
type: build.graphql.GraphQLString,
|
|
680
691
|
apply: function plan($condition, val) {
|
|
681
|
-
if (val == null
|
|
692
|
+
if (val == null)
|
|
682
693
|
return;
|
|
683
|
-
|
|
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
|
+
}
|
|
684
706
|
const qb = (0, graphile_connection_filter_1.getQueryBuilder)(build, $condition);
|
|
685
707
|
// Collect all WHERE clauses (combined with OR)
|
|
686
708
|
const whereClauses = [];
|
|
709
|
+
// Text adapters (tsvector, BM25, trgm)
|
|
687
710
|
for (const { adapter, columns } of textAdapterColumns) {
|
|
688
711
|
for (const column of columns) {
|
|
689
712
|
// Convert text to adapter-specific filter input
|
|
@@ -691,42 +714,23 @@ function createUnifiedSearchPlugin(options) {
|
|
|
691
714
|
const result = adapter.buildFilterApply(sql, $condition.alias, column, filterInput, build);
|
|
692
715
|
if (!result)
|
|
693
716
|
continue;
|
|
694
|
-
// Collect WHERE clause for OR combination
|
|
695
717
|
if (result.whereClause) {
|
|
696
718
|
whereClauses.push(result.whereClause);
|
|
697
719
|
}
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
const
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
});
|
|
718
|
-
// ORDER BY: read the direction stored by the orderBy
|
|
719
|
-
// enum (which ran first) via the shared alias key.
|
|
720
|
-
const orderKey = `unified_order_${adapter.name}_${baseFieldName}`;
|
|
721
|
-
const dirs = _pendingOrderDirections.get($condition.alias);
|
|
722
|
-
const explicitDir = dirs?.[orderKey];
|
|
723
|
-
if (explicitDir) {
|
|
724
|
-
qb.orderBy({
|
|
725
|
-
fragment: result.scoreExpression,
|
|
726
|
-
codec: pg_1.TYPES.float,
|
|
727
|
-
direction: explicitDir,
|
|
728
|
-
});
|
|
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);
|
|
729
732
|
}
|
|
733
|
+
injectScoreAndRank(qb, adapter, column, result, codec, $condition);
|
|
730
734
|
}
|
|
731
735
|
}
|
|
732
736
|
}
|
|
@@ -740,6 +744,12 @@ function createUnifiedSearchPlugin(options) {
|
|
|
740
744
|
$condition.where(combined);
|
|
741
745
|
}
|
|
742
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
|
+
}
|
|
743
753
|
},
|
|
744
754
|
}),
|
|
745
755
|
}, `UnifiedSearchPlugin adding unifiedSearch composite filter on '${codec.name}'`);
|