graphile-llm 0.11.5 → 0.12.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/__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 +7 -5
- 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
|
@@ -39,6 +39,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
39
39
|
const ollama_1 = __importDefault(require("@agentic-kit/ollama"));
|
|
40
40
|
const graphile_connection_filter_1 = require("graphile-connection-filter");
|
|
41
41
|
const pgvector_1 = require("graphile-search/adapters/pgvector");
|
|
42
|
+
const tsvector_1 = require("graphile-search/adapters/tsvector");
|
|
43
|
+
const tsvector_codec_1 = require("graphile-search/codecs/tsvector-codec");
|
|
42
44
|
const vector_codec_1 = require("graphile-search/codecs/vector-codec");
|
|
43
45
|
const plugin_1 = require("graphile-search/plugin");
|
|
44
46
|
const graphile_test_1 = require("graphile-test");
|
|
@@ -235,10 +237,10 @@ describe('graphile-llm schema enrichment', () => {
|
|
|
235
237
|
});
|
|
236
238
|
// ─── Mutation text companion fields ──────────────────────────────────────
|
|
237
239
|
describe('Mutation text companion fields', () => {
|
|
238
|
-
it('adds embeddingText field to
|
|
240
|
+
it('adds embeddingText field to ArticleInput (create input)', async () => {
|
|
239
241
|
const result = await query(`
|
|
240
242
|
query {
|
|
241
|
-
__type(name: "
|
|
243
|
+
__type(name: "ArticleInput") {
|
|
242
244
|
inputFields {
|
|
243
245
|
name
|
|
244
246
|
type { name }
|
|
@@ -250,9 +252,7 @@ describe('graphile-llm schema enrichment', () => {
|
|
|
250
252
|
const inputType = result.data?.__type;
|
|
251
253
|
expect(inputType).toBeDefined();
|
|
252
254
|
const fieldNames = inputType.inputFields.map((f) => f.name);
|
|
253
|
-
// Original embedding field
|
|
254
255
|
expect(fieldNames).toContain('embedding');
|
|
255
|
-
// Companion text field from LlmTextMutationPlugin
|
|
256
256
|
expect(fieldNames).toContain('embeddingText');
|
|
257
257
|
const textField = inputType.inputFields.find((f) => f.name === 'embeddingText');
|
|
258
258
|
expect(textField).toBeDefined();
|
|
@@ -461,12 +461,16 @@ function makeTestSmartTagsPlugin(tagsByTable) {
|
|
|
461
461
|
return {
|
|
462
462
|
name: 'TestSmartTagsPlugin',
|
|
463
463
|
version: '1.0.0',
|
|
464
|
+
before: ['LlmRagPlugin'],
|
|
464
465
|
schema: {
|
|
465
466
|
hooks: {
|
|
466
|
-
|
|
467
|
-
before: ['
|
|
468
|
-
callback(
|
|
469
|
-
|
|
467
|
+
build: {
|
|
468
|
+
before: ['LlmRagPlugin'],
|
|
469
|
+
callback(build) {
|
|
470
|
+
const pgRegistry = build.input?.pgRegistry ?? build.pgRegistry;
|
|
471
|
+
if (!pgRegistry)
|
|
472
|
+
return build;
|
|
473
|
+
for (const codec of Object.values(pgRegistry.pgCodecs || {})) {
|
|
470
474
|
const c = codec;
|
|
471
475
|
if (!c.attributes || !c.name)
|
|
472
476
|
continue;
|
|
@@ -479,7 +483,7 @@ function makeTestSmartTagsPlugin(tagsByTable) {
|
|
|
479
483
|
c.extensions.tags = {};
|
|
480
484
|
Object.assign(c.extensions.tags, tags);
|
|
481
485
|
}
|
|
482
|
-
return
|
|
486
|
+
return build;
|
|
483
487
|
}
|
|
484
488
|
}
|
|
485
489
|
}
|
|
@@ -518,47 +522,39 @@ describe('RAG plugin schema enrichment', () => {
|
|
|
518
522
|
usage: { input: 10, output: 15, reasoning: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 25 }
|
|
519
523
|
};
|
|
520
524
|
};
|
|
525
|
+
// Provide mock embedder/chat directly instead of using createLlmModulePlugin
|
|
526
|
+
// to avoid build.extend naming conflicts when overriding
|
|
527
|
+
const mockLlmPlugin = {
|
|
528
|
+
name: 'TestMockLlmPlugin',
|
|
529
|
+
version: '1.0.0',
|
|
530
|
+
schema: {
|
|
531
|
+
hooks: {
|
|
532
|
+
build(build) {
|
|
533
|
+
return build.extend(build, {
|
|
534
|
+
llmEmbedder: mockEmbedder,
|
|
535
|
+
llmChatCompleter: mockChatCompleter,
|
|
536
|
+
llmEmbeddingModel: 'test-mock-model',
|
|
537
|
+
llmChatModel: 'test-mock-chat'
|
|
538
|
+
}, 'TestMockLlmPlugin providing mock embedder and chat completer');
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
};
|
|
521
543
|
const testPreset = {
|
|
522
544
|
extends: [(0, graphile_connection_filter_1.ConnectionFilterPreset)()],
|
|
523
545
|
plugins: [
|
|
524
546
|
vector_codec_1.VectorCodecPlugin,
|
|
525
547
|
unifiedPlugin,
|
|
526
548
|
smartTagsPlugin,
|
|
527
|
-
|
|
528
|
-
defaultEmbedder: {
|
|
529
|
-
provider: 'ollama',
|
|
530
|
-
model: 'nomic-embed-text',
|
|
531
|
-
baseUrl: 'http://localhost:11434'
|
|
532
|
-
}
|
|
533
|
-
}),
|
|
549
|
+
mockLlmPlugin,
|
|
534
550
|
(0, text_search_plugin_1.createLlmTextSearchPlugin)(),
|
|
535
551
|
(0, text_mutation_plugin_1.createLlmTextMutationPlugin)(),
|
|
536
552
|
(0, rag_plugin_1.createLlmRagPlugin)()
|
|
537
553
|
]
|
|
538
554
|
};
|
|
539
|
-
// Override the embedder and chat completer on the build context
|
|
540
|
-
// by wrapping the LlmModulePlugin's build hook
|
|
541
|
-
const overridePlugin = {
|
|
542
|
-
name: 'TestOverridePlugin',
|
|
543
|
-
version: '1.0.0',
|
|
544
|
-
after: ['LlmModulePlugin'],
|
|
545
|
-
schema: {
|
|
546
|
-
hooks: {
|
|
547
|
-
build(build) {
|
|
548
|
-
return build.extend(build, {
|
|
549
|
-
llmEmbedder: mockEmbedder,
|
|
550
|
-
llmChatCompleter: mockChatCompleter
|
|
551
|
-
}, 'TestOverridePlugin overriding embedder and chat completer');
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
};
|
|
556
555
|
const connections = await (0, graphile_test_1.getConnections)({
|
|
557
556
|
schemas: ['llm_test'],
|
|
558
|
-
preset:
|
|
559
|
-
...testPreset,
|
|
560
|
-
plugins: [...testPreset.plugins, overridePlugin]
|
|
561
|
-
},
|
|
557
|
+
preset: testPreset,
|
|
562
558
|
useRoot: true,
|
|
563
559
|
authRole: 'postgres'
|
|
564
560
|
}, [graphile_test_1.seed.sqlfile([(0, path_1.join)(__dirname, './setup.sql')])]);
|
|
@@ -735,3 +731,288 @@ describe('GraphileLlmPreset toggles', () => {
|
|
|
735
731
|
expect(pluginNames).not.toContain('LlmRagPlugin');
|
|
736
732
|
});
|
|
737
733
|
});
|
|
734
|
+
// =============================================================================
|
|
735
|
+
// Suite 7: Real Ollama + unifiedSearch + pgvector RRF integration
|
|
736
|
+
//
|
|
737
|
+
// End-to-end: embeds article text with real Ollama nomic-embed-text (768 dims),
|
|
738
|
+
// seeds the DB, then runs hybrid text+vector queries verifying RRF fusion,
|
|
739
|
+
// semantic relevance, and the embedTextInWhere pipeline.
|
|
740
|
+
//
|
|
741
|
+
// Requires: PostgreSQL with pgvector + Ollama with nomic-embed-text.
|
|
742
|
+
// =============================================================================
|
|
743
|
+
const INTEGRATION_ARTICLES = [
|
|
744
|
+
{ id: 1, title: 'Introduction to Machine Learning', body: 'Machine learning is a subset of artificial intelligence that focuses on algorithms and statistical models.' },
|
|
745
|
+
{ id: 2, title: 'Cooking Italian Pasta', body: 'The best Italian pasta is made with fresh ingredients like San Marzano tomatoes and homemade egg noodles.' },
|
|
746
|
+
{ id: 3, title: 'Deep Learning with Neural Networks', body: 'Deep learning uses neural networks with multiple layers to learn hierarchical representations of data.' },
|
|
747
|
+
{ 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.' },
|
|
748
|
+
{ id: 5, title: 'Natural Language Processing', body: 'NLP combines computational linguistics with machine learning to enable computers to understand human language.' },
|
|
749
|
+
];
|
|
750
|
+
const INTEGRATION_SETUP_SQL = `
|
|
751
|
+
CREATE EXTENSION IF NOT EXISTS vector;
|
|
752
|
+
CREATE SCHEMA IF NOT EXISTS llm_integration;
|
|
753
|
+
|
|
754
|
+
CREATE TABLE llm_integration.articles (
|
|
755
|
+
id serial PRIMARY KEY,
|
|
756
|
+
title text NOT NULL,
|
|
757
|
+
body text NOT NULL,
|
|
758
|
+
tsv tsvector GENERATED ALWAYS AS (
|
|
759
|
+
setweight(to_tsvector('english', title), 'A') ||
|
|
760
|
+
setweight(to_tsvector('english', body), 'B')
|
|
761
|
+
) STORED,
|
|
762
|
+
embedding vector(768) NOT NULL
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
CREATE INDEX idx_int_articles_tsv ON llm_integration.articles USING gin(tsv);
|
|
766
|
+
CREATE INDEX idx_int_articles_embedding ON llm_integration.articles
|
|
767
|
+
USING hnsw(embedding vector_cosine_ops);
|
|
768
|
+
`;
|
|
769
|
+
describe('Real Ollama + unifiedSearch + pgvector RRF integration', () => {
|
|
770
|
+
let db;
|
|
771
|
+
let teardown;
|
|
772
|
+
let query;
|
|
773
|
+
let embedder;
|
|
774
|
+
// Pre-computed query vectors (embedded once in beforeAll)
|
|
775
|
+
let mlQueryVector;
|
|
776
|
+
let cookingQueryVector;
|
|
777
|
+
beforeAll(async () => {
|
|
778
|
+
// Build a real Ollama embedder
|
|
779
|
+
const built = (0, embedder_1.buildEmbedder)({
|
|
780
|
+
provider: 'ollama',
|
|
781
|
+
model: 'nomic-embed-text',
|
|
782
|
+
baseUrl: 'http://localhost:11434',
|
|
783
|
+
});
|
|
784
|
+
if (!built)
|
|
785
|
+
throw new Error('Failed to build Ollama embedder');
|
|
786
|
+
embedder = built;
|
|
787
|
+
await ensureNomicModel();
|
|
788
|
+
// Embed all article bodies in parallel
|
|
789
|
+
const embeddings = await Promise.all(INTEGRATION_ARTICLES.map((a) => embedder(`${a.title}. ${a.body}`)));
|
|
790
|
+
// Build seed SQL with real 768-dim embeddings
|
|
791
|
+
const values = INTEGRATION_ARTICLES.map((a, i) => {
|
|
792
|
+
const vecStr = `'[${embeddings[i].embedding.join(',')}]'`;
|
|
793
|
+
const titleEsc = a.title.replace(/'/g, "''");
|
|
794
|
+
const bodyEsc = a.body.replace(/'/g, "''");
|
|
795
|
+
return `(${a.id}, '${titleEsc}', '${bodyEsc}', ${vecStr})`;
|
|
796
|
+
}).join(',\n ');
|
|
797
|
+
const seedSql = `
|
|
798
|
+
INSERT INTO llm_integration.articles (id, title, body, embedding)
|
|
799
|
+
VALUES ${values};
|
|
800
|
+
SELECT setval('llm_integration.articles_id_seq', ${INTEGRATION_ARTICLES.length});
|
|
801
|
+
`;
|
|
802
|
+
// Pre-compute search query vectors
|
|
803
|
+
const [mlResult, cookResult] = await Promise.all([
|
|
804
|
+
embedder('machine learning artificial intelligence algorithms'),
|
|
805
|
+
embedder('Italian cooking pasta recipes tomatoes'),
|
|
806
|
+
]);
|
|
807
|
+
mlQueryVector = mlResult.embedding;
|
|
808
|
+
cookingQueryVector = cookResult.embedding;
|
|
809
|
+
// Set up PostGraphile with tsvector + pgvector adapters + LLM plugin
|
|
810
|
+
const unifiedPlugin = (0, plugin_1.createUnifiedSearchPlugin)({
|
|
811
|
+
adapters: [
|
|
812
|
+
(0, tsvector_1.createTsvectorAdapter)(),
|
|
813
|
+
(0, pgvector_1.createPgvectorAdapter)(),
|
|
814
|
+
],
|
|
815
|
+
enableSearchScore: true,
|
|
816
|
+
enableUnifiedSearch: true,
|
|
817
|
+
rrfK: 60,
|
|
818
|
+
});
|
|
819
|
+
const testPreset = {
|
|
820
|
+
extends: [(0, graphile_connection_filter_1.ConnectionFilterPreset)()],
|
|
821
|
+
plugins: [
|
|
822
|
+
tsvector_codec_1.TsvectorCodecPlugin,
|
|
823
|
+
vector_codec_1.VectorCodecPlugin,
|
|
824
|
+
unifiedPlugin,
|
|
825
|
+
(0, llm_module_plugin_1.createLlmModulePlugin)({
|
|
826
|
+
defaultEmbedder: {
|
|
827
|
+
provider: 'ollama',
|
|
828
|
+
model: 'nomic-embed-text',
|
|
829
|
+
baseUrl: 'http://localhost:11434',
|
|
830
|
+
},
|
|
831
|
+
}),
|
|
832
|
+
(0, text_search_plugin_1.createLlmTextSearchPlugin)(),
|
|
833
|
+
],
|
|
834
|
+
};
|
|
835
|
+
const connections = await (0, graphile_test_1.getConnections)({
|
|
836
|
+
schemas: ['llm_integration'],
|
|
837
|
+
preset: testPreset,
|
|
838
|
+
useRoot: true,
|
|
839
|
+
authRole: 'postgres',
|
|
840
|
+
}, [
|
|
841
|
+
graphile_test_1.seed.fn(async (ctx) => {
|
|
842
|
+
await ctx.pg.query(INTEGRATION_SETUP_SQL);
|
|
843
|
+
await ctx.pg.query(seedSql);
|
|
844
|
+
}),
|
|
845
|
+
]);
|
|
846
|
+
db = connections.db;
|
|
847
|
+
teardown = connections.teardown;
|
|
848
|
+
query = connections.query;
|
|
849
|
+
await db.client.query('BEGIN');
|
|
850
|
+
}, 120_000);
|
|
851
|
+
afterAll(async () => {
|
|
852
|
+
if (db) {
|
|
853
|
+
try {
|
|
854
|
+
await db.client.query('ROLLBACK');
|
|
855
|
+
}
|
|
856
|
+
catch { }
|
|
857
|
+
}
|
|
858
|
+
if (teardown)
|
|
859
|
+
await teardown();
|
|
860
|
+
});
|
|
861
|
+
beforeEach(async () => { await db.beforeEach(); });
|
|
862
|
+
afterEach(async () => { await db.afterEach(); });
|
|
863
|
+
// ─── embedTextInWhere with real Ollama ──────────────────────────────────
|
|
864
|
+
it('embedTextInWhere transforms unifiedSearch with real 768-dim Ollama vector', async () => {
|
|
865
|
+
const realEmbedder = async (text) => {
|
|
866
|
+
const result = await embedder(text);
|
|
867
|
+
return result.embedding;
|
|
868
|
+
};
|
|
869
|
+
const where = { unifiedSearch: 'machine learning algorithms' };
|
|
870
|
+
await (0, text_search_plugin_1.embedTextInWhere)(where, realEmbedder, true);
|
|
871
|
+
expect(where.unifiedSearch).toHaveProperty('__text', 'machine learning algorithms');
|
|
872
|
+
expect(where.unifiedSearch).toHaveProperty('__vector');
|
|
873
|
+
expect(Array.isArray(where.unifiedSearch.__vector)).toBe(true);
|
|
874
|
+
expect(where.unifiedSearch.__vector.length).toBe(768);
|
|
875
|
+
for (const v of where.unifiedSearch.__vector) {
|
|
876
|
+
expect(typeof v).toBe('number');
|
|
877
|
+
expect(Number.isFinite(v)).toBe(true);
|
|
878
|
+
}
|
|
879
|
+
}, 30_000);
|
|
880
|
+
// ─── unifiedSearch text-only (no vector injection) ─────────────────────
|
|
881
|
+
it('unifiedSearch text-only uses tsvector without pgvector', async () => {
|
|
882
|
+
const result = await query(`
|
|
883
|
+
query {
|
|
884
|
+
allArticles(where: { unifiedSearch: "machine learning" }) {
|
|
885
|
+
nodes { rowId title tsvRank embeddingVectorDistance searchScore }
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
`);
|
|
889
|
+
expect(result.errors).toBeUndefined();
|
|
890
|
+
const nodes = result.data?.allArticles?.nodes ?? [];
|
|
891
|
+
expect(nodes.length).toBeGreaterThan(0);
|
|
892
|
+
// tsvector should be active
|
|
893
|
+
const hasTsv = nodes.some((n) => n.tsvRank !== null);
|
|
894
|
+
expect(hasTsv).toBe(true);
|
|
895
|
+
// pgvector should NOT participate (no vector injection in graphile-test)
|
|
896
|
+
for (const node of nodes) {
|
|
897
|
+
expect(node.embeddingVectorDistance).toBeNull();
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
// ─── unifiedSearch + real pgvector: hybrid RRF ─────────────────────────
|
|
901
|
+
it('unifiedSearch + real vector embedding fuses tsvector + pgvector via RRF', async () => {
|
|
902
|
+
const vecStr = `[${mlQueryVector.join(',')}]`;
|
|
903
|
+
const result = await query(`
|
|
904
|
+
query($vec: [Float!]!) {
|
|
905
|
+
allArticles(where: {
|
|
906
|
+
unifiedSearch: "machine learning"
|
|
907
|
+
vectorEmbedding: { vector: $vec, metric: COSINE }
|
|
908
|
+
}) {
|
|
909
|
+
nodes { rowId title tsvRank embeddingVectorDistance searchScore }
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
`, { vec: mlQueryVector });
|
|
913
|
+
expect(result.errors).toBeUndefined();
|
|
914
|
+
const nodes = result.data?.allArticles?.nodes ?? [];
|
|
915
|
+
expect(nodes.length).toBeGreaterThan(0);
|
|
916
|
+
// Both adapters should be active
|
|
917
|
+
const hasTsv = nodes.some((n) => n.tsvRank !== null);
|
|
918
|
+
const hasVector = nodes.some((n) => n.embeddingVectorDistance !== null);
|
|
919
|
+
expect(hasTsv).toBe(true);
|
|
920
|
+
expect(hasVector).toBe(true);
|
|
921
|
+
// searchScore must be normalized [0,1]
|
|
922
|
+
for (const node of nodes) {
|
|
923
|
+
expect(node.searchScore).toBeGreaterThanOrEqual(0);
|
|
924
|
+
expect(node.searchScore).toBeLessThanOrEqual(1);
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
// ─── Semantic relevance with real embeddings ───────────────────────────
|
|
928
|
+
it('ML query ranks ML articles higher than cooking/gardening with real embeddings', async () => {
|
|
929
|
+
const result = await query(`
|
|
930
|
+
query($vec: [Float!]!) {
|
|
931
|
+
allArticles(where: {
|
|
932
|
+
unifiedSearch: "machine learning"
|
|
933
|
+
vectorEmbedding: { vector: $vec, metric: COSINE }
|
|
934
|
+
}) {
|
|
935
|
+
nodes { rowId title searchScore }
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
`, { vec: mlQueryVector });
|
|
939
|
+
expect(result.errors).toBeUndefined();
|
|
940
|
+
const nodes = result.data?.allArticles?.nodes ?? [];
|
|
941
|
+
// ML-related articles (1, 3, 5) should score higher than non-ML (2, 4)
|
|
942
|
+
const mlArticles = nodes.filter((n) => [1, 3, 5].includes(n.rowId));
|
|
943
|
+
const nonMlArticles = nodes.filter((n) => [2, 4].includes(n.rowId));
|
|
944
|
+
if (mlArticles.length > 0 && nonMlArticles.length > 0) {
|
|
945
|
+
const bestMlScore = Math.max(...mlArticles.map((n) => n.searchScore));
|
|
946
|
+
const bestNonMlScore = Math.max(...nonMlArticles.map((n) => n.searchScore));
|
|
947
|
+
expect(bestMlScore).toBeGreaterThan(bestNonMlScore);
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
it('cooking query ranks cooking article higher than ML articles with real embeddings', async () => {
|
|
951
|
+
const result = await query(`
|
|
952
|
+
query($vec: [Float!]!) {
|
|
953
|
+
allArticles(where: {
|
|
954
|
+
unifiedSearch: "Italian pasta cooking"
|
|
955
|
+
vectorEmbedding: { vector: $vec, metric: COSINE }
|
|
956
|
+
}) {
|
|
957
|
+
nodes { rowId title searchScore }
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
`, { vec: cookingQueryVector });
|
|
961
|
+
expect(result.errors).toBeUndefined();
|
|
962
|
+
const nodes = result.data?.allArticles?.nodes ?? [];
|
|
963
|
+
// Cooking article (id=2) should rank highest
|
|
964
|
+
const cookingArticle = nodes.find((n) => n.rowId === 2);
|
|
965
|
+
const otherArticles = nodes.filter((n) => n.rowId !== 2);
|
|
966
|
+
if (cookingArticle && otherArticles.length > 0) {
|
|
967
|
+
const bestOtherScore = Math.max(...otherArticles.map((n) => n.searchScore));
|
|
968
|
+
expect(cookingArticle.searchScore).toBeGreaterThan(bestOtherScore);
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
// ─── Vector-only search with real embeddings ───────────────────────────
|
|
972
|
+
it('VectorNearbyInput with real embedding returns semantically relevant results', async () => {
|
|
973
|
+
const result = await query(`
|
|
974
|
+
query($vec: [Float!]!) {
|
|
975
|
+
allArticles(where: {
|
|
976
|
+
vectorEmbedding: { vector: $vec, metric: COSINE }
|
|
977
|
+
}) {
|
|
978
|
+
nodes { rowId title embeddingVectorDistance }
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
`, { vec: mlQueryVector });
|
|
982
|
+
expect(result.errors).toBeUndefined();
|
|
983
|
+
const nodes = result.data?.allArticles?.nodes ?? [];
|
|
984
|
+
expect(nodes.length).toBe(5);
|
|
985
|
+
// Nearest to ML query should be ML articles
|
|
986
|
+
const sorted = [...nodes].sort((a, b) => a.embeddingVectorDistance - b.embeddingVectorDistance);
|
|
987
|
+
// Top result should be an ML-related article (1, 3, or 5)
|
|
988
|
+
expect([1, 3, 5]).toContain(sorted[0].rowId);
|
|
989
|
+
});
|
|
990
|
+
// ─── RRF boost: text+vector scores higher than text-only ───────────────
|
|
991
|
+
it('hybrid text+vector search scores higher than text-only for relevant docs', async () => {
|
|
992
|
+
const textOnly = await query(`
|
|
993
|
+
query {
|
|
994
|
+
allArticles(where: { unifiedSearch: "machine learning" }) {
|
|
995
|
+
nodes { rowId searchScore }
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
`);
|
|
999
|
+
const hybrid = await query(`
|
|
1000
|
+
query($vec: [Float!]!) {
|
|
1001
|
+
allArticles(where: {
|
|
1002
|
+
unifiedSearch: "machine learning"
|
|
1003
|
+
vectorEmbedding: { vector: $vec, metric: COSINE }
|
|
1004
|
+
}) {
|
|
1005
|
+
nodes { rowId searchScore }
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
`, { vec: mlQueryVector });
|
|
1009
|
+
expect(textOnly.errors).toBeUndefined();
|
|
1010
|
+
expect(hybrid.errors).toBeUndefined();
|
|
1011
|
+
const doc1TextOnly = textOnly.data?.allArticles?.nodes?.find((n) => n.rowId === 1);
|
|
1012
|
+
const doc1Hybrid = hybrid.data?.allArticles?.nodes?.find((n) => n.rowId === 1);
|
|
1013
|
+
// Doc 1 (ML article) should score at least as high with hybrid
|
|
1014
|
+
if (doc1TextOnly && doc1Hybrid) {
|
|
1015
|
+
expect(doc1Hybrid.searchScore).toBeGreaterThanOrEqual(doc1TextOnly.searchScore);
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
});
|
|
@@ -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 {};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Unit tests for LlmTextSearchPlugin's embedTextInWhere function.
|
|
4
|
+
*
|
|
5
|
+
* Tests the transformation logic:
|
|
6
|
+
* - unifiedSearch: "text" → unifiedSearch: { __text: "text", __vector: [...] }
|
|
7
|
+
* - VectorNearbyInput.text → VectorNearbyInput.vector (existing behavior)
|
|
8
|
+
*
|
|
9
|
+
* Pure unit tests — no database or Ollama required.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
const text_search_plugin_1 = require("../../src/plugins/text-search-plugin");
|
|
13
|
+
describe('unifiedSearch embedding integration', () => {
|
|
14
|
+
const mockVector = [0.1, 0.2, 0.3, 0.4, 0.5];
|
|
15
|
+
const mockEmbedder = jest.fn(async (_text) => mockVector);
|
|
16
|
+
const nullEmbedder = jest.fn(async (_text) => null);
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
mockEmbedder.mockClear();
|
|
19
|
+
nullEmbedder.mockClear();
|
|
20
|
+
});
|
|
21
|
+
describe('unifiedSearch text → { __text, __vector } transformation', () => {
|
|
22
|
+
it('transforms unifiedSearch string to object with __text and __vector', async () => {
|
|
23
|
+
const where = { unifiedSearch: 'HIPAA compliance' };
|
|
24
|
+
await (0, text_search_plugin_1.embedTextInWhere)(where, mockEmbedder, true);
|
|
25
|
+
expect(where.unifiedSearch).toEqual({
|
|
26
|
+
__text: 'HIPAA compliance',
|
|
27
|
+
__vector: mockVector,
|
|
28
|
+
});
|
|
29
|
+
expect(mockEmbedder).toHaveBeenCalledWith('HIPAA compliance');
|
|
30
|
+
});
|
|
31
|
+
it('leaves unifiedSearch as plain string when embedder returns null (graceful degradation)', async () => {
|
|
32
|
+
const where = { unifiedSearch: 'database normalization' };
|
|
33
|
+
await (0, text_search_plugin_1.embedTextInWhere)(where, nullEmbedder, true);
|
|
34
|
+
// Should remain as string — text adapters handle it
|
|
35
|
+
expect(where.unifiedSearch).toBe('database normalization');
|
|
36
|
+
expect(nullEmbedder).toHaveBeenCalledWith('database normalization');
|
|
37
|
+
});
|
|
38
|
+
it('throws when embedder returns null and no text adapters available', async () => {
|
|
39
|
+
const where = { unifiedSearch: 'vector only query' };
|
|
40
|
+
await expect((0, text_search_plugin_1.embedTextInWhere)(where, nullEmbedder, false)).rejects.toThrow('embedding failed');
|
|
41
|
+
});
|
|
42
|
+
it('does not embed empty unifiedSearch strings', async () => {
|
|
43
|
+
const where = { unifiedSearch: ' ' };
|
|
44
|
+
await (0, text_search_plugin_1.embedTextInWhere)(where, mockEmbedder, true);
|
|
45
|
+
expect(where.unifiedSearch).toBe(' ');
|
|
46
|
+
expect(mockEmbedder).not.toHaveBeenCalled();
|
|
47
|
+
});
|
|
48
|
+
it('does not embed null unifiedSearch', async () => {
|
|
49
|
+
const where = { unifiedSearch: null };
|
|
50
|
+
await (0, text_search_plugin_1.embedTextInWhere)(where, mockEmbedder, true);
|
|
51
|
+
expect(where.unifiedSearch).toBeNull();
|
|
52
|
+
expect(mockEmbedder).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('VectorNearbyInput text → vector (existing behavior)', () => {
|
|
56
|
+
it('transforms VectorNearbyInput text to vector', async () => {
|
|
57
|
+
const where = { vectorEmbedding: { text: 'semantic query' } };
|
|
58
|
+
await (0, text_search_plugin_1.embedTextInWhere)(where, mockEmbedder, true);
|
|
59
|
+
expect(where.vectorEmbedding).toEqual({ vector: mockVector });
|
|
60
|
+
expect(mockEmbedder).toHaveBeenCalledWith('semantic query');
|
|
61
|
+
});
|
|
62
|
+
it('removes text field when embedder returns null', async () => {
|
|
63
|
+
const where = { vectorEmbedding: { text: 'failed query' } };
|
|
64
|
+
await (0, text_search_plugin_1.embedTextInWhere)(where, nullEmbedder, true);
|
|
65
|
+
expect(where.vectorEmbedding).toEqual({});
|
|
66
|
+
expect(nullEmbedder).toHaveBeenCalledWith('failed query');
|
|
67
|
+
});
|
|
68
|
+
it('does not modify VectorNearbyInput with existing vector', async () => {
|
|
69
|
+
const existingVector = [1, 2, 3];
|
|
70
|
+
const where = { vectorEmbedding: { vector: existingVector, text: 'ignored' } };
|
|
71
|
+
await (0, text_search_plugin_1.embedTextInWhere)(where, mockEmbedder, true);
|
|
72
|
+
// text + vector present → not modified (vector takes precedence)
|
|
73
|
+
expect(where.vectorEmbedding.vector).toBe(existingVector);
|
|
74
|
+
expect(mockEmbedder).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe('combined unifiedSearch + VectorNearbyInput', () => {
|
|
78
|
+
it('embeds both unifiedSearch and VectorNearbyInput.text in parallel', async () => {
|
|
79
|
+
const where = {
|
|
80
|
+
unifiedSearch: 'hybrid search query',
|
|
81
|
+
vectorEmbedding: { text: 'vector part' },
|
|
82
|
+
};
|
|
83
|
+
await (0, text_search_plugin_1.embedTextInWhere)(where, mockEmbedder, true);
|
|
84
|
+
expect(where.unifiedSearch).toEqual({
|
|
85
|
+
__text: 'hybrid search query',
|
|
86
|
+
__vector: mockVector,
|
|
87
|
+
});
|
|
88
|
+
expect(where.vectorEmbedding).toEqual({ vector: mockVector });
|
|
89
|
+
expect(mockEmbedder).toHaveBeenCalledTimes(2);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe('nested filter structures (AND, OR)', () => {
|
|
93
|
+
it('handles unifiedSearch inside nested AND/OR filters', async () => {
|
|
94
|
+
const where = {
|
|
95
|
+
AND: [
|
|
96
|
+
{ unifiedSearch: 'first query' },
|
|
97
|
+
{ unifiedSearch: 'second query' },
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
await (0, text_search_plugin_1.embedTextInWhere)(where, mockEmbedder, true);
|
|
101
|
+
expect(where.AND[0].unifiedSearch).toEqual({
|
|
102
|
+
__text: 'first query',
|
|
103
|
+
__vector: mockVector,
|
|
104
|
+
});
|
|
105
|
+
expect(where.AND[1].unifiedSearch).toEqual({
|
|
106
|
+
__text: 'second query',
|
|
107
|
+
__vector: mockVector,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe('onQuotaExceeded option', () => {
|
|
112
|
+
it('degrade mode (default): leaves unifiedSearch as string when embedder returns null', async () => {
|
|
113
|
+
const where = { unifiedSearch: 'some query' };
|
|
114
|
+
await (0, text_search_plugin_1.embedTextInWhere)(where, nullEmbedder, true, 'degrade');
|
|
115
|
+
expect(where.unifiedSearch).toBe('some query');
|
|
116
|
+
});
|
|
117
|
+
it('throw mode: throws on unifiedSearch when embedder returns null even with text adapters', async () => {
|
|
118
|
+
const where = { unifiedSearch: 'some query' };
|
|
119
|
+
await expect((0, text_search_plugin_1.embedTextInWhere)(where, nullEmbedder, true, 'throw')).rejects.toThrow('onQuotaExceeded is set to \'throw\'');
|
|
120
|
+
});
|
|
121
|
+
it('throw mode: throws on VectorNearbyInput.text when embedder returns null', async () => {
|
|
122
|
+
const where = { vectorEmbedding: { text: 'semantic query' } };
|
|
123
|
+
await expect((0, text_search_plugin_1.embedTextInWhere)(where, nullEmbedder, true, 'throw')).rejects.toThrow('VectorNearbyInput: embedding failed');
|
|
124
|
+
});
|
|
125
|
+
it('degrade mode: silently removes VectorNearbyInput.text when embedder returns null', async () => {
|
|
126
|
+
const where = { vectorEmbedding: { text: 'failed query' } };
|
|
127
|
+
await (0, text_search_plugin_1.embedTextInWhere)(where, nullEmbedder, true, 'degrade');
|
|
128
|
+
expect(where.vectorEmbedding).toEqual({});
|
|
129
|
+
});
|
|
130
|
+
it('throw mode: still works normally when embedder succeeds', async () => {
|
|
131
|
+
const where = { unifiedSearch: 'good query' };
|
|
132
|
+
await (0, text_search_plugin_1.embedTextInWhere)(where, mockEmbedder, true, 'throw');
|
|
133
|
+
expect(where.unifiedSearch).toEqual({
|
|
134
|
+
__text: 'good query',
|
|
135
|
+
__vector: mockVector,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
it('no text adapters: throws regardless of onQuotaExceeded setting', async () => {
|
|
139
|
+
const where1 = { unifiedSearch: 'query1' };
|
|
140
|
+
await expect((0, text_search_plugin_1.embedTextInWhere)(where1, nullEmbedder, false, 'degrade')).rejects.toThrow('No text search adapters available');
|
|
141
|
+
const where2 = { unifiedSearch: 'query2' };
|
|
142
|
+
await expect((0, text_search_plugin_1.embedTextInWhere)(where2, nullEmbedder, false, 'throw')).rejects.toThrow('No text search adapters available');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
describe('apply function object shape handling', () => {
|
|
146
|
+
it('plugin.ts apply function handles { __text, __vector } correctly', () => {
|
|
147
|
+
// Simulate what the apply function does with the transformed value
|
|
148
|
+
const val = { __text: 'HIPAA compliance', __vector: [0.1, 0.2, 0.3] };
|
|
149
|
+
let text;
|
|
150
|
+
let vector = null;
|
|
151
|
+
if (typeof val === 'object' && val.__text) {
|
|
152
|
+
text = val.__text;
|
|
153
|
+
vector = val.__vector ?? null;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
text = typeof val === 'string' ? val : String(val);
|
|
157
|
+
}
|
|
158
|
+
expect(text).toBe('HIPAA compliance');
|
|
159
|
+
expect(vector).toEqual([0.1, 0.2, 0.3]);
|
|
160
|
+
});
|
|
161
|
+
it('plugin.ts apply function handles plain string correctly', () => {
|
|
162
|
+
const val = 'plain text search';
|
|
163
|
+
let text;
|
|
164
|
+
let vector = null;
|
|
165
|
+
if (typeof val === 'object' && val.__text) {
|
|
166
|
+
text = val.__text;
|
|
167
|
+
vector = val.__vector ?? null;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
text = typeof val === 'string' ? val : String(val);
|
|
171
|
+
}
|
|
172
|
+
expect(text).toBe('plain text search');
|
|
173
|
+
expect(vector).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
package/embedder.d.ts
CHANGED
|
@@ -4,12 +4,32 @@
|
|
|
4
4
|
* Provides a provider-based architecture for converting text into vector
|
|
5
5
|
* embeddings. Currently supports Ollama via @agentic-kit/ollama.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Per-request model/baseUrl overrides are read from AsyncLocalStorage
|
|
8
|
+
* (`llmConfigStore`). The text-search-plugin sets these overrides from
|
|
9
|
+
* the database's `llm_module` config via `ctx.useLlm()`. This means
|
|
10
|
+
* the same embedder function — and its metering wrapper — handle every
|
|
11
|
+
* request, regardless of which tenant's model config applies.
|
|
11
12
|
*/
|
|
13
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
12
14
|
import type { EmbedderConfig, EmbedderFunction, LlmModuleData } from './types';
|
|
15
|
+
/**
|
|
16
|
+
* Per-request LLM config overrides from the database's llm_module.
|
|
17
|
+
* Only string parameters — the embedder client itself stays the same.
|
|
18
|
+
*/
|
|
19
|
+
export interface LlmConfigOverrides {
|
|
20
|
+
embeddingModel?: string;
|
|
21
|
+
embeddingBaseUrl?: string;
|
|
22
|
+
chatModel?: string;
|
|
23
|
+
chatBaseUrl?: string;
|
|
24
|
+
}
|
|
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
|
+
export declare const llmConfigStore: AsyncLocalStorage<LlmConfigOverrides>;
|
|
13
33
|
/**
|
|
14
34
|
* Build an embedder function from a config object.
|
|
15
35
|
*
|