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.
Files changed (43) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +193 -0
  3. package/__tests__/graphile-llm.test.d.ts +1 -0
  4. package/__tests__/graphile-llm.test.js +721 -0
  5. package/chat.d.ts +37 -0
  6. package/chat.js +105 -0
  7. package/embedder.d.ts +35 -0
  8. package/embedder.js +79 -0
  9. package/esm/__tests__/graphile-llm.test.d.ts +1 -0
  10. package/esm/__tests__/graphile-llm.test.js +683 -0
  11. package/esm/chat.d.ts +37 -0
  12. package/esm/chat.js +97 -0
  13. package/esm/embedder.d.ts +35 -0
  14. package/esm/embedder.js +71 -0
  15. package/esm/index.d.ts +39 -0
  16. package/esm/index.js +42 -0
  17. package/esm/plugins/llm-module-plugin.d.ts +38 -0
  18. package/esm/plugins/llm-module-plugin.js +82 -0
  19. package/esm/plugins/rag-plugin.d.ts +36 -0
  20. package/esm/plugins/rag-plugin.js +341 -0
  21. package/esm/plugins/text-mutation-plugin.d.ts +44 -0
  22. package/esm/plugins/text-mutation-plugin.js +191 -0
  23. package/esm/plugins/text-search-plugin.d.ts +41 -0
  24. package/esm/plugins/text-search-plugin.js +163 -0
  25. package/esm/preset.d.ts +55 -0
  26. package/esm/preset.js +74 -0
  27. package/esm/types.d.ts +173 -0
  28. package/esm/types.js +6 -0
  29. package/index.d.ts +39 -0
  30. package/index.js +56 -0
  31. package/package.json +76 -0
  32. package/plugins/llm-module-plugin.d.ts +38 -0
  33. package/plugins/llm-module-plugin.js +85 -0
  34. package/plugins/rag-plugin.d.ts +36 -0
  35. package/plugins/rag-plugin.js +344 -0
  36. package/plugins/text-mutation-plugin.d.ts +44 -0
  37. package/plugins/text-mutation-plugin.js +194 -0
  38. package/plugins/text-search-plugin.d.ts +41 -0
  39. package/plugins/text-search-plugin.js +166 -0
  40. package/preset.d.ts +55 -0
  41. package/preset.js +77 -0
  42. package/types.d.ts +173 -0
  43. 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;