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.
Files changed (3) hide show
  1. package/esm/plugin.js +74 -64
  2. package/package.json +5 -5
  3. 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
- if (qb && qb.mode === 'normal') {
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 || (typeof val === 'string' && val.trim().length === 0))
689
+ if (val == null)
679
690
  return;
680
- 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
+ }
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
- // Still inject score into SELECT so score fields are populated
696
- if (qb && qb.mode === 'normal') {
697
- const baseFieldName = inflection.attribute({
698
- codec: pgCodec,
699
- attributeName: column.attributeName,
700
- });
701
- const scoreMetaKey = `__unified_search_${adapter.name}_${baseFieldName}`;
702
- const wrappedScoreSql = sql `${sql.parens(result.scoreExpression)}::text`;
703
- const scoreIndex = qb.selectAndReturnIndex(wrappedScoreSql);
704
- qb.setMeta(scoreMetaKey, {
705
- selectIndex: scoreIndex,
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.16.0",
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.4",
35
- "graphile-test": "^4.17.4",
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.4"
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": "2558a0cdb58c815933af8bfe6769cd59014c77cf"
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
- if (qb && qb.mode === 'normal') {
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 || (typeof val === 'string' && val.trim().length === 0))
692
+ if (val == null)
682
693
  return;
683
- 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
+ }
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
- // Still inject score into SELECT so score fields are populated
699
- if (qb && qb.mode === 'normal') {
700
- const baseFieldName = inflection.attribute({
701
- codec: pgCodec,
702
- attributeName: column.attributeName,
703
- });
704
- const scoreMetaKey = `__unified_search_${adapter.name}_${baseFieldName}`;
705
- const wrappedScoreSql = sql `${sql.parens(result.scoreExpression)}::text`;
706
- const scoreIndex = qb.selectAndReturnIndex(wrappedScoreSql);
707
- qb.setMeta(scoreMetaKey, {
708
- selectIndex: scoreIndex,
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}'`);