graphile-llm 0.2.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/LICENSE +23 -0
- package/README.md +193 -0
- package/__tests__/graphile-llm.test.d.ts +1 -0
- package/__tests__/graphile-llm.test.js +721 -0
- package/chat.d.ts +37 -0
- package/chat.js +105 -0
- package/embedder.d.ts +35 -0
- package/embedder.js +79 -0
- package/esm/__tests__/graphile-llm.test.d.ts +1 -0
- package/esm/__tests__/graphile-llm.test.js +683 -0
- package/esm/chat.d.ts +37 -0
- package/esm/chat.js +97 -0
- package/esm/embedder.d.ts +35 -0
- package/esm/embedder.js +71 -0
- package/esm/index.d.ts +39 -0
- package/esm/index.js +42 -0
- package/esm/plugins/llm-module-plugin.d.ts +38 -0
- package/esm/plugins/llm-module-plugin.js +82 -0
- package/esm/plugins/rag-plugin.d.ts +36 -0
- package/esm/plugins/rag-plugin.js +341 -0
- package/esm/plugins/text-mutation-plugin.d.ts +44 -0
- package/esm/plugins/text-mutation-plugin.js +191 -0
- package/esm/plugins/text-search-plugin.d.ts +41 -0
- package/esm/plugins/text-search-plugin.js +163 -0
- package/esm/preset.d.ts +55 -0
- package/esm/preset.js +74 -0
- package/esm/types.d.ts +173 -0
- package/esm/types.js +6 -0
- package/index.d.ts +39 -0
- package/index.js +56 -0
- package/package.json +76 -0
- package/plugins/llm-module-plugin.d.ts +38 -0
- package/plugins/llm-module-plugin.js +85 -0
- package/plugins/rag-plugin.d.ts +36 -0
- package/plugins/rag-plugin.js +344 -0
- package/plugins/text-mutation-plugin.d.ts +44 -0
- package/plugins/text-mutation-plugin.js +194 -0
- package/plugins/text-search-plugin.d.ts +41 -0
- package/plugins/text-search-plugin.js +166 -0
- package/preset.d.ts +55 -0
- package/preset.js +77 -0
- package/types.d.ts +173 -0
- package/types.js +7 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LlmRagPlugin
|
|
3
|
+
*
|
|
4
|
+
* Adds RAG (Retrieval-Augmented Generation) query support to PostGraphile v5.
|
|
5
|
+
*
|
|
6
|
+
* When enabled, this plugin:
|
|
7
|
+
* 1. Discovers tables with @hasChunks smart tag during schema build
|
|
8
|
+
* 2. Adds a `ragQuery` root query field that orchestrates:
|
|
9
|
+
* embed prompt → pgvector search chunks → assemble context → call chat LLM → return answer
|
|
10
|
+
* 3. Adds an `embedText` root query field for standalone text-to-vector conversion
|
|
11
|
+
*
|
|
12
|
+
* Uses the extendSchema + grafast lambda pattern (same as bucket-provisioner
|
|
13
|
+
* and presigned-url plugins) for async operations at execution time.
|
|
14
|
+
*
|
|
15
|
+
* RAG is a consumer of graphile-search's pgvector adapter — it uses the existing
|
|
16
|
+
* chunk-aware tables but orchestrates the full LLM synthesis pipeline.
|
|
17
|
+
*
|
|
18
|
+
* Resolution order for embedder and chat completer:
|
|
19
|
+
* 1. build.llmEmbedder / build.llmChatCompleter (from LlmModulePlugin)
|
|
20
|
+
* 2. Falls back to error if not configured
|
|
21
|
+
*/
|
|
22
|
+
import { context as grafastContext, lambda, object } from 'grafast';
|
|
23
|
+
import { extendSchema, gql } from 'graphile-utils';
|
|
24
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
25
|
+
const DEFAULT_CONTEXT_LIMIT = 5;
|
|
26
|
+
const DEFAULT_MAX_TOKENS = 4000;
|
|
27
|
+
const DEFAULT_MIN_SIMILARITY = 0;
|
|
28
|
+
const DEFAULT_SYSTEM_PROMPT = 'You are a helpful assistant. Answer the user\'s question based ONLY on the ' +
|
|
29
|
+
'following context. If the context does not contain enough information to ' +
|
|
30
|
+
'answer, say so. Do not make up information.\n\n' +
|
|
31
|
+
'--- CONTEXT ---\n';
|
|
32
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
33
|
+
/**
|
|
34
|
+
* Parse @hasChunks smart tag from a codec's extensions into ChunkTableInfo.
|
|
35
|
+
* Mirrors the parsing logic in graphile-search's pgvector adapter.
|
|
36
|
+
*/
|
|
37
|
+
function parseHasChunksTag(raw, codec) {
|
|
38
|
+
let parsed;
|
|
39
|
+
if (typeof raw === 'string') {
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(raw);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else if (typeof raw === 'object' && raw !== null) {
|
|
48
|
+
parsed = raw;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
if (!parsed.chunksTable)
|
|
54
|
+
return null;
|
|
55
|
+
const chunksSchema = parsed.chunksSchema
|
|
56
|
+
|| codec?.extensions?.pg?.schemaName
|
|
57
|
+
|| null;
|
|
58
|
+
return {
|
|
59
|
+
parentCodecName: codec.name || 'unknown',
|
|
60
|
+
chunksSchema,
|
|
61
|
+
chunksTableName: parsed.chunksTable,
|
|
62
|
+
parentFkField: parsed.parentFk || 'parent_id',
|
|
63
|
+
parentPkField: parsed.parentPk || 'id',
|
|
64
|
+
embeddingField: parsed.embeddingField || 'embedding',
|
|
65
|
+
contentField: parsed.contentField || 'content',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Discover all chunk-aware tables from the pgRegistry.
|
|
70
|
+
*/
|
|
71
|
+
function discoverChunkTables(build) {
|
|
72
|
+
const chunkTables = [];
|
|
73
|
+
const pgRegistry = build.pgRegistry;
|
|
74
|
+
if (!pgRegistry)
|
|
75
|
+
return chunkTables;
|
|
76
|
+
// Scan all codecs for @hasChunks smart tag
|
|
77
|
+
for (const source of Object.values(pgRegistry.pgResources || {})) {
|
|
78
|
+
const codec = source?.codec;
|
|
79
|
+
if (!codec?.attributes)
|
|
80
|
+
continue;
|
|
81
|
+
const tags = codec.extensions?.tags;
|
|
82
|
+
if (!tags?.hasChunks)
|
|
83
|
+
continue;
|
|
84
|
+
const info = parseHasChunksTag(tags.hasChunks, codec);
|
|
85
|
+
if (info) {
|
|
86
|
+
chunkTables.push(info);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return chunkTables;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Build a SQL query string to search a chunks table for similar embeddings.
|
|
93
|
+
*/
|
|
94
|
+
function buildChunkSearchSql(table, vectorString, limit, maxDistance) {
|
|
95
|
+
const schema = table.chunksSchema;
|
|
96
|
+
const qualifiedTable = schema
|
|
97
|
+
? `"${schema}"."${table.chunksTableName}"`
|
|
98
|
+
: `"${table.chunksTableName}"`;
|
|
99
|
+
const embeddingCol = `"${table.embeddingField}"`;
|
|
100
|
+
const contentCol = `"${table.contentField}"`;
|
|
101
|
+
const parentFkCol = `"${table.parentFkField}"`;
|
|
102
|
+
let text = `
|
|
103
|
+
SELECT
|
|
104
|
+
${contentCol} AS content,
|
|
105
|
+
${parentFkCol}::text AS parent_id,
|
|
106
|
+
(${embeddingCol} <=> $1::vector) AS distance
|
|
107
|
+
FROM ${qualifiedTable}
|
|
108
|
+
`;
|
|
109
|
+
const values = [vectorString];
|
|
110
|
+
if (maxDistance !== null) {
|
|
111
|
+
text += ` WHERE (${embeddingCol} <=> $1::vector) <= $2`;
|
|
112
|
+
values.push(maxDistance);
|
|
113
|
+
}
|
|
114
|
+
text += ` ORDER BY ${embeddingCol} <=> $1::vector LIMIT $${values.length + 1}`;
|
|
115
|
+
values.push(limit);
|
|
116
|
+
return { text, values };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Assemble retrieved chunks into a context string for the LLM prompt.
|
|
120
|
+
*/
|
|
121
|
+
function assembleContext(chunks) {
|
|
122
|
+
return chunks
|
|
123
|
+
.map((chunk, i) => `[Source ${i + 1}] (similarity: ${(1 - chunk.distance).toFixed(3)})\n${chunk.content}`)
|
|
124
|
+
.join('\n\n---\n\n');
|
|
125
|
+
}
|
|
126
|
+
// ─── Plugin Factory ─────────────────────────────────────────────────────────
|
|
127
|
+
/**
|
|
128
|
+
* Creates the LlmRagPlugin.
|
|
129
|
+
*
|
|
130
|
+
* @param ragDefaults - Default configuration for RAG queries
|
|
131
|
+
*/
|
|
132
|
+
export function createLlmRagPlugin(ragDefaults = {}) {
|
|
133
|
+
// Chunk tables discovered during schema build, used by the plan at execution time
|
|
134
|
+
let chunkTables = [];
|
|
135
|
+
let embedder = null;
|
|
136
|
+
let chatCompleter = null;
|
|
137
|
+
const schemaExtension = extendSchema((build) => {
|
|
138
|
+
// Discover chunk-aware tables from pgRegistry
|
|
139
|
+
chunkTables = discoverChunkTables(build);
|
|
140
|
+
embedder = build.llmEmbedder || null;
|
|
141
|
+
chatCompleter = build.llmChatCompleter || null;
|
|
142
|
+
if (chunkTables.length > 0) {
|
|
143
|
+
console.log(`[graphile-llm] RAG plugin discovered ${chunkTables.length} chunk-aware table(s): ` +
|
|
144
|
+
chunkTables.map((t) => t.parentCodecName).join(', '));
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
console.log('[graphile-llm] RAG plugin found no @hasChunks tables. ' +
|
|
148
|
+
'ragQuery will still work if chunks tables are queried directly.');
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
typeDefs: gql `
|
|
152
|
+
"""A source chunk retrieved during RAG context assembly."""
|
|
153
|
+
type RagSource {
|
|
154
|
+
"""The text content of the retrieved chunk."""
|
|
155
|
+
content: String!
|
|
156
|
+
"""Cosine similarity score (0..1, higher = more similar)."""
|
|
157
|
+
similarity: Float!
|
|
158
|
+
"""The parent table this chunk belongs to."""
|
|
159
|
+
tableName: String
|
|
160
|
+
"""The parent row ID this chunk belongs to."""
|
|
161
|
+
parentId: String
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
"""Response from a RAG (Retrieval-Augmented Generation) query."""
|
|
165
|
+
type RagResponse {
|
|
166
|
+
"""The LLM-generated answer based on retrieved context."""
|
|
167
|
+
answer: String!
|
|
168
|
+
"""The source chunks used as context for the answer."""
|
|
169
|
+
sources: [RagSource!]!
|
|
170
|
+
"""Approximate token count for the request (logging only, not metered)."""
|
|
171
|
+
tokensUsed: Int
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
"""Response from an embedText query."""
|
|
175
|
+
type EmbedTextResponse {
|
|
176
|
+
"""The resulting vector embedding."""
|
|
177
|
+
vector: [Float!]!
|
|
178
|
+
"""Number of dimensions in the vector."""
|
|
179
|
+
dimensions: Int!
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
extend type Query {
|
|
183
|
+
"""
|
|
184
|
+
RAG (Retrieval-Augmented Generation) query.
|
|
185
|
+
Embeds the prompt, searches chunk-aware tables for similar content,
|
|
186
|
+
assembles context, and calls the chat LLM to generate an answer.
|
|
187
|
+
Requires both an embedding provider and a chat provider to be configured.
|
|
188
|
+
"""
|
|
189
|
+
ragQuery(
|
|
190
|
+
"""The natural language question or prompt."""
|
|
191
|
+
prompt: String!
|
|
192
|
+
"""Maximum number of context chunks to include (default: 5)."""
|
|
193
|
+
contextLimit: Int
|
|
194
|
+
"""Minimum similarity threshold (0..1). Chunks below this are excluded."""
|
|
195
|
+
minSimilarity: Float
|
|
196
|
+
"""Custom system prompt. Overrides the default RAG system prompt."""
|
|
197
|
+
systemPrompt: String
|
|
198
|
+
): RagResponse
|
|
199
|
+
|
|
200
|
+
"""
|
|
201
|
+
Convert text to a vector embedding using the configured embedding provider.
|
|
202
|
+
Useful for client-side vector operations when you need the raw vector.
|
|
203
|
+
"""
|
|
204
|
+
embedText(
|
|
205
|
+
"""The text to embed."""
|
|
206
|
+
text: String!
|
|
207
|
+
): EmbedTextResponse
|
|
208
|
+
}
|
|
209
|
+
`,
|
|
210
|
+
plans: {
|
|
211
|
+
Query: {
|
|
212
|
+
ragQuery(_$root, fieldArgs) {
|
|
213
|
+
const $prompt = fieldArgs.getRaw('prompt');
|
|
214
|
+
const $contextLimit = fieldArgs.getRaw('contextLimit');
|
|
215
|
+
const $minSimilarity = fieldArgs.getRaw('minSimilarity');
|
|
216
|
+
const $systemPrompt = fieldArgs.getRaw('systemPrompt');
|
|
217
|
+
const $withPgClient = grafastContext().get('withPgClient');
|
|
218
|
+
const $pgSettings = grafastContext().get('pgSettings');
|
|
219
|
+
const $combined = object({
|
|
220
|
+
prompt: $prompt,
|
|
221
|
+
contextLimit: $contextLimit,
|
|
222
|
+
minSimilarity: $minSimilarity,
|
|
223
|
+
systemPrompt: $systemPrompt,
|
|
224
|
+
withPgClient: $withPgClient,
|
|
225
|
+
pgSettings: $pgSettings,
|
|
226
|
+
});
|
|
227
|
+
return lambda($combined, async (input) => {
|
|
228
|
+
const { prompt, contextLimit: queryContextLimit, minSimilarity: queryMinSimilarity, systemPrompt: querySystemPrompt, withPgClient, pgSettings, } = input;
|
|
229
|
+
if (!prompt || typeof prompt !== 'string') {
|
|
230
|
+
throw new Error('RAG_INVALID_PROMPT: prompt is required');
|
|
231
|
+
}
|
|
232
|
+
if (!embedder) {
|
|
233
|
+
throw new Error('RAG_EMBEDDER_NOT_CONFIGURED: An embedding provider must be configured ' +
|
|
234
|
+
'to use ragQuery. Set defaultEmbedder in GraphileLlmPreset options.');
|
|
235
|
+
}
|
|
236
|
+
if (!chatCompleter) {
|
|
237
|
+
throw new Error('RAG_CHAT_NOT_CONFIGURED: A chat completion provider must be configured ' +
|
|
238
|
+
'to use ragQuery. Set defaultChatCompleter in GraphileLlmPreset options.');
|
|
239
|
+
}
|
|
240
|
+
// Resolve parameters with defaults
|
|
241
|
+
const limit = queryContextLimit ?? ragDefaults.contextLimit ?? DEFAULT_CONTEXT_LIMIT;
|
|
242
|
+
const minSim = queryMinSimilarity ?? ragDefaults.minSimilarity ?? DEFAULT_MIN_SIMILARITY;
|
|
243
|
+
const maxDistance = minSim > 0 ? (1 - minSim) : null;
|
|
244
|
+
const systemPromptTemplate = querySystemPrompt ?? ragDefaults.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
|
245
|
+
// Step 1: Embed the prompt
|
|
246
|
+
const startEmbed = Date.now();
|
|
247
|
+
const vector = await embedder(prompt);
|
|
248
|
+
const embedLatency = Date.now() - startEmbed;
|
|
249
|
+
const vectorString = `[${vector.join(',')}]`;
|
|
250
|
+
console.log(`[graphile-llm] RAG embed: dims=${vector.length}, latency=${embedLatency}ms`);
|
|
251
|
+
// Step 2: Search chunks tables for similar content
|
|
252
|
+
const allChunks = [];
|
|
253
|
+
if (chunkTables.length > 0) {
|
|
254
|
+
await withPgClient(pgSettings, async (pgClient) => {
|
|
255
|
+
for (const table of chunkTables) {
|
|
256
|
+
const query = buildChunkSearchSql(table, vectorString, limit, maxDistance);
|
|
257
|
+
const result = await pgClient.query(query);
|
|
258
|
+
for (const row of result.rows) {
|
|
259
|
+
allChunks.push({
|
|
260
|
+
content: row.content,
|
|
261
|
+
parent_id: row.parent_id,
|
|
262
|
+
distance: parseFloat(row.distance),
|
|
263
|
+
table_name: table.parentCodecName,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
// Sort by distance (ascending) and take top N
|
|
270
|
+
allChunks.sort((a, b) => a.distance - b.distance);
|
|
271
|
+
const topChunks = allChunks.slice(0, limit);
|
|
272
|
+
if (topChunks.length === 0) {
|
|
273
|
+
return {
|
|
274
|
+
answer: 'No relevant context found for your query. ' +
|
|
275
|
+
'Try broadening your search or lowering the minimum similarity threshold.',
|
|
276
|
+
sources: [],
|
|
277
|
+
tokensUsed: null,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
// Step 3: Assemble context
|
|
281
|
+
const contextText = assembleContext(topChunks);
|
|
282
|
+
// Step 4: Call chat completion
|
|
283
|
+
const startChat = Date.now();
|
|
284
|
+
const answer = await chatCompleter([
|
|
285
|
+
{ role: 'system', content: systemPromptTemplate + contextText },
|
|
286
|
+
{ role: 'user', content: prompt },
|
|
287
|
+
], {
|
|
288
|
+
maxTokens: ragDefaults.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
289
|
+
});
|
|
290
|
+
const chatLatency = Date.now() - startChat;
|
|
291
|
+
console.log(`[graphile-llm] RAG chat: sources=${topChunks.length}, latency=${chatLatency}ms`);
|
|
292
|
+
// Step 5: Return response
|
|
293
|
+
return {
|
|
294
|
+
answer,
|
|
295
|
+
sources: topChunks.map((chunk) => ({
|
|
296
|
+
content: chunk.content,
|
|
297
|
+
similarity: 1 - chunk.distance,
|
|
298
|
+
tableName: chunk.table_name,
|
|
299
|
+
parentId: chunk.parent_id,
|
|
300
|
+
})),
|
|
301
|
+
tokensUsed: null, // Deferred to metering system
|
|
302
|
+
};
|
|
303
|
+
});
|
|
304
|
+
},
|
|
305
|
+
embedText(_$root, fieldArgs) {
|
|
306
|
+
const $text = fieldArgs.getRaw('text');
|
|
307
|
+
return lambda($text, async (text) => {
|
|
308
|
+
if (!text || typeof text !== 'string') {
|
|
309
|
+
throw new Error('EMBED_INVALID_TEXT: text is required');
|
|
310
|
+
}
|
|
311
|
+
if (!embedder) {
|
|
312
|
+
throw new Error('EMBED_NOT_CONFIGURED: An embedding provider must be configured ' +
|
|
313
|
+
'to use embedText. Set defaultEmbedder in GraphileLlmPreset options.');
|
|
314
|
+
}
|
|
315
|
+
const startTime = Date.now();
|
|
316
|
+
const vector = await embedder(text);
|
|
317
|
+
const latencyMs = Date.now() - startTime;
|
|
318
|
+
console.log(`[graphile-llm] embedText: dims=${vector.length}, latency=${latencyMs}ms`);
|
|
319
|
+
return {
|
|
320
|
+
vector,
|
|
321
|
+
dimensions: vector.length,
|
|
322
|
+
};
|
|
323
|
+
});
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
});
|
|
329
|
+
return {
|
|
330
|
+
...schemaExtension,
|
|
331
|
+
name: 'LlmRagPlugin',
|
|
332
|
+
version: '0.1.0',
|
|
333
|
+
description: 'RAG (Retrieval-Augmented Generation) query support — ' +
|
|
334
|
+
'detects @hasChunks tables and adds ragQuery/embedText fields',
|
|
335
|
+
after: [
|
|
336
|
+
'LlmModulePlugin',
|
|
337
|
+
'UnifiedSearchPlugin',
|
|
338
|
+
'VectorCodecPlugin',
|
|
339
|
+
],
|
|
340
|
+
};
|
|
341
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LlmTextMutationPlugin
|
|
3
|
+
*
|
|
4
|
+
* Adds `{columnName}Text: String` companion fields on create/update mutation
|
|
5
|
+
* inputs for every vector column. When the client provides a text string in
|
|
6
|
+
* the companion field, the plugin embeds it server-side and injects the
|
|
7
|
+
* resulting vector into the actual column.
|
|
8
|
+
*
|
|
9
|
+
* Example:
|
|
10
|
+
* mutation { createArticle(input: { embeddingText: "Machine learning concepts" }) }
|
|
11
|
+
*
|
|
12
|
+
* This is the mutation counterpart to LlmTextSearchPlugin (which handles
|
|
13
|
+
* filter/query-side text-to-vector). Together they let clients work entirely
|
|
14
|
+
* with text/prompts instead of raw float vectors.
|
|
15
|
+
*
|
|
16
|
+
* Runtime embedding uses the v4-style resolver wrapping approach (same as
|
|
17
|
+
* graphile-upload-plugin and graphile-bucket-provisioner-plugin). grafserv v5
|
|
18
|
+
* supports this through its backwards-compatibility layer.
|
|
19
|
+
*
|
|
20
|
+
* The companion fields are only added when the LLM plugin is loaded.
|
|
21
|
+
* If no embedder is configured, the fields are still registered for schema
|
|
22
|
+
* stability but return a clear error at execution time.
|
|
23
|
+
*/
|
|
24
|
+
import 'graphile-build';
|
|
25
|
+
import 'graphile-build-pg';
|
|
26
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
27
|
+
declare global {
|
|
28
|
+
namespace GraphileConfig {
|
|
29
|
+
interface Plugins {
|
|
30
|
+
LlmTextMutationPlugin: true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Creates the LlmTextMutationPlugin.
|
|
36
|
+
*
|
|
37
|
+
* Hooks into GraphQLInputObjectType_fields for create/update input types
|
|
38
|
+
* and adds `{columnName}Text: String` for each vector column.
|
|
39
|
+
*
|
|
40
|
+
* Also wraps mutation resolvers via GraphQLObjectType_fields_field to
|
|
41
|
+
* intercept `*Text` companion field values, embed them, and inject the
|
|
42
|
+
* resulting vectors before the mutation executes.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createLlmTextMutationPlugin(): GraphileConfig.Plugin;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LlmTextMutationPlugin
|
|
3
|
+
*
|
|
4
|
+
* Adds `{columnName}Text: String` companion fields on create/update mutation
|
|
5
|
+
* inputs for every vector column. When the client provides a text string in
|
|
6
|
+
* the companion field, the plugin embeds it server-side and injects the
|
|
7
|
+
* resulting vector into the actual column.
|
|
8
|
+
*
|
|
9
|
+
* Example:
|
|
10
|
+
* mutation { createArticle(input: { embeddingText: "Machine learning concepts" }) }
|
|
11
|
+
*
|
|
12
|
+
* This is the mutation counterpart to LlmTextSearchPlugin (which handles
|
|
13
|
+
* filter/query-side text-to-vector). Together they let clients work entirely
|
|
14
|
+
* with text/prompts instead of raw float vectors.
|
|
15
|
+
*
|
|
16
|
+
* Runtime embedding uses the v4-style resolver wrapping approach (same as
|
|
17
|
+
* graphile-upload-plugin and graphile-bucket-provisioner-plugin). grafserv v5
|
|
18
|
+
* supports this through its backwards-compatibility layer.
|
|
19
|
+
*
|
|
20
|
+
* The companion fields are only added when the LLM plugin is loaded.
|
|
21
|
+
* If no embedder is configured, the fields are still registered for schema
|
|
22
|
+
* stability but return a clear error at execution time.
|
|
23
|
+
*/
|
|
24
|
+
import 'graphile-build';
|
|
25
|
+
import 'graphile-build-pg';
|
|
26
|
+
/**
|
|
27
|
+
* Check if a codec is the pgvector `vector` type.
|
|
28
|
+
*/
|
|
29
|
+
function isVectorCodec(codec) {
|
|
30
|
+
return codec?.name === 'vector';
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Collect the text→vector field mapping for a codec's vector columns.
|
|
34
|
+
* Returns a map of textFieldName → vectorFieldName.
|
|
35
|
+
*/
|
|
36
|
+
function getTextToVectorMapping(pgCodec, build) {
|
|
37
|
+
const mapping = {};
|
|
38
|
+
if (!pgCodec?.attributes)
|
|
39
|
+
return mapping;
|
|
40
|
+
for (const [attributeName, attribute] of Object.entries(pgCodec.attributes)) {
|
|
41
|
+
if (isVectorCodec(attribute.codec)) {
|
|
42
|
+
const fieldName = build.inflection.attribute({
|
|
43
|
+
codec: pgCodec,
|
|
44
|
+
attributeName,
|
|
45
|
+
});
|
|
46
|
+
mapping[`${fieldName}Text`] = fieldName;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return mapping;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Creates the LlmTextMutationPlugin.
|
|
53
|
+
*
|
|
54
|
+
* Hooks into GraphQLInputObjectType_fields for create/update input types
|
|
55
|
+
* and adds `{columnName}Text: String` for each vector column.
|
|
56
|
+
*
|
|
57
|
+
* Also wraps mutation resolvers via GraphQLObjectType_fields_field to
|
|
58
|
+
* intercept `*Text` companion field values, embed them, and inject the
|
|
59
|
+
* resulting vectors before the mutation executes.
|
|
60
|
+
*/
|
|
61
|
+
export function createLlmTextMutationPlugin() {
|
|
62
|
+
return {
|
|
63
|
+
name: 'LlmTextMutationPlugin',
|
|
64
|
+
version: '0.1.0',
|
|
65
|
+
description: 'Adds text companion fields on mutation inputs for vector columns — ' +
|
|
66
|
+
'text is embedded server-side before storing',
|
|
67
|
+
after: [
|
|
68
|
+
'LlmModulePlugin',
|
|
69
|
+
'PgAttributesPlugin',
|
|
70
|
+
'PgMutationCreatePlugin',
|
|
71
|
+
'PgMutationUpdateDeletePlugin',
|
|
72
|
+
'VectorCodecPlugin',
|
|
73
|
+
],
|
|
74
|
+
schema: {
|
|
75
|
+
hooks: {
|
|
76
|
+
/**
|
|
77
|
+
* Add `{columnName}Text: String` fields to create/update input types
|
|
78
|
+
* for tables that have vector columns.
|
|
79
|
+
*/
|
|
80
|
+
GraphQLInputObjectType_fields(fields, build, context) {
|
|
81
|
+
const { scope: { isPgPatch, isPgBaseInput, isMutationInput, pgCodec, }, } = context;
|
|
82
|
+
// Only intercept create/update input types for table rows
|
|
83
|
+
if (!pgCodec?.attributes || (!isPgPatch && !isPgBaseInput && !isMutationInput)) {
|
|
84
|
+
return fields;
|
|
85
|
+
}
|
|
86
|
+
const { graphql: { GraphQLString }, } = build;
|
|
87
|
+
// Find vector columns on this table
|
|
88
|
+
const vectorColumns = [];
|
|
89
|
+
for (const [attributeName, attribute] of Object.entries(pgCodec.attributes)) {
|
|
90
|
+
if (isVectorCodec(attribute.codec)) {
|
|
91
|
+
vectorColumns.push(attributeName);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (vectorColumns.length === 0) {
|
|
95
|
+
return fields;
|
|
96
|
+
}
|
|
97
|
+
let newFields = fields;
|
|
98
|
+
for (const columnName of vectorColumns) {
|
|
99
|
+
// Convert snake_case column name to camelCase field name
|
|
100
|
+
const fieldName = build.inflection.attribute({
|
|
101
|
+
codec: pgCodec,
|
|
102
|
+
attributeName: columnName,
|
|
103
|
+
});
|
|
104
|
+
const textFieldName = `${fieldName}Text`;
|
|
105
|
+
newFields = build.extend(newFields, {
|
|
106
|
+
[textFieldName]: {
|
|
107
|
+
type: GraphQLString,
|
|
108
|
+
description: `Natural language text to embed server-side into the \`${fieldName}\` vector column. ` +
|
|
109
|
+
`Mutually exclusive with \`${fieldName}\` — provide one or the other. ` +
|
|
110
|
+
'Requires the LLM plugin to be configured with an embedding provider.',
|
|
111
|
+
},
|
|
112
|
+
}, `LlmTextMutationPlugin adding ${textFieldName} companion field for vector column '${columnName}'`);
|
|
113
|
+
}
|
|
114
|
+
return newFields;
|
|
115
|
+
},
|
|
116
|
+
/**
|
|
117
|
+
* Wrap create/update mutation resolvers to intercept `*Text` companion
|
|
118
|
+
* field values, embed them using the configured embedder, and inject
|
|
119
|
+
* the resulting vector into the corresponding vector field.
|
|
120
|
+
*
|
|
121
|
+
* Uses the same v4-style resolver wrapping pattern as graphile-upload-plugin
|
|
122
|
+
* and graphile-bucket-provisioner-plugin. grafserv v5 supports this through
|
|
123
|
+
* its backwards-compatibility layer.
|
|
124
|
+
*/
|
|
125
|
+
GraphQLObjectType_fields_field(field, build, context) {
|
|
126
|
+
const { scope: { isRootMutation, fieldName, pgCodec }, } = context;
|
|
127
|
+
// Only wrap root mutation fields on tables with attributes
|
|
128
|
+
if (!isRootMutation || !pgCodec || !pgCodec.attributes) {
|
|
129
|
+
return field;
|
|
130
|
+
}
|
|
131
|
+
// Only wrap create/update mutations
|
|
132
|
+
const isCreate = fieldName.startsWith('create');
|
|
133
|
+
const isUpdate = fieldName.startsWith('update');
|
|
134
|
+
if (!isCreate && !isUpdate) {
|
|
135
|
+
return field;
|
|
136
|
+
}
|
|
137
|
+
// Build the text→vector mapping for this codec
|
|
138
|
+
const textToVectorMap = getTextToVectorMapping(pgCodec, build);
|
|
139
|
+
if (Object.keys(textToVectorMap).length === 0) {
|
|
140
|
+
return field;
|
|
141
|
+
}
|
|
142
|
+
const embedder = build.llmEmbedder;
|
|
143
|
+
const defaultResolver = (obj) => obj[fieldName];
|
|
144
|
+
const { resolve: oldResolve = defaultResolver, ...rest } = field;
|
|
145
|
+
return {
|
|
146
|
+
...rest,
|
|
147
|
+
async resolve(source, args, graphqlContext, info) {
|
|
148
|
+
// Walk through the input args and embed any *Text companion fields
|
|
149
|
+
async function embedTextFields(obj) {
|
|
150
|
+
if (!obj || typeof obj !== 'object')
|
|
151
|
+
return;
|
|
152
|
+
const pending = [];
|
|
153
|
+
for (const key of Object.keys(obj)) {
|
|
154
|
+
const value = obj[key];
|
|
155
|
+
// Check if this key is a *Text companion field
|
|
156
|
+
if (key in textToVectorMap && typeof value === 'string') {
|
|
157
|
+
const vectorFieldName = textToVectorMap[key];
|
|
158
|
+
pending.push((async () => {
|
|
159
|
+
if (!embedder) {
|
|
160
|
+
throw new Error(`EMBED_NOT_CONFIGURED: Cannot embed ${key} — no embedding provider configured. ` +
|
|
161
|
+
'Set defaultEmbedder in GraphileLlmPreset options or EMBEDDER_PROVIDER env var.');
|
|
162
|
+
}
|
|
163
|
+
const startTime = Date.now();
|
|
164
|
+
const vector = await embedder(value);
|
|
165
|
+
const latencyMs = Date.now() - startTime;
|
|
166
|
+
console.log(`[graphile-llm] Mutation embed: field=${key}, dims=${vector.length}, latency=${latencyMs}ms`);
|
|
167
|
+
// Inject the vector into the corresponding field
|
|
168
|
+
obj[vectorFieldName] = vector;
|
|
169
|
+
// Remove the consumed *Text field
|
|
170
|
+
delete obj[key];
|
|
171
|
+
})());
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
// Recurse into nested objects (e.g. input.article.embeddingText)
|
|
175
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
176
|
+
pending.push(embedTextFields(value));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (pending.length > 0) {
|
|
180
|
+
await Promise.all(pending);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
await embedTextFields(args);
|
|
184
|
+
return oldResolve(source, args, graphqlContext, info);
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LlmTextSearchPlugin
|
|
3
|
+
*
|
|
4
|
+
* Adds a `text: String` field to `VectorNearbyInput` when the LLM plugin
|
|
5
|
+
* is enabled. This allows clients to pass natural language text instead of
|
|
6
|
+
* raw float vectors for similarity search — the plugin converts text to
|
|
7
|
+
* vectors server-side using the configured embedder.
|
|
8
|
+
*
|
|
9
|
+
* This mirrors the graphile-postgis pattern where `WithinDistanceInput`
|
|
10
|
+
* accepts a compound input (point + distance) and the plugin handles
|
|
11
|
+
* the conversion to SQL internally.
|
|
12
|
+
*
|
|
13
|
+
* The `text` field is mutually exclusive with `vector`: clients provide
|
|
14
|
+
* one or the other. When `text` is provided, the plugin embeds it and
|
|
15
|
+
* injects the resulting vector into the normal pgvector pipeline.
|
|
16
|
+
*
|
|
17
|
+
* Runtime embedding for query filters uses the v4-style resolver wrapping
|
|
18
|
+
* approach (same as graphile-upload-plugin). When a connection query's
|
|
19
|
+
* `where` argument includes a VectorNearbyInput with `text`, the resolver
|
|
20
|
+
* wrapper embeds the text and replaces it with the resulting vector before
|
|
21
|
+
* the plan executes.
|
|
22
|
+
*
|
|
23
|
+
* If the embedder is not configured, the `text` field is still registered
|
|
24
|
+
* (so the schema is stable) but will return a clear error at execution time.
|
|
25
|
+
*/
|
|
26
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
27
|
+
declare global {
|
|
28
|
+
namespace GraphileConfig {
|
|
29
|
+
interface Plugins {
|
|
30
|
+
LlmTextSearchPlugin: true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Creates the LlmTextSearchPlugin.
|
|
36
|
+
*
|
|
37
|
+
* Hooks into VectorNearbyInput to add a `text` field alongside the
|
|
38
|
+
* existing `vector` field. When a user provides `text`, the plugin's
|
|
39
|
+
* resolver wrapper embeds it before passing to pgvector.
|
|
40
|
+
*/
|
|
41
|
+
export declare function createLlmTextSearchPlugin(): GraphileConfig.Plugin;
|