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.
- package/__tests__/graphile-llm.test.js +318 -37
- package/__tests__/unified-search-embedding.test.d.ts +10 -0
- package/__tests__/unified-search-embedding.test.js +176 -0
- package/embedder.d.ts +24 -4
- package/embedder.js +36 -7
- package/env.d.ts +6 -28
- package/env.js +10 -47
- package/esm/__tests__/graphile-llm.test.js +319 -38
- package/esm/__tests__/unified-search-embedding.test.d.ts +10 -0
- package/esm/__tests__/unified-search-embedding.test.js +174 -0
- package/esm/embedder.d.ts +24 -4
- package/esm/embedder.js +35 -7
- package/esm/env.d.ts +6 -28
- package/esm/env.js +5 -46
- package/esm/index.d.ts +5 -4
- package/esm/index.js +3 -4
- package/esm/metering.d.ts +1 -3
- package/esm/metering.js +48 -108
- package/esm/plugins/llm-module-plugin.d.ts +9 -4
- package/esm/plugins/llm-module-plugin.js +9 -4
- package/esm/plugins/rag-plugin.js +30 -20
- package/esm/plugins/text-mutation-plugin.js +15 -2
- package/esm/plugins/text-search-plugin.d.ts +15 -3
- package/esm/plugins/text-search-plugin.js +94 -23
- package/esm/preset.d.ts +13 -2
- package/esm/preset.js +15 -4
- package/esm/types.d.ts +13 -0
- package/index.d.ts +5 -4
- package/index.js +6 -2
- package/metering.d.ts +1 -3
- package/metering.js +48 -108
- package/package.json +6 -4
- package/plugins/llm-module-plugin.d.ts +9 -4
- package/plugins/llm-module-plugin.js +9 -4
- package/plugins/rag-plugin.js +30 -20
- package/plugins/text-mutation-plugin.js +15 -2
- package/plugins/text-search-plugin.d.ts +15 -3
- package/plugins/text-search-plugin.js +95 -23
- package/preset.d.ts +13 -2
- package/preset.js +15 -4
- 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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
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(
|
|
29
|
-
|
|
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
|
|
2
|
+
* Re-export LLM env configuration from @constructive-io/llm-env.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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 =
|
|
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
|
-
*
|
|
5
|
+
* Re-export LLM env configuration from @constructive-io/llm-env.
|
|
35
6
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
202
|
+
it('adds embeddingText field to ArticleInput (create input)', async () => {
|
|
201
203
|
const result = await query(`
|
|
202
204
|
query {
|
|
203
|
-
__type(name: "
|
|
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
|
-
|
|
429
|
-
before: ['
|
|
430
|
-
callback(
|
|
431
|
-
|
|
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
|
-
|
|
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 {};
|