graphile-llm 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/package.json +10 -10
- package/__tests__/graphile-llm.test.d.ts +0 -1
- package/__tests__/graphile-llm.test.js +0 -1060
- package/__tests__/unified-search-embedding.test.d.ts +0 -10
- package/__tests__/unified-search-embedding.test.js +0 -176
- package/esm/__tests__/graphile-llm.test.d.ts +0 -1
- package/esm/__tests__/graphile-llm.test.js +0 -1022
- package/esm/__tests__/unified-search-embedding.test.d.ts +0 -10
- package/esm/__tests__/unified-search-embedding.test.js +0 -174
|
@@ -1,1022 +0,0 @@
|
|
|
1
|
-
import OllamaClient from '@agentic-kit/ollama';
|
|
2
|
-
import { ConnectionFilterPreset } from 'graphile-connection-filter';
|
|
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';
|
|
6
|
-
import { VectorCodecPlugin } from 'graphile-search/codecs/vector-codec';
|
|
7
|
-
import { createUnifiedSearchPlugin } from 'graphile-search/plugin';
|
|
8
|
-
import { getConnections, seed } from 'graphile-test';
|
|
9
|
-
import { join } from 'path';
|
|
10
|
-
import { buildChatCompleter, buildChatCompleterFromEnv, buildChatCompleterFromModule } from '../../src/chat';
|
|
11
|
-
import { buildEmbedder, buildEmbedderFromEnv, buildEmbedderFromModule } from '../../src/embedder';
|
|
12
|
-
import { createLlmModulePlugin } from '../../src/plugins/llm-module-plugin';
|
|
13
|
-
import { createLlmRagPlugin } from '../../src/plugins/rag-plugin';
|
|
14
|
-
import { createLlmTextMutationPlugin } from '../../src/plugins/text-mutation-plugin';
|
|
15
|
-
import { createLlmTextSearchPlugin, embedTextInWhere } from '../../src/plugins/text-search-plugin';
|
|
16
|
-
// ─── @agentic-kit/ollama client ─────────────────────────────────────────────
|
|
17
|
-
const ollamaClient = new OllamaClient('http://localhost:11434');
|
|
18
|
-
async function ensureNomicModel() {
|
|
19
|
-
const models = await ollamaClient.listModels();
|
|
20
|
-
const hasModel = models.some((m) => m.includes('nomic-embed-text'));
|
|
21
|
-
if (!hasModel) {
|
|
22
|
-
console.log('Pulling nomic-embed-text model...');
|
|
23
|
-
await ollamaClient.pullModel('nomic-embed-text');
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
// =============================================================================
|
|
27
|
-
// Suite 1: Embedder abstraction unit tests
|
|
28
|
-
// =============================================================================
|
|
29
|
-
describe('Embedder abstraction', () => {
|
|
30
|
-
describe('buildEmbedder()', () => {
|
|
31
|
-
it('returns an EmbedderFunction for ollama provider', () => {
|
|
32
|
-
const embedder = buildEmbedder({
|
|
33
|
-
provider: 'ollama',
|
|
34
|
-
model: 'nomic-embed-text',
|
|
35
|
-
baseUrl: 'http://localhost:11434'
|
|
36
|
-
});
|
|
37
|
-
expect(embedder).not.toBeNull();
|
|
38
|
-
expect(typeof embedder).toBe('function');
|
|
39
|
-
});
|
|
40
|
-
it('returns null for unknown provider', () => {
|
|
41
|
-
const embedder = buildEmbedder({
|
|
42
|
-
provider: 'unknown-provider'
|
|
43
|
-
});
|
|
44
|
-
expect(embedder).toBeNull();
|
|
45
|
-
});
|
|
46
|
-
it('uses default model and baseUrl for ollama when not specified', () => {
|
|
47
|
-
const embedder = buildEmbedder({ provider: 'ollama' });
|
|
48
|
-
expect(embedder).not.toBeNull();
|
|
49
|
-
expect(typeof embedder).toBe('function');
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
describe('buildEmbedderFromModule()', () => {
|
|
53
|
-
it('builds embedder from LlmModuleData', () => {
|
|
54
|
-
const moduleData = {
|
|
55
|
-
embedding_provider: 'ollama',
|
|
56
|
-
embedding_model: 'nomic-embed-text',
|
|
57
|
-
embedding_base_url: 'http://localhost:11434'
|
|
58
|
-
};
|
|
59
|
-
const embedder = buildEmbedderFromModule(moduleData);
|
|
60
|
-
expect(embedder).not.toBeNull();
|
|
61
|
-
expect(typeof embedder).toBe('function');
|
|
62
|
-
});
|
|
63
|
-
it('returns null for unsupported provider in module data', () => {
|
|
64
|
-
const moduleData = {
|
|
65
|
-
embedding_provider: 'unsupported'
|
|
66
|
-
};
|
|
67
|
-
const embedder = buildEmbedderFromModule(moduleData);
|
|
68
|
-
expect(embedder).toBeNull();
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
describe('buildEmbedderFromEnv()', () => {
|
|
72
|
-
const originalEnv = process.env;
|
|
73
|
-
afterEach(() => {
|
|
74
|
-
process.env = originalEnv;
|
|
75
|
-
});
|
|
76
|
-
it('returns default ollama embedder when EMBEDDER_PROVIDER is not set', () => {
|
|
77
|
-
process.env = { ...originalEnv };
|
|
78
|
-
delete process.env.EMBEDDER_PROVIDER;
|
|
79
|
-
const embedder = buildEmbedderFromEnv();
|
|
80
|
-
expect(embedder).not.toBeNull();
|
|
81
|
-
expect(typeof embedder).toBe('function');
|
|
82
|
-
});
|
|
83
|
-
it('builds embedder from environment variables', () => {
|
|
84
|
-
process.env = {
|
|
85
|
-
...originalEnv,
|
|
86
|
-
EMBEDDER_PROVIDER: 'ollama',
|
|
87
|
-
EMBEDDER_MODEL: 'nomic-embed-text',
|
|
88
|
-
EMBEDDER_BASE_URL: 'http://localhost:11434'
|
|
89
|
-
};
|
|
90
|
-
const embedder = buildEmbedderFromEnv();
|
|
91
|
-
expect(embedder).not.toBeNull();
|
|
92
|
-
expect(typeof embedder).toBe('function');
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
// =============================================================================
|
|
97
|
-
// Suite 2: Schema enrichment — plugin adds text fields to GraphQL schema
|
|
98
|
-
// Requires PostgreSQL + pgvector. Tests WILL fail if database is unavailable.
|
|
99
|
-
// =============================================================================
|
|
100
|
-
describe('graphile-llm schema enrichment', () => {
|
|
101
|
-
let db;
|
|
102
|
-
let teardown;
|
|
103
|
-
let query;
|
|
104
|
-
beforeAll(async () => {
|
|
105
|
-
const unifiedPlugin = createUnifiedSearchPlugin({
|
|
106
|
-
adapters: [createPgvectorAdapter()]
|
|
107
|
-
});
|
|
108
|
-
const testPreset = {
|
|
109
|
-
extends: [ConnectionFilterPreset()],
|
|
110
|
-
plugins: [
|
|
111
|
-
// Search infrastructure (provides VectorNearbyInput)
|
|
112
|
-
VectorCodecPlugin,
|
|
113
|
-
unifiedPlugin,
|
|
114
|
-
// LLM plugins under test
|
|
115
|
-
createLlmModulePlugin({
|
|
116
|
-
defaultEmbedder: {
|
|
117
|
-
provider: 'ollama',
|
|
118
|
-
model: 'nomic-embed-text',
|
|
119
|
-
baseUrl: 'http://localhost:11434'
|
|
120
|
-
}
|
|
121
|
-
}),
|
|
122
|
-
createLlmTextSearchPlugin(),
|
|
123
|
-
createLlmTextMutationPlugin()
|
|
124
|
-
]
|
|
125
|
-
};
|
|
126
|
-
const connections = await getConnections({
|
|
127
|
-
schemas: ['llm_test'],
|
|
128
|
-
preset: testPreset,
|
|
129
|
-
useRoot: true,
|
|
130
|
-
authRole: 'postgres'
|
|
131
|
-
}, [seed.sqlfile([join(__dirname, './setup.sql')])]);
|
|
132
|
-
db = connections.db;
|
|
133
|
-
teardown = connections.teardown;
|
|
134
|
-
query = connections.query;
|
|
135
|
-
});
|
|
136
|
-
afterAll(async () => {
|
|
137
|
-
if (teardown) {
|
|
138
|
-
await teardown();
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
beforeEach(async () => {
|
|
142
|
-
await db.beforeEach();
|
|
143
|
-
});
|
|
144
|
-
afterEach(async () => {
|
|
145
|
-
await db.afterEach();
|
|
146
|
-
});
|
|
147
|
-
// ─── VectorNearbyInput text field ────────────────────────────────────────
|
|
148
|
-
describe('VectorNearbyInput text field', () => {
|
|
149
|
-
it('adds text field to VectorNearbyInput type', async () => {
|
|
150
|
-
const result = await query(`
|
|
151
|
-
query {
|
|
152
|
-
__type(name: "VectorNearbyInput") {
|
|
153
|
-
inputFields {
|
|
154
|
-
name
|
|
155
|
-
type { name }
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
`);
|
|
160
|
-
expect(result.errors).toBeUndefined();
|
|
161
|
-
const inputType = result.data?.__type;
|
|
162
|
-
expect(inputType).toBeDefined();
|
|
163
|
-
const fieldNames = inputType.inputFields.map((f) => f.name);
|
|
164
|
-
// Original fields from graphile-search
|
|
165
|
-
expect(fieldNames).toContain('vector');
|
|
166
|
-
expect(fieldNames).toContain('metric');
|
|
167
|
-
expect(fieldNames).toContain('distance');
|
|
168
|
-
// New field from LlmTextSearchPlugin
|
|
169
|
-
expect(fieldNames).toContain('text');
|
|
170
|
-
// text field should be a String
|
|
171
|
-
const textField = inputType.inputFields.find((f) => f.name === 'text');
|
|
172
|
-
expect(textField.type.name).toBe('String');
|
|
173
|
-
});
|
|
174
|
-
it('still allows vector-based queries (existing behavior unchanged)', async () => {
|
|
175
|
-
const result = await query(`
|
|
176
|
-
query {
|
|
177
|
-
allArticles(where: {
|
|
178
|
-
vectorEmbedding: {
|
|
179
|
-
vector: [1, 0, 0]
|
|
180
|
-
metric: COSINE
|
|
181
|
-
}
|
|
182
|
-
}) {
|
|
183
|
-
nodes {
|
|
184
|
-
title
|
|
185
|
-
embeddingVectorDistance
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
`);
|
|
190
|
-
expect(result.errors).toBeUndefined();
|
|
191
|
-
const nodes = result.data?.allArticles?.nodes;
|
|
192
|
-
expect(nodes).toBeDefined();
|
|
193
|
-
expect(nodes.length).toBeGreaterThan(0);
|
|
194
|
-
for (const node of nodes) {
|
|
195
|
-
expect(typeof node.embeddingVectorDistance).toBe('number');
|
|
196
|
-
expect(node.embeddingVectorDistance).toBeGreaterThanOrEqual(0);
|
|
197
|
-
}
|
|
198
|
-
});
|
|
199
|
-
});
|
|
200
|
-
// ─── Mutation text companion fields ──────────────────────────────────────
|
|
201
|
-
describe('Mutation text companion fields', () => {
|
|
202
|
-
it('adds embeddingText field to ArticleInput (create input)', async () => {
|
|
203
|
-
const result = await query(`
|
|
204
|
-
query {
|
|
205
|
-
__type(name: "ArticleInput") {
|
|
206
|
-
inputFields {
|
|
207
|
-
name
|
|
208
|
-
type { name }
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
`);
|
|
213
|
-
expect(result.errors).toBeUndefined();
|
|
214
|
-
const inputType = result.data?.__type;
|
|
215
|
-
expect(inputType).toBeDefined();
|
|
216
|
-
const fieldNames = inputType.inputFields.map((f) => f.name);
|
|
217
|
-
expect(fieldNames).toContain('embedding');
|
|
218
|
-
expect(fieldNames).toContain('embeddingText');
|
|
219
|
-
const textField = inputType.inputFields.find((f) => f.name === 'embeddingText');
|
|
220
|
-
expect(textField).toBeDefined();
|
|
221
|
-
expect(textField.type.name).toBe('String');
|
|
222
|
-
});
|
|
223
|
-
it('adds embeddingText field to UpdateArticleInput (patch)', async () => {
|
|
224
|
-
const result = await query(`
|
|
225
|
-
query {
|
|
226
|
-
__type(name: "ArticlePatch") {
|
|
227
|
-
inputFields {
|
|
228
|
-
name
|
|
229
|
-
type { name }
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
`);
|
|
234
|
-
expect(result.errors).toBeUndefined();
|
|
235
|
-
const inputType = result.data?.__type;
|
|
236
|
-
expect(inputType).toBeDefined();
|
|
237
|
-
const fieldNames = inputType.inputFields.map((f) => f.name);
|
|
238
|
-
expect(fieldNames).toContain('embeddingText');
|
|
239
|
-
const textField = inputType.inputFields.find((f) => f.name === 'embeddingText');
|
|
240
|
-
expect(textField).toBeDefined();
|
|
241
|
-
expect(textField.type.name).toBe('String');
|
|
242
|
-
});
|
|
243
|
-
it('does not conflict with a physical embedding_text column (NoteInput)', async () => {
|
|
244
|
-
const result = await query(`
|
|
245
|
-
query {
|
|
246
|
-
__type(name: "NoteInput") {
|
|
247
|
-
inputFields {
|
|
248
|
-
name
|
|
249
|
-
type { name }
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
`);
|
|
254
|
-
expect(result.errors).toBeUndefined();
|
|
255
|
-
const inputType = result.data?.__type;
|
|
256
|
-
expect(inputType).toBeDefined();
|
|
257
|
-
// Exactly one embeddingText field — the physical column, not a
|
|
258
|
-
// plugin-synthesized companion.
|
|
259
|
-
const textFields = inputType.inputFields.filter((f) => f.name === 'embeddingText');
|
|
260
|
-
expect(textFields).toHaveLength(1);
|
|
261
|
-
expect(textFields[0].type.name).toBe('String');
|
|
262
|
-
});
|
|
263
|
-
it('stores raw text in the physical embedding_text column (not consumed as companion)', async () => {
|
|
264
|
-
const result = await query(`
|
|
265
|
-
mutation {
|
|
266
|
-
createNote(input: { note: { title: "Physical column", embeddingText: "keep me as text" } }) {
|
|
267
|
-
note {
|
|
268
|
-
id
|
|
269
|
-
title
|
|
270
|
-
embeddingText
|
|
271
|
-
embedding
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
`);
|
|
276
|
-
expect(result.errors).toBeUndefined();
|
|
277
|
-
const note = result.data?.createNote?.note;
|
|
278
|
-
expect(note).toBeDefined();
|
|
279
|
-
expect(note.embeddingText).toBe('keep me as text');
|
|
280
|
-
// The vector column must NOT have been populated by the plugin.
|
|
281
|
-
expect(note.embedding).toBeNull();
|
|
282
|
-
});
|
|
283
|
-
});
|
|
284
|
-
});
|
|
285
|
-
// =============================================================================
|
|
286
|
-
// Suite 3: Real Ollama embedding via @agentic-kit/ollama
|
|
287
|
-
// Requires Ollama running with nomic-embed-text. Tests WILL fail if unavailable.
|
|
288
|
-
// =============================================================================
|
|
289
|
-
describe('graphile-llm with real Ollama embedding', () => {
|
|
290
|
-
beforeAll(async () => {
|
|
291
|
-
await ensureNomicModel();
|
|
292
|
-
});
|
|
293
|
-
it('should embed text to a real vector via Ollama nomic-embed-text', async () => {
|
|
294
|
-
const embedder = buildEmbedder({
|
|
295
|
-
provider: 'ollama',
|
|
296
|
-
model: 'nomic-embed-text',
|
|
297
|
-
baseUrl: 'http://localhost:11434'
|
|
298
|
-
});
|
|
299
|
-
expect(embedder).not.toBeNull();
|
|
300
|
-
const result = await embedder('Machine learning is transforming AI');
|
|
301
|
-
// nomic-embed-text produces 768-dimensional vectors
|
|
302
|
-
expect(Array.isArray(result.embedding)).toBe(true);
|
|
303
|
-
expect(result.embedding.length).toBe(768);
|
|
304
|
-
expect(result.promptTokens).toBeGreaterThan(0);
|
|
305
|
-
// All elements should be numbers
|
|
306
|
-
for (const v of result.embedding) {
|
|
307
|
-
expect(typeof v).toBe('number');
|
|
308
|
-
expect(Number.isFinite(v)).toBe(true);
|
|
309
|
-
}
|
|
310
|
-
// Vector should not be all zeros
|
|
311
|
-
const magnitude = Math.sqrt(result.embedding.reduce((sum, v) => sum + v * v, 0));
|
|
312
|
-
expect(magnitude).toBeGreaterThan(0);
|
|
313
|
-
});
|
|
314
|
-
it('should produce different vectors for semantically different text', async () => {
|
|
315
|
-
const embedder = buildEmbedder({
|
|
316
|
-
provider: 'ollama',
|
|
317
|
-
model: 'nomic-embed-text',
|
|
318
|
-
baseUrl: 'http://localhost:11434'
|
|
319
|
-
});
|
|
320
|
-
expect(embedder).not.toBeNull();
|
|
321
|
-
const [resultA, resultB] = await Promise.all([
|
|
322
|
-
embedder('Artificial intelligence and machine learning'),
|
|
323
|
-
embedder('Cooking recipes for Italian pasta dishes')
|
|
324
|
-
]);
|
|
325
|
-
const vecA = resultA.embedding;
|
|
326
|
-
const vecB = resultB.embedding;
|
|
327
|
-
expect(vecA.length).toBe(768);
|
|
328
|
-
expect(vecB.length).toBe(768);
|
|
329
|
-
// Compute cosine similarity
|
|
330
|
-
let dotProduct = 0;
|
|
331
|
-
let normA = 0;
|
|
332
|
-
let normB = 0;
|
|
333
|
-
for (let i = 0; i < vecA.length; i++) {
|
|
334
|
-
dotProduct += vecA[i] * vecB[i];
|
|
335
|
-
normA += vecA[i] * vecA[i];
|
|
336
|
-
normB += vecB[i] * vecB[i];
|
|
337
|
-
}
|
|
338
|
-
const cosineSimilarity = dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
339
|
-
// Semantically different texts should have lower similarity
|
|
340
|
-
expect(cosineSimilarity).toBeLessThan(0.95);
|
|
341
|
-
});
|
|
342
|
-
it('should produce similar vectors for semantically similar text', async () => {
|
|
343
|
-
const embedder = buildEmbedder({
|
|
344
|
-
provider: 'ollama',
|
|
345
|
-
model: 'nomic-embed-text',
|
|
346
|
-
baseUrl: 'http://localhost:11434'
|
|
347
|
-
});
|
|
348
|
-
expect(embedder).not.toBeNull();
|
|
349
|
-
const [resultA, resultB] = await Promise.all([
|
|
350
|
-
embedder('Machine learning and artificial intelligence'),
|
|
351
|
-
embedder('AI and ML are subfields of computer science')
|
|
352
|
-
]);
|
|
353
|
-
const vecA = resultA.embedding;
|
|
354
|
-
const vecB = resultB.embedding;
|
|
355
|
-
expect(vecA.length).toBe(768);
|
|
356
|
-
expect(vecB.length).toBe(768);
|
|
357
|
-
// Compute cosine similarity
|
|
358
|
-
let dotProduct = 0;
|
|
359
|
-
let normA = 0;
|
|
360
|
-
let normB = 0;
|
|
361
|
-
for (let i = 0; i < vecA.length; i++) {
|
|
362
|
-
dotProduct += vecA[i] * vecB[i];
|
|
363
|
-
normA += vecA[i] * vecA[i];
|
|
364
|
-
normB += vecB[i] * vecB[i];
|
|
365
|
-
}
|
|
366
|
-
const cosineSimilarity = dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
367
|
-
// Semantically similar texts should have high similarity
|
|
368
|
-
expect(cosineSimilarity).toBeGreaterThan(0.5);
|
|
369
|
-
});
|
|
370
|
-
it('should produce embeddings with token count via @agentic-kit/ollama OllamaClient directly', async () => {
|
|
371
|
-
const result = await ollamaClient.generateEmbedding('Testing the agentic-kit Ollama client directly', 'nomic-embed-text');
|
|
372
|
-
expect(result).toHaveProperty('embedding');
|
|
373
|
-
expect(result).toHaveProperty('promptTokens');
|
|
374
|
-
expect(Array.isArray(result.embedding)).toBe(true);
|
|
375
|
-
expect(result.embedding.length).toBe(768);
|
|
376
|
-
expect(result.promptTokens).toBeGreaterThan(0);
|
|
377
|
-
for (const v of result.embedding) {
|
|
378
|
-
expect(typeof v).toBe('number');
|
|
379
|
-
expect(Number.isFinite(v)).toBe(true);
|
|
380
|
-
}
|
|
381
|
-
});
|
|
382
|
-
});
|
|
383
|
-
// =============================================================================
|
|
384
|
-
// Suite 4: Chat completion abstraction unit tests
|
|
385
|
-
// =============================================================================
|
|
386
|
-
describe('Chat completion abstraction', () => {
|
|
387
|
-
describe('buildChatCompleter()', () => {
|
|
388
|
-
it('returns a ChatFunction for ollama provider', () => {
|
|
389
|
-
const chat = buildChatCompleter({
|
|
390
|
-
provider: 'ollama',
|
|
391
|
-
model: 'llama3',
|
|
392
|
-
baseUrl: 'http://localhost:11434'
|
|
393
|
-
});
|
|
394
|
-
expect(chat).not.toBeNull();
|
|
395
|
-
expect(typeof chat).toBe('function');
|
|
396
|
-
});
|
|
397
|
-
it('returns null for unknown provider', () => {
|
|
398
|
-
const chat = buildChatCompleter({
|
|
399
|
-
provider: 'unknown-provider'
|
|
400
|
-
});
|
|
401
|
-
expect(chat).toBeNull();
|
|
402
|
-
});
|
|
403
|
-
it('uses default model and baseUrl for ollama when not specified', () => {
|
|
404
|
-
const chat = buildChatCompleter({ provider: 'ollama' });
|
|
405
|
-
expect(chat).not.toBeNull();
|
|
406
|
-
expect(typeof chat).toBe('function');
|
|
407
|
-
});
|
|
408
|
-
});
|
|
409
|
-
describe('buildChatCompleterFromModule()', () => {
|
|
410
|
-
it('builds chat completer from LlmModuleData with chat_provider', () => {
|
|
411
|
-
const moduleData = {
|
|
412
|
-
embedding_provider: 'ollama',
|
|
413
|
-
chat_provider: 'ollama',
|
|
414
|
-
chat_model: 'llama3',
|
|
415
|
-
chat_base_url: 'http://localhost:11434'
|
|
416
|
-
};
|
|
417
|
-
const chat = buildChatCompleterFromModule(moduleData);
|
|
418
|
-
expect(chat).not.toBeNull();
|
|
419
|
-
expect(typeof chat).toBe('function');
|
|
420
|
-
});
|
|
421
|
-
it('returns null when chat_provider is not set', () => {
|
|
422
|
-
const moduleData = {
|
|
423
|
-
embedding_provider: 'ollama'
|
|
424
|
-
};
|
|
425
|
-
const chat = buildChatCompleterFromModule(moduleData);
|
|
426
|
-
expect(chat).toBeNull();
|
|
427
|
-
});
|
|
428
|
-
});
|
|
429
|
-
describe('buildChatCompleterFromEnv()', () => {
|
|
430
|
-
const originalEnv = process.env;
|
|
431
|
-
afterEach(() => {
|
|
432
|
-
process.env = originalEnv;
|
|
433
|
-
});
|
|
434
|
-
it('returns default ollama chat completer when CHAT_PROVIDER is not set', () => {
|
|
435
|
-
process.env = { ...originalEnv };
|
|
436
|
-
delete process.env.CHAT_PROVIDER;
|
|
437
|
-
const chat = buildChatCompleterFromEnv();
|
|
438
|
-
expect(chat).not.toBeNull();
|
|
439
|
-
expect(typeof chat).toBe('function');
|
|
440
|
-
});
|
|
441
|
-
it('builds chat completer from environment variables', () => {
|
|
442
|
-
process.env = {
|
|
443
|
-
...originalEnv,
|
|
444
|
-
CHAT_PROVIDER: 'ollama',
|
|
445
|
-
CHAT_MODEL: 'llama3',
|
|
446
|
-
CHAT_BASE_URL: 'http://localhost:11434'
|
|
447
|
-
};
|
|
448
|
-
const chat = buildChatCompleterFromEnv();
|
|
449
|
-
expect(chat).not.toBeNull();
|
|
450
|
-
expect(typeof chat).toBe('function');
|
|
451
|
-
});
|
|
452
|
-
});
|
|
453
|
-
});
|
|
454
|
-
// =============================================================================
|
|
455
|
-
// Suite 5: RAG plugin schema enrichment
|
|
456
|
-
// Requires PostgreSQL + pgvector. Tests WILL fail if database is unavailable.
|
|
457
|
-
// =============================================================================
|
|
458
|
-
/**
|
|
459
|
-
* Smart tag injection plugin for test tables.
|
|
460
|
-
* Injects @hasChunks on the articles codec so the RAG plugin can discover it.
|
|
461
|
-
*/
|
|
462
|
-
function makeTestSmartTagsPlugin(tagsByTable) {
|
|
463
|
-
return {
|
|
464
|
-
name: 'TestSmartTagsPlugin',
|
|
465
|
-
version: '1.0.0',
|
|
466
|
-
before: ['LlmRagPlugin'],
|
|
467
|
-
schema: {
|
|
468
|
-
hooks: {
|
|
469
|
-
build: {
|
|
470
|
-
before: ['LlmRagPlugin'],
|
|
471
|
-
callback(build) {
|
|
472
|
-
const pgRegistry = build.input?.pgRegistry ?? build.pgRegistry;
|
|
473
|
-
if (!pgRegistry)
|
|
474
|
-
return build;
|
|
475
|
-
for (const codec of Object.values(pgRegistry.pgCodecs || {})) {
|
|
476
|
-
const c = codec;
|
|
477
|
-
if (!c.attributes || !c.name)
|
|
478
|
-
continue;
|
|
479
|
-
const tags = tagsByTable[c.name];
|
|
480
|
-
if (!tags)
|
|
481
|
-
continue;
|
|
482
|
-
if (!c.extensions)
|
|
483
|
-
c.extensions = {};
|
|
484
|
-
if (!c.extensions.tags)
|
|
485
|
-
c.extensions.tags = {};
|
|
486
|
-
Object.assign(c.extensions.tags, tags);
|
|
487
|
-
}
|
|
488
|
-
return build;
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
};
|
|
494
|
-
}
|
|
495
|
-
describe('RAG plugin schema enrichment', () => {
|
|
496
|
-
let db;
|
|
497
|
-
let teardown;
|
|
498
|
-
let query;
|
|
499
|
-
beforeAll(async () => {
|
|
500
|
-
const unifiedPlugin = createUnifiedSearchPlugin({
|
|
501
|
-
adapters: [createPgvectorAdapter()]
|
|
502
|
-
});
|
|
503
|
-
const smartTagsPlugin = makeTestSmartTagsPlugin({
|
|
504
|
-
articles: {
|
|
505
|
-
hasChunks: {
|
|
506
|
-
chunksTable: 'articles_chunks',
|
|
507
|
-
parentFk: 'parent_id',
|
|
508
|
-
parentPk: 'id',
|
|
509
|
-
embeddingField: 'embedding',
|
|
510
|
-
contentField: 'content'
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
});
|
|
514
|
-
// Mock embedder that returns a fixed 3-dim vector with token count
|
|
515
|
-
const mockEmbedder = async (_text) => ({
|
|
516
|
-
embedding: [1, 0, 0],
|
|
517
|
-
promptTokens: 5
|
|
518
|
-
});
|
|
519
|
-
// Mock chat completer that returns a canned response with usage
|
|
520
|
-
const mockChatCompleter = async (messages) => {
|
|
521
|
-
const userMessage = messages.find((m) => m.role === 'user');
|
|
522
|
-
return {
|
|
523
|
-
content: `Mock answer for: ${userMessage?.content || 'unknown'}`,
|
|
524
|
-
usage: { input: 10, output: 15, reasoning: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 25 }
|
|
525
|
-
};
|
|
526
|
-
};
|
|
527
|
-
// Provide mock embedder/chat directly instead of using createLlmModulePlugin
|
|
528
|
-
// to avoid build.extend naming conflicts when overriding
|
|
529
|
-
const mockLlmPlugin = {
|
|
530
|
-
name: 'TestMockLlmPlugin',
|
|
531
|
-
version: '1.0.0',
|
|
532
|
-
schema: {
|
|
533
|
-
hooks: {
|
|
534
|
-
build(build) {
|
|
535
|
-
return build.extend(build, {
|
|
536
|
-
llmEmbedder: mockEmbedder,
|
|
537
|
-
llmChatCompleter: mockChatCompleter,
|
|
538
|
-
llmEmbeddingModel: 'test-mock-model',
|
|
539
|
-
llmChatModel: 'test-mock-chat'
|
|
540
|
-
}, 'TestMockLlmPlugin providing mock embedder and chat completer');
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
};
|
|
545
|
-
const testPreset = {
|
|
546
|
-
extends: [ConnectionFilterPreset()],
|
|
547
|
-
plugins: [
|
|
548
|
-
VectorCodecPlugin,
|
|
549
|
-
unifiedPlugin,
|
|
550
|
-
smartTagsPlugin,
|
|
551
|
-
mockLlmPlugin,
|
|
552
|
-
createLlmTextSearchPlugin(),
|
|
553
|
-
createLlmTextMutationPlugin(),
|
|
554
|
-
createLlmRagPlugin()
|
|
555
|
-
]
|
|
556
|
-
};
|
|
557
|
-
const connections = await getConnections({
|
|
558
|
-
schemas: ['llm_test'],
|
|
559
|
-
preset: testPreset,
|
|
560
|
-
useRoot: true,
|
|
561
|
-
authRole: 'postgres'
|
|
562
|
-
}, [seed.sqlfile([join(__dirname, './setup.sql')])]);
|
|
563
|
-
db = connections.db;
|
|
564
|
-
teardown = connections.teardown;
|
|
565
|
-
query = connections.query;
|
|
566
|
-
});
|
|
567
|
-
afterAll(async () => {
|
|
568
|
-
if (teardown) {
|
|
569
|
-
await teardown();
|
|
570
|
-
}
|
|
571
|
-
});
|
|
572
|
-
beforeEach(async () => {
|
|
573
|
-
await db.beforeEach();
|
|
574
|
-
});
|
|
575
|
-
afterEach(async () => {
|
|
576
|
-
await db.afterEach();
|
|
577
|
-
});
|
|
578
|
-
it('adds ragQuery field to the Query type', async () => {
|
|
579
|
-
const result = await query(`
|
|
580
|
-
query {
|
|
581
|
-
__type(name: "Query") {
|
|
582
|
-
fields {
|
|
583
|
-
name
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
`);
|
|
588
|
-
expect(result.errors).toBeUndefined();
|
|
589
|
-
const queryType = result.data?.__type;
|
|
590
|
-
expect(queryType).toBeDefined();
|
|
591
|
-
const fieldNames = queryType.fields.map((f) => f.name);
|
|
592
|
-
expect(fieldNames).toContain('ragQuery');
|
|
593
|
-
});
|
|
594
|
-
it('adds embedText field to the Query type', async () => {
|
|
595
|
-
const result = await query(`
|
|
596
|
-
query {
|
|
597
|
-
__type(name: "Query") {
|
|
598
|
-
fields {
|
|
599
|
-
name
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
`);
|
|
604
|
-
expect(result.errors).toBeUndefined();
|
|
605
|
-
const fieldNames = result.data.__type.fields.map((f) => f.name);
|
|
606
|
-
expect(fieldNames).toContain('embedText');
|
|
607
|
-
});
|
|
608
|
-
it('ragQuery returns RagResponse type with answer and sources', async () => {
|
|
609
|
-
const result = await query(`
|
|
610
|
-
query {
|
|
611
|
-
__type(name: "RagResponse") {
|
|
612
|
-
fields {
|
|
613
|
-
name
|
|
614
|
-
type { name kind }
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
`);
|
|
619
|
-
expect(result.errors).toBeUndefined();
|
|
620
|
-
const ragResponseType = result.data?.__type;
|
|
621
|
-
expect(ragResponseType).toBeDefined();
|
|
622
|
-
const fieldNames = ragResponseType.fields.map((f) => f.name);
|
|
623
|
-
expect(fieldNames).toContain('answer');
|
|
624
|
-
expect(fieldNames).toContain('sources');
|
|
625
|
-
expect(fieldNames).toContain('tokensUsed');
|
|
626
|
-
});
|
|
627
|
-
it('ragQuery executes and returns mock answer with sources', async () => {
|
|
628
|
-
const result = await query(`
|
|
629
|
-
query {
|
|
630
|
-
ragQuery(prompt: "What is machine learning?", contextLimit: 3) {
|
|
631
|
-
answer
|
|
632
|
-
sources {
|
|
633
|
-
content
|
|
634
|
-
similarity
|
|
635
|
-
tableName
|
|
636
|
-
parentId
|
|
637
|
-
}
|
|
638
|
-
tokensUsed
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
`);
|
|
642
|
-
expect(result.errors).toBeUndefined();
|
|
643
|
-
const rag = result.data?.ragQuery;
|
|
644
|
-
expect(rag).toBeDefined();
|
|
645
|
-
// Mock chat completer returns a canned response
|
|
646
|
-
expect(rag.answer).toContain('Mock answer for');
|
|
647
|
-
expect(rag.answer).toContain('What is machine learning?');
|
|
648
|
-
// Sources should come from the chunks table
|
|
649
|
-
expect(rag.sources.length).toBeGreaterThan(0);
|
|
650
|
-
expect(rag.sources.length).toBeLessThanOrEqual(3);
|
|
651
|
-
for (const source of rag.sources) {
|
|
652
|
-
expect(typeof source.content).toBe('string');
|
|
653
|
-
expect(source.content.length).toBeGreaterThan(0);
|
|
654
|
-
expect(typeof source.similarity).toBe('number');
|
|
655
|
-
expect(source.similarity).toBeGreaterThanOrEqual(0);
|
|
656
|
-
expect(source.similarity).toBeLessThanOrEqual(1);
|
|
657
|
-
expect(source.tableName).toBe('articles');
|
|
658
|
-
expect(source.parentId).toBeTruthy();
|
|
659
|
-
}
|
|
660
|
-
});
|
|
661
|
-
it('embedText executes and returns vector with dimensions', async () => {
|
|
662
|
-
const result = await query(`
|
|
663
|
-
query {
|
|
664
|
-
embedText(text: "test embedding") {
|
|
665
|
-
vector
|
|
666
|
-
dimensions
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
`);
|
|
670
|
-
expect(result.errors).toBeUndefined();
|
|
671
|
-
const embed = result.data?.embedText;
|
|
672
|
-
expect(embed).toBeDefined();
|
|
673
|
-
// Mock embedder returns [1, 0, 0]
|
|
674
|
-
expect(embed.vector).toEqual([1, 0, 0]);
|
|
675
|
-
expect(embed.dimensions).toBe(3);
|
|
676
|
-
});
|
|
677
|
-
});
|
|
678
|
-
// =============================================================================
|
|
679
|
-
// Suite 6: GraphileLlmPreset toggle tests
|
|
680
|
-
// =============================================================================
|
|
681
|
-
describe('GraphileLlmPreset toggles', () => {
|
|
682
|
-
it('enableRag=false excludes RAG plugin (no ragQuery field)', async () => {
|
|
683
|
-
const { GraphileLlmPreset } = await import('../../src/preset');
|
|
684
|
-
const preset = GraphileLlmPreset({
|
|
685
|
-
enableRag: false
|
|
686
|
-
});
|
|
687
|
-
const pluginNames = preset.plugins.map((p) => p.name);
|
|
688
|
-
expect(pluginNames).not.toContain('LlmRagPlugin');
|
|
689
|
-
});
|
|
690
|
-
it('enableRag=true includes RAG plugin', async () => {
|
|
691
|
-
const { GraphileLlmPreset } = await import('../../src/preset');
|
|
692
|
-
const preset = GraphileLlmPreset({
|
|
693
|
-
enableRag: true
|
|
694
|
-
});
|
|
695
|
-
const pluginNames = preset.plugins.map((p) => p.name);
|
|
696
|
-
expect(pluginNames).toContain('LlmRagPlugin');
|
|
697
|
-
});
|
|
698
|
-
it('enableTextSearch=false excludes text search plugin', async () => {
|
|
699
|
-
const { GraphileLlmPreset } = await import('../../src/preset');
|
|
700
|
-
const preset = GraphileLlmPreset({
|
|
701
|
-
enableTextSearch: false
|
|
702
|
-
});
|
|
703
|
-
const pluginNames = preset.plugins.map((p) => p.name);
|
|
704
|
-
expect(pluginNames).not.toContain('LlmTextSearchPlugin');
|
|
705
|
-
// Module plugin is always included
|
|
706
|
-
expect(pluginNames).toContain('LlmModulePlugin');
|
|
707
|
-
});
|
|
708
|
-
it('enableTextMutations=false excludes text mutation plugin', async () => {
|
|
709
|
-
const { GraphileLlmPreset } = await import('../../src/preset');
|
|
710
|
-
const preset = GraphileLlmPreset({
|
|
711
|
-
enableTextMutations: false
|
|
712
|
-
});
|
|
713
|
-
const pluginNames = preset.plugins.map((p) => p.name);
|
|
714
|
-
expect(pluginNames).not.toContain('LlmTextMutationPlugin');
|
|
715
|
-
});
|
|
716
|
-
it('all toggles false leaves only LlmModulePlugin', async () => {
|
|
717
|
-
const { GraphileLlmPreset } = await import('../../src/preset');
|
|
718
|
-
const preset = GraphileLlmPreset({
|
|
719
|
-
enableTextSearch: false,
|
|
720
|
-
enableTextMutations: false,
|
|
721
|
-
enableRag: false
|
|
722
|
-
});
|
|
723
|
-
const pluginNames = preset.plugins.map((p) => p.name);
|
|
724
|
-
expect(pluginNames).toEqual(['LlmModulePlugin']);
|
|
725
|
-
});
|
|
726
|
-
it('default options include text search and mutations but not RAG', async () => {
|
|
727
|
-
const { GraphileLlmPreset } = await import('../../src/preset');
|
|
728
|
-
const preset = GraphileLlmPreset();
|
|
729
|
-
const pluginNames = preset.plugins.map((p) => p.name);
|
|
730
|
-
expect(pluginNames).toContain('LlmModulePlugin');
|
|
731
|
-
expect(pluginNames).toContain('LlmTextSearchPlugin');
|
|
732
|
-
expect(pluginNames).toContain('LlmTextMutationPlugin');
|
|
733
|
-
expect(pluginNames).not.toContain('LlmRagPlugin');
|
|
734
|
-
});
|
|
735
|
-
});
|
|
736
|
-
// =============================================================================
|
|
737
|
-
// Suite 7: Real Ollama + unifiedSearch + pgvector RRF integration
|
|
738
|
-
//
|
|
739
|
-
// End-to-end: embeds article text with real Ollama nomic-embed-text (768 dims),
|
|
740
|
-
// seeds the DB, then runs hybrid text+vector queries verifying RRF fusion,
|
|
741
|
-
// semantic relevance, and the embedTextInWhere pipeline.
|
|
742
|
-
//
|
|
743
|
-
// Requires: PostgreSQL with pgvector + Ollama with nomic-embed-text.
|
|
744
|
-
// =============================================================================
|
|
745
|
-
const INTEGRATION_ARTICLES = [
|
|
746
|
-
{ id: 1, title: 'Introduction to Machine Learning', body: 'Machine learning is a subset of artificial intelligence that focuses on algorithms and statistical models.' },
|
|
747
|
-
{ id: 2, title: 'Cooking Italian Pasta', body: 'The best Italian pasta is made with fresh ingredients like San Marzano tomatoes and homemade egg noodles.' },
|
|
748
|
-
{ id: 3, title: 'Deep Learning with Neural Networks', body: 'Deep learning uses neural networks with multiple layers to learn hierarchical representations of data.' },
|
|
749
|
-
{ 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.' },
|
|
750
|
-
{ id: 5, title: 'Natural Language Processing', body: 'NLP combines computational linguistics with machine learning to enable computers to understand human language.' },
|
|
751
|
-
];
|
|
752
|
-
const INTEGRATION_SETUP_SQL = `
|
|
753
|
-
CREATE EXTENSION IF NOT EXISTS vector;
|
|
754
|
-
CREATE SCHEMA IF NOT EXISTS llm_integration;
|
|
755
|
-
|
|
756
|
-
CREATE TABLE llm_integration.articles (
|
|
757
|
-
id serial PRIMARY KEY,
|
|
758
|
-
title text NOT NULL,
|
|
759
|
-
body text NOT NULL,
|
|
760
|
-
tsv tsvector GENERATED ALWAYS AS (
|
|
761
|
-
setweight(to_tsvector('english', title), 'A') ||
|
|
762
|
-
setweight(to_tsvector('english', body), 'B')
|
|
763
|
-
) STORED,
|
|
764
|
-
embedding vector(768) NOT NULL
|
|
765
|
-
);
|
|
766
|
-
|
|
767
|
-
CREATE INDEX idx_int_articles_tsv ON llm_integration.articles USING gin(tsv);
|
|
768
|
-
CREATE INDEX idx_int_articles_embedding ON llm_integration.articles
|
|
769
|
-
USING hnsw(embedding vector_cosine_ops);
|
|
770
|
-
`;
|
|
771
|
-
describe('Real Ollama + unifiedSearch + pgvector RRF integration', () => {
|
|
772
|
-
let db;
|
|
773
|
-
let teardown;
|
|
774
|
-
let query;
|
|
775
|
-
let embedder;
|
|
776
|
-
// Pre-computed query vectors (embedded once in beforeAll)
|
|
777
|
-
let mlQueryVector;
|
|
778
|
-
let cookingQueryVector;
|
|
779
|
-
beforeAll(async () => {
|
|
780
|
-
// Build a real Ollama embedder
|
|
781
|
-
const built = buildEmbedder({
|
|
782
|
-
provider: 'ollama',
|
|
783
|
-
model: 'nomic-embed-text',
|
|
784
|
-
baseUrl: 'http://localhost:11434',
|
|
785
|
-
});
|
|
786
|
-
if (!built)
|
|
787
|
-
throw new Error('Failed to build Ollama embedder');
|
|
788
|
-
embedder = built;
|
|
789
|
-
await ensureNomicModel();
|
|
790
|
-
// Embed all article bodies in parallel
|
|
791
|
-
const embeddings = await Promise.all(INTEGRATION_ARTICLES.map((a) => embedder(`${a.title}. ${a.body}`)));
|
|
792
|
-
// Build seed SQL with real 768-dim embeddings
|
|
793
|
-
const values = INTEGRATION_ARTICLES.map((a, i) => {
|
|
794
|
-
const vecStr = `'[${embeddings[i].embedding.join(',')}]'`;
|
|
795
|
-
const titleEsc = a.title.replace(/'/g, "''");
|
|
796
|
-
const bodyEsc = a.body.replace(/'/g, "''");
|
|
797
|
-
return `(${a.id}, '${titleEsc}', '${bodyEsc}', ${vecStr})`;
|
|
798
|
-
}).join(',\n ');
|
|
799
|
-
const seedSql = `
|
|
800
|
-
INSERT INTO llm_integration.articles (id, title, body, embedding)
|
|
801
|
-
VALUES ${values};
|
|
802
|
-
SELECT setval('llm_integration.articles_id_seq', ${INTEGRATION_ARTICLES.length});
|
|
803
|
-
`;
|
|
804
|
-
// Pre-compute search query vectors
|
|
805
|
-
const [mlResult, cookResult] = await Promise.all([
|
|
806
|
-
embedder('machine learning artificial intelligence algorithms'),
|
|
807
|
-
embedder('Italian cooking pasta recipes tomatoes'),
|
|
808
|
-
]);
|
|
809
|
-
mlQueryVector = mlResult.embedding;
|
|
810
|
-
cookingQueryVector = cookResult.embedding;
|
|
811
|
-
// Set up PostGraphile with tsvector + pgvector adapters + LLM plugin
|
|
812
|
-
const unifiedPlugin = createUnifiedSearchPlugin({
|
|
813
|
-
adapters: [
|
|
814
|
-
createTsvectorAdapter(),
|
|
815
|
-
createPgvectorAdapter(),
|
|
816
|
-
],
|
|
817
|
-
enableSearchScore: true,
|
|
818
|
-
enableUnifiedSearch: true,
|
|
819
|
-
rrfK: 60,
|
|
820
|
-
});
|
|
821
|
-
const testPreset = {
|
|
822
|
-
extends: [ConnectionFilterPreset()],
|
|
823
|
-
plugins: [
|
|
824
|
-
TsvectorCodecPlugin,
|
|
825
|
-
VectorCodecPlugin,
|
|
826
|
-
unifiedPlugin,
|
|
827
|
-
createLlmModulePlugin({
|
|
828
|
-
defaultEmbedder: {
|
|
829
|
-
provider: 'ollama',
|
|
830
|
-
model: 'nomic-embed-text',
|
|
831
|
-
baseUrl: 'http://localhost:11434',
|
|
832
|
-
},
|
|
833
|
-
}),
|
|
834
|
-
createLlmTextSearchPlugin(),
|
|
835
|
-
],
|
|
836
|
-
};
|
|
837
|
-
const connections = await getConnections({
|
|
838
|
-
schemas: ['llm_integration'],
|
|
839
|
-
preset: testPreset,
|
|
840
|
-
useRoot: true,
|
|
841
|
-
authRole: 'postgres',
|
|
842
|
-
}, [
|
|
843
|
-
seed.fn(async (ctx) => {
|
|
844
|
-
await ctx.pg.query(INTEGRATION_SETUP_SQL);
|
|
845
|
-
await ctx.pg.query(seedSql);
|
|
846
|
-
}),
|
|
847
|
-
]);
|
|
848
|
-
db = connections.db;
|
|
849
|
-
teardown = connections.teardown;
|
|
850
|
-
query = connections.query;
|
|
851
|
-
await db.client.query('BEGIN');
|
|
852
|
-
}, 120_000);
|
|
853
|
-
afterAll(async () => {
|
|
854
|
-
if (db) {
|
|
855
|
-
try {
|
|
856
|
-
await db.client.query('ROLLBACK');
|
|
857
|
-
}
|
|
858
|
-
catch {
|
|
859
|
-
// ignore
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
if (teardown)
|
|
863
|
-
await teardown();
|
|
864
|
-
});
|
|
865
|
-
beforeEach(async () => { await db.beforeEach(); });
|
|
866
|
-
afterEach(async () => { await db.afterEach(); });
|
|
867
|
-
// ─── embedTextInWhere with real Ollama ──────────────────────────────────
|
|
868
|
-
it('embedTextInWhere transforms unifiedSearch with real 768-dim Ollama vector', async () => {
|
|
869
|
-
const realEmbedder = async (text) => {
|
|
870
|
-
const result = await embedder(text);
|
|
871
|
-
return result.embedding;
|
|
872
|
-
};
|
|
873
|
-
const where = { unifiedSearch: 'machine learning algorithms' };
|
|
874
|
-
await embedTextInWhere(where, realEmbedder, true);
|
|
875
|
-
expect(where.unifiedSearch).toHaveProperty('__text', 'machine learning algorithms');
|
|
876
|
-
expect(where.unifiedSearch).toHaveProperty('__vector');
|
|
877
|
-
expect(Array.isArray(where.unifiedSearch.__vector)).toBe(true);
|
|
878
|
-
expect(where.unifiedSearch.__vector.length).toBe(768);
|
|
879
|
-
for (const v of where.unifiedSearch.__vector) {
|
|
880
|
-
expect(typeof v).toBe('number');
|
|
881
|
-
expect(Number.isFinite(v)).toBe(true);
|
|
882
|
-
}
|
|
883
|
-
}, 30_000);
|
|
884
|
-
// ─── unifiedSearch text-only (no vector injection) ─────────────────────
|
|
885
|
-
it('unifiedSearch text-only uses tsvector without pgvector', async () => {
|
|
886
|
-
const result = await query(`
|
|
887
|
-
query {
|
|
888
|
-
allArticles(where: { unifiedSearch: "machine learning" }) {
|
|
889
|
-
nodes { rowId title tsvRank embeddingVectorDistance searchScore }
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
`);
|
|
893
|
-
expect(result.errors).toBeUndefined();
|
|
894
|
-
const nodes = result.data?.allArticles?.nodes ?? [];
|
|
895
|
-
expect(nodes.length).toBeGreaterThan(0);
|
|
896
|
-
// tsvector should be active
|
|
897
|
-
const hasTsv = nodes.some((n) => n.tsvRank !== null);
|
|
898
|
-
expect(hasTsv).toBe(true);
|
|
899
|
-
// pgvector should NOT participate (no vector injection in graphile-test)
|
|
900
|
-
for (const node of nodes) {
|
|
901
|
-
expect(node.embeddingVectorDistance).toBeNull();
|
|
902
|
-
}
|
|
903
|
-
});
|
|
904
|
-
// ─── unifiedSearch + real pgvector: hybrid RRF ─────────────────────────
|
|
905
|
-
it('unifiedSearch + real vector embedding fuses tsvector + pgvector via RRF', async () => {
|
|
906
|
-
const vecStr = `[${mlQueryVector.join(',')}]`;
|
|
907
|
-
const result = await query(`
|
|
908
|
-
query($vec: [Float!]!) {
|
|
909
|
-
allArticles(where: {
|
|
910
|
-
unifiedSearch: "machine learning"
|
|
911
|
-
vectorEmbedding: { vector: $vec, metric: COSINE }
|
|
912
|
-
}) {
|
|
913
|
-
nodes { rowId title tsvRank embeddingVectorDistance searchScore }
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
`, { vec: mlQueryVector });
|
|
917
|
-
expect(result.errors).toBeUndefined();
|
|
918
|
-
const nodes = result.data?.allArticles?.nodes ?? [];
|
|
919
|
-
expect(nodes.length).toBeGreaterThan(0);
|
|
920
|
-
// Both adapters should be active
|
|
921
|
-
const hasTsv = nodes.some((n) => n.tsvRank !== null);
|
|
922
|
-
const hasVector = nodes.some((n) => n.embeddingVectorDistance !== null);
|
|
923
|
-
expect(hasTsv).toBe(true);
|
|
924
|
-
expect(hasVector).toBe(true);
|
|
925
|
-
// searchScore must be normalized [0,1]
|
|
926
|
-
for (const node of nodes) {
|
|
927
|
-
expect(node.searchScore).toBeGreaterThanOrEqual(0);
|
|
928
|
-
expect(node.searchScore).toBeLessThanOrEqual(1);
|
|
929
|
-
}
|
|
930
|
-
});
|
|
931
|
-
// ─── Semantic relevance with real embeddings ───────────────────────────
|
|
932
|
-
it('ML query ranks ML articles higher than cooking/gardening with real embeddings', async () => {
|
|
933
|
-
const result = await query(`
|
|
934
|
-
query($vec: [Float!]!) {
|
|
935
|
-
allArticles(where: {
|
|
936
|
-
unifiedSearch: "machine learning"
|
|
937
|
-
vectorEmbedding: { vector: $vec, metric: COSINE }
|
|
938
|
-
}) {
|
|
939
|
-
nodes { rowId title searchScore }
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
`, { vec: mlQueryVector });
|
|
943
|
-
expect(result.errors).toBeUndefined();
|
|
944
|
-
const nodes = result.data?.allArticles?.nodes ?? [];
|
|
945
|
-
// ML-related articles (1, 3, 5) should score higher than non-ML (2, 4)
|
|
946
|
-
const mlArticles = nodes.filter((n) => [1, 3, 5].includes(n.rowId));
|
|
947
|
-
const nonMlArticles = nodes.filter((n) => [2, 4].includes(n.rowId));
|
|
948
|
-
if (mlArticles.length > 0 && nonMlArticles.length > 0) {
|
|
949
|
-
const bestMlScore = Math.max(...mlArticles.map((n) => n.searchScore));
|
|
950
|
-
const bestNonMlScore = Math.max(...nonMlArticles.map((n) => n.searchScore));
|
|
951
|
-
expect(bestMlScore).toBeGreaterThan(bestNonMlScore);
|
|
952
|
-
}
|
|
953
|
-
});
|
|
954
|
-
it('cooking query ranks cooking article higher than ML articles with real embeddings', async () => {
|
|
955
|
-
const result = await query(`
|
|
956
|
-
query($vec: [Float!]!) {
|
|
957
|
-
allArticles(where: {
|
|
958
|
-
unifiedSearch: "Italian pasta cooking"
|
|
959
|
-
vectorEmbedding: { vector: $vec, metric: COSINE }
|
|
960
|
-
}) {
|
|
961
|
-
nodes { rowId title searchScore }
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
`, { vec: cookingQueryVector });
|
|
965
|
-
expect(result.errors).toBeUndefined();
|
|
966
|
-
const nodes = result.data?.allArticles?.nodes ?? [];
|
|
967
|
-
// Cooking article (id=2) should rank highest
|
|
968
|
-
const cookingArticle = nodes.find((n) => n.rowId === 2);
|
|
969
|
-
const otherArticles = nodes.filter((n) => n.rowId !== 2);
|
|
970
|
-
if (cookingArticle && otherArticles.length > 0) {
|
|
971
|
-
const bestOtherScore = Math.max(...otherArticles.map((n) => n.searchScore));
|
|
972
|
-
expect(cookingArticle.searchScore).toBeGreaterThan(bestOtherScore);
|
|
973
|
-
}
|
|
974
|
-
});
|
|
975
|
-
// ─── Vector-only search with real embeddings ───────────────────────────
|
|
976
|
-
it('VectorNearbyInput with real embedding returns semantically relevant results', async () => {
|
|
977
|
-
const result = await query(`
|
|
978
|
-
query($vec: [Float!]!) {
|
|
979
|
-
allArticles(where: {
|
|
980
|
-
vectorEmbedding: { vector: $vec, metric: COSINE }
|
|
981
|
-
}) {
|
|
982
|
-
nodes { rowId title embeddingVectorDistance }
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
`, { vec: mlQueryVector });
|
|
986
|
-
expect(result.errors).toBeUndefined();
|
|
987
|
-
const nodes = result.data?.allArticles?.nodes ?? [];
|
|
988
|
-
expect(nodes.length).toBe(5);
|
|
989
|
-
// Nearest to ML query should be ML articles
|
|
990
|
-
const sorted = [...nodes].sort((a, b) => a.embeddingVectorDistance - b.embeddingVectorDistance);
|
|
991
|
-
// Top result should be an ML-related article (1, 3, or 5)
|
|
992
|
-
expect([1, 3, 5]).toContain(sorted[0].rowId);
|
|
993
|
-
});
|
|
994
|
-
// ─── RRF boost: text+vector scores higher than text-only ───────────────
|
|
995
|
-
it('hybrid text+vector search scores higher than text-only for relevant docs', async () => {
|
|
996
|
-
const textOnly = await query(`
|
|
997
|
-
query {
|
|
998
|
-
allArticles(where: { unifiedSearch: "machine learning" }) {
|
|
999
|
-
nodes { rowId searchScore }
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
`);
|
|
1003
|
-
const hybrid = await query(`
|
|
1004
|
-
query($vec: [Float!]!) {
|
|
1005
|
-
allArticles(where: {
|
|
1006
|
-
unifiedSearch: "machine learning"
|
|
1007
|
-
vectorEmbedding: { vector: $vec, metric: COSINE }
|
|
1008
|
-
}) {
|
|
1009
|
-
nodes { rowId searchScore }
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
`, { vec: mlQueryVector });
|
|
1013
|
-
expect(textOnly.errors).toBeUndefined();
|
|
1014
|
-
expect(hybrid.errors).toBeUndefined();
|
|
1015
|
-
const doc1TextOnly = textOnly.data?.allArticles?.nodes?.find((n) => n.rowId === 1);
|
|
1016
|
-
const doc1Hybrid = hybrid.data?.allArticles?.nodes?.find((n) => n.rowId === 1);
|
|
1017
|
-
// Doc 1 (ML article) should score at least as high with hybrid
|
|
1018
|
-
if (doc1TextOnly && doc1Hybrid) {
|
|
1019
|
-
expect(doc1Hybrid.searchScore).toBeGreaterThanOrEqual(doc1TextOnly.searchScore);
|
|
1020
|
-
}
|
|
1021
|
-
});
|
|
1022
|
-
});
|