graphile-llm 0.11.5 → 0.12.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.
Files changed (41) hide show
  1. package/__tests__/graphile-llm.test.js +318 -37
  2. package/__tests__/unified-search-embedding.test.d.ts +10 -0
  3. package/__tests__/unified-search-embedding.test.js +176 -0
  4. package/embedder.d.ts +24 -4
  5. package/embedder.js +36 -7
  6. package/env.d.ts +6 -28
  7. package/env.js +10 -47
  8. package/esm/__tests__/graphile-llm.test.js +319 -38
  9. package/esm/__tests__/unified-search-embedding.test.d.ts +10 -0
  10. package/esm/__tests__/unified-search-embedding.test.js +174 -0
  11. package/esm/embedder.d.ts +24 -4
  12. package/esm/embedder.js +35 -7
  13. package/esm/env.d.ts +6 -28
  14. package/esm/env.js +5 -46
  15. package/esm/index.d.ts +5 -4
  16. package/esm/index.js +3 -4
  17. package/esm/metering.d.ts +1 -3
  18. package/esm/metering.js +48 -108
  19. package/esm/plugins/llm-module-plugin.d.ts +9 -4
  20. package/esm/plugins/llm-module-plugin.js +9 -4
  21. package/esm/plugins/rag-plugin.js +30 -20
  22. package/esm/plugins/text-mutation-plugin.js +15 -2
  23. package/esm/plugins/text-search-plugin.d.ts +15 -3
  24. package/esm/plugins/text-search-plugin.js +94 -23
  25. package/esm/preset.d.ts +13 -2
  26. package/esm/preset.js +15 -4
  27. package/esm/types.d.ts +13 -0
  28. package/index.d.ts +5 -4
  29. package/index.js +6 -2
  30. package/metering.d.ts +1 -3
  31. package/metering.js +48 -108
  32. package/package.json +6 -4
  33. package/plugins/llm-module-plugin.d.ts +9 -4
  34. package/plugins/llm-module-plugin.js +9 -4
  35. package/plugins/rag-plugin.js +30 -20
  36. package/plugins/text-mutation-plugin.js +15 -2
  37. package/plugins/text-search-plugin.d.ts +15 -3
  38. package/plugins/text-search-plugin.js +95 -23
  39. package/preset.d.ts +13 -2
  40. package/preset.js +15 -4
  41. package/types.d.ts +13 -0
package/embedder.js CHANGED
@@ -5,29 +5,58 @@
5
5
  * Provides a provider-based architecture for converting text into vector
6
6
  * embeddings. Currently supports Ollama via @agentic-kit/ollama.
7
7
  *
8
- * The embedder is resolved at request time from:
9
- * 1. The `llm_module` api_modules configuration (per-database)
10
- * 2. The preset's `defaultEmbedder` option (fallback for dev/testing)
11
- * 3. Environment variables (EMBEDDER_PROVIDER, EMBEDDER_MODEL, EMBEDDER_BASE_URL)
8
+ * Per-request model/baseUrl overrides are read from AsyncLocalStorage
9
+ * (`llmConfigStore`). The text-search-plugin sets these overrides from
10
+ * the database's `llm_module` config via `ctx.useLlm()`. This means
11
+ * the same embedder function — and its metering wrapper — handle every
12
+ * request, regardless of which tenant's model config applies.
12
13
  */
13
14
  var __importDefault = (this && this.__importDefault) || function (mod) {
14
15
  return (mod && mod.__esModule) ? mod : { "default": mod };
15
16
  };
16
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.llmConfigStore = void 0;
17
19
  exports.buildEmbedder = buildEmbedder;
18
20
  exports.buildEmbedderFromModule = buildEmbedderFromModule;
19
21
  exports.buildEmbedderFromEnv = buildEmbedderFromEnv;
22
+ const node_async_hooks_1 = require("node:async_hooks");
20
23
  const ollama_1 = __importDefault(require("@agentic-kit/ollama"));
21
24
  const env_1 = require("./env");
25
+ /**
26
+ * AsyncLocalStorage for per-request LLM config overrides.
27
+ *
28
+ * Set by the text-search-plugin resolver wrapper (from ctx.useLlm()),
29
+ * read by the embedder at call time. The metering wrapper sits between
30
+ * them and is completely unaware — it just wraps the same function.
31
+ */
32
+ exports.llmConfigStore = new node_async_hooks_1.AsyncLocalStorage();
33
+ // ─── Client cache ───────────────────────────────────────────────────────────
34
+ /** Cache OllamaClient instances by baseUrl to avoid re-creation per request */
35
+ const ollamaClientCache = new Map();
36
+ function getOllamaClient(baseUrl) {
37
+ let client = ollamaClientCache.get(baseUrl);
38
+ if (!client) {
39
+ client = new ollama_1.default(baseUrl);
40
+ ollamaClientCache.set(baseUrl, client);
41
+ }
42
+ return client;
43
+ }
22
44
  // ─── Built-in Providers ─────────────────────────────────────────────────────
23
45
  /**
24
46
  * Create an Ollama-based embedder function.
25
47
  *
26
- * Uses the /api/embed endpoint which returns prompt_eval_count (real token count).
48
+ * At call time, checks `llmConfigStore` for per-request model/baseUrl
49
+ * overrides. Falls back to the defaults passed at creation time.
50
+ * Client instances are cached by baseUrl — no new objects per request.
27
51
  */
28
- function createOllamaEmbedder(baseUrl = 'http://localhost:11434', model = 'nomic-embed-text') {
29
- const client = new ollama_1.default(baseUrl);
52
+ function createOllamaEmbedder(defaultBaseUrl = 'http://localhost:11434', defaultModel = 'nomic-embed-text') {
53
+ // Pre-cache the default client
54
+ const defaultClient = getOllamaClient(defaultBaseUrl);
30
55
  return async (text) => {
56
+ const overrides = exports.llmConfigStore.getStore();
57
+ const model = overrides?.embeddingModel ?? defaultModel;
58
+ const baseUrl = overrides?.embeddingBaseUrl ?? defaultBaseUrl;
59
+ const client = baseUrl === defaultBaseUrl ? defaultClient : getOllamaClient(baseUrl);
31
60
  return client.generateEmbedding(text, model);
32
61
  };
33
62
  }
package/env.d.ts CHANGED
@@ -1,31 +1,9 @@
1
1
  /**
2
- * LLM Environment Configuration
2
+ * Re-export LLM env configuration from @constructive-io/llm-env.
3
3
  *
4
- * Single source of truth for all LLM-related environment variables and defaults.
5
- * Every other module in graphile-llm imports from here — no direct process.env
6
- * reads or scattered null coalescing elsewhere.
7
- *
8
- * Environment variables:
9
- * EMBEDDER_PROVIDER - Embedding provider name ('ollama')
10
- * EMBEDDER_MODEL - Embedding model (default: 'nomic-embed-text')
11
- * EMBEDDER_BASE_URL - Embedding provider URL (default: 'http://localhost:11434')
12
- * CHAT_PROVIDER - Chat provider name ('ollama')
13
- * CHAT_MODEL - Chat model (default: 'llama3')
14
- * CHAT_BASE_URL - Chat provider URL (default: 'http://localhost:11434')
15
- */
16
- export interface LlmProviderConfig {
17
- provider: string;
18
- model: string;
19
- baseUrl: string;
20
- }
21
- export interface LlmEnvOptions {
22
- embedding: LlmProviderConfig;
23
- chat: LlmProviderConfig;
24
- }
25
- /**
26
- * Resolve LLM configuration from environment variables with sensible defaults.
27
- *
28
- * Call this once and pass the result around — never read process.env directly
29
- * in plugin code.
4
+ * This file exists for backward compatibility — all internal graphile-llm
5
+ * modules import from './env'. The actual implementation lives in the
6
+ * shared @constructive-io/llm-env package.
30
7
  */
31
- export declare function getLlmEnvOptions(): LlmEnvOptions;
8
+ export { getEnvVars, getEnvOptions, getLlmEnvOptions, llmDefaults, } from '@constructive-io/llm-env';
9
+ export type { LlmEnvOptions, LlmProviderConfig, ResolvedLlmEnvOptions, } from '@constructive-io/llm-env';
package/env.js CHANGED
@@ -1,52 +1,15 @@
1
1
  "use strict";
2
- /**
3
- * LLM Environment Configuration
4
- *
5
- * Single source of truth for all LLM-related environment variables and defaults.
6
- * Every other module in graphile-llm imports from here — no direct process.env
7
- * reads or scattered null coalescing elsewhere.
8
- *
9
- * Environment variables:
10
- * EMBEDDER_PROVIDER - Embedding provider name ('ollama')
11
- * EMBEDDER_MODEL - Embedding model (default: 'nomic-embed-text')
12
- * EMBEDDER_BASE_URL - Embedding provider URL (default: 'http://localhost:11434')
13
- * CHAT_PROVIDER - Chat provider name ('ollama')
14
- * CHAT_MODEL - Chat model (default: 'llama3')
15
- * CHAT_BASE_URL - Chat provider URL (default: 'http://localhost:11434')
16
- */
17
2
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.getLlmEnvOptions = getLlmEnvOptions;
19
- // ─── Defaults ───────────────────────────────────────────────────────────────
20
- const LLM_DEFAULTS = {
21
- embedding: {
22
- provider: 'ollama',
23
- model: 'nomic-embed-text',
24
- baseUrl: 'http://localhost:11434'
25
- },
26
- chat: {
27
- provider: 'ollama',
28
- model: 'llama3',
29
- baseUrl: 'http://localhost:11434'
30
- }
31
- };
32
- // ─── Resolution ─────────────────────────────────────────────────────────────
3
+ exports.llmDefaults = exports.getLlmEnvOptions = exports.getEnvOptions = exports.getEnvVars = void 0;
33
4
  /**
34
- * Resolve LLM configuration from environment variables with sensible defaults.
5
+ * Re-export LLM env configuration from @constructive-io/llm-env.
35
6
  *
36
- * Call this once and pass the result around — never read process.env directly
37
- * in plugin code.
7
+ * This file exists for backward compatibility — all internal graphile-llm
8
+ * modules import from './env'. The actual implementation lives in the
9
+ * shared @constructive-io/llm-env package.
38
10
  */
39
- function getLlmEnvOptions() {
40
- return {
41
- embedding: {
42
- provider: process.env.EMBEDDER_PROVIDER ?? LLM_DEFAULTS.embedding.provider,
43
- model: process.env.EMBEDDER_MODEL ?? LLM_DEFAULTS.embedding.model,
44
- baseUrl: process.env.EMBEDDER_BASE_URL ?? LLM_DEFAULTS.embedding.baseUrl
45
- },
46
- chat: {
47
- provider: process.env.CHAT_PROVIDER ?? LLM_DEFAULTS.chat.provider,
48
- model: process.env.CHAT_MODEL ?? LLM_DEFAULTS.chat.model,
49
- baseUrl: process.env.CHAT_BASE_URL ?? LLM_DEFAULTS.chat.baseUrl
50
- }
51
- };
52
- }
11
+ var llm_env_1 = require("@constructive-io/llm-env");
12
+ Object.defineProperty(exports, "getEnvVars", { enumerable: true, get: function () { return llm_env_1.getEnvVars; } });
13
+ Object.defineProperty(exports, "getEnvOptions", { enumerable: true, get: function () { return llm_env_1.getEnvOptions; } });
14
+ Object.defineProperty(exports, "getLlmEnvOptions", { enumerable: true, get: function () { return llm_env_1.getLlmEnvOptions; } });
15
+ Object.defineProperty(exports, "llmDefaults", { enumerable: true, get: function () { return llm_env_1.llmDefaults; } });
@@ -1,6 +1,8 @@
1
1
  import OllamaClient from '@agentic-kit/ollama';
2
2
  import { ConnectionFilterPreset } from 'graphile-connection-filter';
3
3
  import { createPgvectorAdapter } from 'graphile-search/adapters/pgvector';
4
+ import { createTsvectorAdapter } from 'graphile-search/adapters/tsvector';
5
+ import { TsvectorCodecPlugin } from 'graphile-search/codecs/tsvector-codec';
4
6
  import { VectorCodecPlugin } from 'graphile-search/codecs/vector-codec';
5
7
  import { createUnifiedSearchPlugin } from 'graphile-search/plugin';
6
8
  import { getConnections, seed } from 'graphile-test';
@@ -10,7 +12,7 @@ import { buildEmbedder, buildEmbedderFromEnv, buildEmbedderFromModule } from '..
10
12
  import { createLlmModulePlugin } from '../../src/plugins/llm-module-plugin';
11
13
  import { createLlmRagPlugin } from '../../src/plugins/rag-plugin';
12
14
  import { createLlmTextMutationPlugin } from '../../src/plugins/text-mutation-plugin';
13
- import { createLlmTextSearchPlugin } from '../../src/plugins/text-search-plugin';
15
+ import { createLlmTextSearchPlugin, embedTextInWhere } from '../../src/plugins/text-search-plugin';
14
16
  // ─── @agentic-kit/ollama client ─────────────────────────────────────────────
15
17
  const ollamaClient = new OllamaClient('http://localhost:11434');
16
18
  async function ensureNomicModel() {
@@ -197,10 +199,10 @@ describe('graphile-llm schema enrichment', () => {
197
199
  });
198
200
  // ─── Mutation text companion fields ──────────────────────────────────────
199
201
  describe('Mutation text companion fields', () => {
200
- it('adds embeddingText field to CreateArticleInput', async () => {
202
+ it('adds embeddingText field to ArticleInput (create input)', async () => {
201
203
  const result = await query(`
202
204
  query {
203
- __type(name: "CreateArticleInput") {
205
+ __type(name: "ArticleInput") {
204
206
  inputFields {
205
207
  name
206
208
  type { name }
@@ -212,9 +214,7 @@ describe('graphile-llm schema enrichment', () => {
212
214
  const inputType = result.data?.__type;
213
215
  expect(inputType).toBeDefined();
214
216
  const fieldNames = inputType.inputFields.map((f) => f.name);
215
- // Original embedding field
216
217
  expect(fieldNames).toContain('embedding');
217
- // Companion text field from LlmTextMutationPlugin
218
218
  expect(fieldNames).toContain('embeddingText');
219
219
  const textField = inputType.inputFields.find((f) => f.name === 'embeddingText');
220
220
  expect(textField).toBeDefined();
@@ -423,12 +423,16 @@ function makeTestSmartTagsPlugin(tagsByTable) {
423
423
  return {
424
424
  name: 'TestSmartTagsPlugin',
425
425
  version: '1.0.0',
426
+ before: ['LlmRagPlugin'],
426
427
  schema: {
427
428
  hooks: {
428
- init: {
429
- before: ['UnifiedSearchPlugin', 'LlmRagPlugin'],
430
- callback(_, build) {
431
- for (const codec of Object.values(build.input.pgRegistry.pgCodecs)) {
429
+ build: {
430
+ before: ['LlmRagPlugin'],
431
+ callback(build) {
432
+ const pgRegistry = build.input?.pgRegistry ?? build.pgRegistry;
433
+ if (!pgRegistry)
434
+ return build;
435
+ for (const codec of Object.values(pgRegistry.pgCodecs || {})) {
432
436
  const c = codec;
433
437
  if (!c.attributes || !c.name)
434
438
  continue;
@@ -441,7 +445,7 @@ function makeTestSmartTagsPlugin(tagsByTable) {
441
445
  c.extensions.tags = {};
442
446
  Object.assign(c.extensions.tags, tags);
443
447
  }
444
- return _;
448
+ return build;
445
449
  }
446
450
  }
447
451
  }
@@ -480,47 +484,39 @@ describe('RAG plugin schema enrichment', () => {
480
484
  usage: { input: 10, output: 15, reasoning: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 25 }
481
485
  };
482
486
  };
487
+ // Provide mock embedder/chat directly instead of using createLlmModulePlugin
488
+ // to avoid build.extend naming conflicts when overriding
489
+ const mockLlmPlugin = {
490
+ name: 'TestMockLlmPlugin',
491
+ version: '1.0.0',
492
+ schema: {
493
+ hooks: {
494
+ build(build) {
495
+ return build.extend(build, {
496
+ llmEmbedder: mockEmbedder,
497
+ llmChatCompleter: mockChatCompleter,
498
+ llmEmbeddingModel: 'test-mock-model',
499
+ llmChatModel: 'test-mock-chat'
500
+ }, 'TestMockLlmPlugin providing mock embedder and chat completer');
501
+ }
502
+ }
503
+ }
504
+ };
483
505
  const testPreset = {
484
506
  extends: [ConnectionFilterPreset()],
485
507
  plugins: [
486
508
  VectorCodecPlugin,
487
509
  unifiedPlugin,
488
510
  smartTagsPlugin,
489
- createLlmModulePlugin({
490
- defaultEmbedder: {
491
- provider: 'ollama',
492
- model: 'nomic-embed-text',
493
- baseUrl: 'http://localhost:11434'
494
- }
495
- }),
511
+ mockLlmPlugin,
496
512
  createLlmTextSearchPlugin(),
497
513
  createLlmTextMutationPlugin(),
498
514
  createLlmRagPlugin()
499
515
  ]
500
516
  };
501
- // Override the embedder and chat completer on the build context
502
- // by wrapping the LlmModulePlugin's build hook
503
- const overridePlugin = {
504
- name: 'TestOverridePlugin',
505
- version: '1.0.0',
506
- after: ['LlmModulePlugin'],
507
- schema: {
508
- hooks: {
509
- build(build) {
510
- return build.extend(build, {
511
- llmEmbedder: mockEmbedder,
512
- llmChatCompleter: mockChatCompleter
513
- }, 'TestOverridePlugin overriding embedder and chat completer');
514
- }
515
- }
516
- }
517
- };
518
517
  const connections = await getConnections({
519
518
  schemas: ['llm_test'],
520
- preset: {
521
- ...testPreset,
522
- plugins: [...testPreset.plugins, overridePlugin]
523
- },
519
+ preset: testPreset,
524
520
  useRoot: true,
525
521
  authRole: 'postgres'
526
522
  }, [seed.sqlfile([join(__dirname, './setup.sql')])]);
@@ -697,3 +693,288 @@ describe('GraphileLlmPreset toggles', () => {
697
693
  expect(pluginNames).not.toContain('LlmRagPlugin');
698
694
  });
699
695
  });
696
+ // =============================================================================
697
+ // Suite 7: Real Ollama + unifiedSearch + pgvector RRF integration
698
+ //
699
+ // End-to-end: embeds article text with real Ollama nomic-embed-text (768 dims),
700
+ // seeds the DB, then runs hybrid text+vector queries verifying RRF fusion,
701
+ // semantic relevance, and the embedTextInWhere pipeline.
702
+ //
703
+ // Requires: PostgreSQL with pgvector + Ollama with nomic-embed-text.
704
+ // =============================================================================
705
+ const INTEGRATION_ARTICLES = [
706
+ { id: 1, title: 'Introduction to Machine Learning', body: 'Machine learning is a subset of artificial intelligence that focuses on algorithms and statistical models.' },
707
+ { id: 2, title: 'Cooking Italian Pasta', body: 'The best Italian pasta is made with fresh ingredients like San Marzano tomatoes and homemade egg noodles.' },
708
+ { id: 3, title: 'Deep Learning with Neural Networks', body: 'Deep learning uses neural networks with multiple layers to learn hierarchical representations of data.' },
709
+ { id: 4, title: 'Gardening Tips for Spring', body: 'Start your spring garden by preparing soil, choosing the right seeds, and ensuring proper sunlight and watering.' },
710
+ { id: 5, title: 'Natural Language Processing', body: 'NLP combines computational linguistics with machine learning to enable computers to understand human language.' },
711
+ ];
712
+ const INTEGRATION_SETUP_SQL = `
713
+ CREATE EXTENSION IF NOT EXISTS vector;
714
+ CREATE SCHEMA IF NOT EXISTS llm_integration;
715
+
716
+ CREATE TABLE llm_integration.articles (
717
+ id serial PRIMARY KEY,
718
+ title text NOT NULL,
719
+ body text NOT NULL,
720
+ tsv tsvector GENERATED ALWAYS AS (
721
+ setweight(to_tsvector('english', title), 'A') ||
722
+ setweight(to_tsvector('english', body), 'B')
723
+ ) STORED,
724
+ embedding vector(768) NOT NULL
725
+ );
726
+
727
+ CREATE INDEX idx_int_articles_tsv ON llm_integration.articles USING gin(tsv);
728
+ CREATE INDEX idx_int_articles_embedding ON llm_integration.articles
729
+ USING hnsw(embedding vector_cosine_ops);
730
+ `;
731
+ describe('Real Ollama + unifiedSearch + pgvector RRF integration', () => {
732
+ let db;
733
+ let teardown;
734
+ let query;
735
+ let embedder;
736
+ // Pre-computed query vectors (embedded once in beforeAll)
737
+ let mlQueryVector;
738
+ let cookingQueryVector;
739
+ beforeAll(async () => {
740
+ // Build a real Ollama embedder
741
+ const built = buildEmbedder({
742
+ provider: 'ollama',
743
+ model: 'nomic-embed-text',
744
+ baseUrl: 'http://localhost:11434',
745
+ });
746
+ if (!built)
747
+ throw new Error('Failed to build Ollama embedder');
748
+ embedder = built;
749
+ await ensureNomicModel();
750
+ // Embed all article bodies in parallel
751
+ const embeddings = await Promise.all(INTEGRATION_ARTICLES.map((a) => embedder(`${a.title}. ${a.body}`)));
752
+ // Build seed SQL with real 768-dim embeddings
753
+ const values = INTEGRATION_ARTICLES.map((a, i) => {
754
+ const vecStr = `'[${embeddings[i].embedding.join(',')}]'`;
755
+ const titleEsc = a.title.replace(/'/g, "''");
756
+ const bodyEsc = a.body.replace(/'/g, "''");
757
+ return `(${a.id}, '${titleEsc}', '${bodyEsc}', ${vecStr})`;
758
+ }).join(',\n ');
759
+ const seedSql = `
760
+ INSERT INTO llm_integration.articles (id, title, body, embedding)
761
+ VALUES ${values};
762
+ SELECT setval('llm_integration.articles_id_seq', ${INTEGRATION_ARTICLES.length});
763
+ `;
764
+ // Pre-compute search query vectors
765
+ const [mlResult, cookResult] = await Promise.all([
766
+ embedder('machine learning artificial intelligence algorithms'),
767
+ embedder('Italian cooking pasta recipes tomatoes'),
768
+ ]);
769
+ mlQueryVector = mlResult.embedding;
770
+ cookingQueryVector = cookResult.embedding;
771
+ // Set up PostGraphile with tsvector + pgvector adapters + LLM plugin
772
+ const unifiedPlugin = createUnifiedSearchPlugin({
773
+ adapters: [
774
+ createTsvectorAdapter(),
775
+ createPgvectorAdapter(),
776
+ ],
777
+ enableSearchScore: true,
778
+ enableUnifiedSearch: true,
779
+ rrfK: 60,
780
+ });
781
+ const testPreset = {
782
+ extends: [ConnectionFilterPreset()],
783
+ plugins: [
784
+ TsvectorCodecPlugin,
785
+ VectorCodecPlugin,
786
+ unifiedPlugin,
787
+ createLlmModulePlugin({
788
+ defaultEmbedder: {
789
+ provider: 'ollama',
790
+ model: 'nomic-embed-text',
791
+ baseUrl: 'http://localhost:11434',
792
+ },
793
+ }),
794
+ createLlmTextSearchPlugin(),
795
+ ],
796
+ };
797
+ const connections = await getConnections({
798
+ schemas: ['llm_integration'],
799
+ preset: testPreset,
800
+ useRoot: true,
801
+ authRole: 'postgres',
802
+ }, [
803
+ seed.fn(async (ctx) => {
804
+ await ctx.pg.query(INTEGRATION_SETUP_SQL);
805
+ await ctx.pg.query(seedSql);
806
+ }),
807
+ ]);
808
+ db = connections.db;
809
+ teardown = connections.teardown;
810
+ query = connections.query;
811
+ await db.client.query('BEGIN');
812
+ }, 120_000);
813
+ afterAll(async () => {
814
+ if (db) {
815
+ try {
816
+ await db.client.query('ROLLBACK');
817
+ }
818
+ catch { }
819
+ }
820
+ if (teardown)
821
+ await teardown();
822
+ });
823
+ beforeEach(async () => { await db.beforeEach(); });
824
+ afterEach(async () => { await db.afterEach(); });
825
+ // ─── embedTextInWhere with real Ollama ──────────────────────────────────
826
+ it('embedTextInWhere transforms unifiedSearch with real 768-dim Ollama vector', async () => {
827
+ const realEmbedder = async (text) => {
828
+ const result = await embedder(text);
829
+ return result.embedding;
830
+ };
831
+ const where = { unifiedSearch: 'machine learning algorithms' };
832
+ await embedTextInWhere(where, realEmbedder, true);
833
+ expect(where.unifiedSearch).toHaveProperty('__text', 'machine learning algorithms');
834
+ expect(where.unifiedSearch).toHaveProperty('__vector');
835
+ expect(Array.isArray(where.unifiedSearch.__vector)).toBe(true);
836
+ expect(where.unifiedSearch.__vector.length).toBe(768);
837
+ for (const v of where.unifiedSearch.__vector) {
838
+ expect(typeof v).toBe('number');
839
+ expect(Number.isFinite(v)).toBe(true);
840
+ }
841
+ }, 30_000);
842
+ // ─── unifiedSearch text-only (no vector injection) ─────────────────────
843
+ it('unifiedSearch text-only uses tsvector without pgvector', async () => {
844
+ const result = await query(`
845
+ query {
846
+ allArticles(where: { unifiedSearch: "machine learning" }) {
847
+ nodes { rowId title tsvRank embeddingVectorDistance searchScore }
848
+ }
849
+ }
850
+ `);
851
+ expect(result.errors).toBeUndefined();
852
+ const nodes = result.data?.allArticles?.nodes ?? [];
853
+ expect(nodes.length).toBeGreaterThan(0);
854
+ // tsvector should be active
855
+ const hasTsv = nodes.some((n) => n.tsvRank !== null);
856
+ expect(hasTsv).toBe(true);
857
+ // pgvector should NOT participate (no vector injection in graphile-test)
858
+ for (const node of nodes) {
859
+ expect(node.embeddingVectorDistance).toBeNull();
860
+ }
861
+ });
862
+ // ─── unifiedSearch + real pgvector: hybrid RRF ─────────────────────────
863
+ it('unifiedSearch + real vector embedding fuses tsvector + pgvector via RRF', async () => {
864
+ const vecStr = `[${mlQueryVector.join(',')}]`;
865
+ const result = await query(`
866
+ query($vec: [Float!]!) {
867
+ allArticles(where: {
868
+ unifiedSearch: "machine learning"
869
+ vectorEmbedding: { vector: $vec, metric: COSINE }
870
+ }) {
871
+ nodes { rowId title tsvRank embeddingVectorDistance searchScore }
872
+ }
873
+ }
874
+ `, { vec: mlQueryVector });
875
+ expect(result.errors).toBeUndefined();
876
+ const nodes = result.data?.allArticles?.nodes ?? [];
877
+ expect(nodes.length).toBeGreaterThan(0);
878
+ // Both adapters should be active
879
+ const hasTsv = nodes.some((n) => n.tsvRank !== null);
880
+ const hasVector = nodes.some((n) => n.embeddingVectorDistance !== null);
881
+ expect(hasTsv).toBe(true);
882
+ expect(hasVector).toBe(true);
883
+ // searchScore must be normalized [0,1]
884
+ for (const node of nodes) {
885
+ expect(node.searchScore).toBeGreaterThanOrEqual(0);
886
+ expect(node.searchScore).toBeLessThanOrEqual(1);
887
+ }
888
+ });
889
+ // ─── Semantic relevance with real embeddings ───────────────────────────
890
+ it('ML query ranks ML articles higher than cooking/gardening with real embeddings', async () => {
891
+ const result = await query(`
892
+ query($vec: [Float!]!) {
893
+ allArticles(where: {
894
+ unifiedSearch: "machine learning"
895
+ vectorEmbedding: { vector: $vec, metric: COSINE }
896
+ }) {
897
+ nodes { rowId title searchScore }
898
+ }
899
+ }
900
+ `, { vec: mlQueryVector });
901
+ expect(result.errors).toBeUndefined();
902
+ const nodes = result.data?.allArticles?.nodes ?? [];
903
+ // ML-related articles (1, 3, 5) should score higher than non-ML (2, 4)
904
+ const mlArticles = nodes.filter((n) => [1, 3, 5].includes(n.rowId));
905
+ const nonMlArticles = nodes.filter((n) => [2, 4].includes(n.rowId));
906
+ if (mlArticles.length > 0 && nonMlArticles.length > 0) {
907
+ const bestMlScore = Math.max(...mlArticles.map((n) => n.searchScore));
908
+ const bestNonMlScore = Math.max(...nonMlArticles.map((n) => n.searchScore));
909
+ expect(bestMlScore).toBeGreaterThan(bestNonMlScore);
910
+ }
911
+ });
912
+ it('cooking query ranks cooking article higher than ML articles with real embeddings', async () => {
913
+ const result = await query(`
914
+ query($vec: [Float!]!) {
915
+ allArticles(where: {
916
+ unifiedSearch: "Italian pasta cooking"
917
+ vectorEmbedding: { vector: $vec, metric: COSINE }
918
+ }) {
919
+ nodes { rowId title searchScore }
920
+ }
921
+ }
922
+ `, { vec: cookingQueryVector });
923
+ expect(result.errors).toBeUndefined();
924
+ const nodes = result.data?.allArticles?.nodes ?? [];
925
+ // Cooking article (id=2) should rank highest
926
+ const cookingArticle = nodes.find((n) => n.rowId === 2);
927
+ const otherArticles = nodes.filter((n) => n.rowId !== 2);
928
+ if (cookingArticle && otherArticles.length > 0) {
929
+ const bestOtherScore = Math.max(...otherArticles.map((n) => n.searchScore));
930
+ expect(cookingArticle.searchScore).toBeGreaterThan(bestOtherScore);
931
+ }
932
+ });
933
+ // ─── Vector-only search with real embeddings ───────────────────────────
934
+ it('VectorNearbyInput with real embedding returns semantically relevant results', async () => {
935
+ const result = await query(`
936
+ query($vec: [Float!]!) {
937
+ allArticles(where: {
938
+ vectorEmbedding: { vector: $vec, metric: COSINE }
939
+ }) {
940
+ nodes { rowId title embeddingVectorDistance }
941
+ }
942
+ }
943
+ `, { vec: mlQueryVector });
944
+ expect(result.errors).toBeUndefined();
945
+ const nodes = result.data?.allArticles?.nodes ?? [];
946
+ expect(nodes.length).toBe(5);
947
+ // Nearest to ML query should be ML articles
948
+ const sorted = [...nodes].sort((a, b) => a.embeddingVectorDistance - b.embeddingVectorDistance);
949
+ // Top result should be an ML-related article (1, 3, or 5)
950
+ expect([1, 3, 5]).toContain(sorted[0].rowId);
951
+ });
952
+ // ─── RRF boost: text+vector scores higher than text-only ───────────────
953
+ it('hybrid text+vector search scores higher than text-only for relevant docs', async () => {
954
+ const textOnly = await query(`
955
+ query {
956
+ allArticles(where: { unifiedSearch: "machine learning" }) {
957
+ nodes { rowId searchScore }
958
+ }
959
+ }
960
+ `);
961
+ const hybrid = await query(`
962
+ query($vec: [Float!]!) {
963
+ allArticles(where: {
964
+ unifiedSearch: "machine learning"
965
+ vectorEmbedding: { vector: $vec, metric: COSINE }
966
+ }) {
967
+ nodes { rowId searchScore }
968
+ }
969
+ }
970
+ `, { vec: mlQueryVector });
971
+ expect(textOnly.errors).toBeUndefined();
972
+ expect(hybrid.errors).toBeUndefined();
973
+ const doc1TextOnly = textOnly.data?.allArticles?.nodes?.find((n) => n.rowId === 1);
974
+ const doc1Hybrid = hybrid.data?.allArticles?.nodes?.find((n) => n.rowId === 1);
975
+ // Doc 1 (ML article) should score at least as high with hybrid
976
+ if (doc1TextOnly && doc1Hybrid) {
977
+ expect(doc1Hybrid.searchScore).toBeGreaterThanOrEqual(doc1TextOnly.searchScore);
978
+ }
979
+ });
980
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Unit tests for LlmTextSearchPlugin's embedTextInWhere function.
3
+ *
4
+ * Tests the transformation logic:
5
+ * - unifiedSearch: "text" → unifiedSearch: { __text: "text", __vector: [...] }
6
+ * - VectorNearbyInput.text → VectorNearbyInput.vector (existing behavior)
7
+ *
8
+ * Pure unit tests — no database or Ollama required.
9
+ */
10
+ export {};