graphile-llm 1.21.6 → 1.21.7
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/embed-inputs.d.ts +54 -0
- package/embed-inputs.js +285 -0
- package/embedder.d.ts +2 -2
- package/embedder.js +2 -2
- package/esm/embed-inputs.d.ts +54 -0
- package/esm/embed-inputs.js +277 -0
- package/esm/embedder.d.ts +2 -2
- package/esm/embedder.js +2 -2
- package/esm/index.d.ts +2 -0
- package/esm/index.js +1 -0
- package/esm/plugins/llm-module-plugin.d.ts +1 -1
- package/esm/plugins/llm-module-plugin.js +1 -1
- package/esm/plugins/text-mutation-plugin.d.ts +6 -8
- package/esm/plugins/text-mutation-plugin.js +22 -115
- package/esm/plugins/text-search-plugin.d.ts +16 -15
- package/esm/plugins/text-search-plugin.js +37 -121
- package/index.d.ts +2 -0
- package/index.js +4 -1
- package/package.json +2 -2
- package/plugins/llm-module-plugin.d.ts +1 -1
- package/plugins/llm-module-plugin.js +1 -1
- package/plugins/text-mutation-plugin.d.ts +6 -8
- package/plugins/text-mutation-plugin.js +22 -115
- package/plugins/text-search-plugin.d.ts +16 -15
- package/plugins/text-search-plugin.js +37 -121
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan-time text-to-vector embedding.
|
|
3
|
+
*
|
|
4
|
+
* Under Grafast a field's `resolve()` runs after its plan has executed, so the
|
|
5
|
+
* SQL has already been built by the time a resolver wrapper could embed text.
|
|
6
|
+
* Instead, input fields that carry text to embed are tagged with an
|
|
7
|
+
* `EmbedRule` where they are declared, and `planTimeEmbedding` (run from a
|
|
8
|
+
* `finalize` hook) wraps the plan of every object field whose arguments can
|
|
9
|
+
* reach a tagged input field. The wrapper shadows `fieldArgs.getRaw` so every
|
|
10
|
+
* consumer of those arguments (connection-filter's `where` applyPlan, the
|
|
11
|
+
* create/update `args.apply`) reads a step that embeds the text first.
|
|
12
|
+
*/
|
|
13
|
+
import { access, ConstantStep, context, lambda, object } from 'grafast';
|
|
14
|
+
import { llmConfigStore } from './embedder';
|
|
15
|
+
export const EMBED_EXT = 'graphileLlmEmbed';
|
|
16
|
+
export function embedField(rule, spec) {
|
|
17
|
+
return { ...spec, extensions: { ...spec.extensions, [EMBED_EXT]: rule } };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `build.llmEmbedder` resolves to `{ embedding }` from the providers and to
|
|
21
|
+
* `number[] | null` once the metering plugin has wrapped it.
|
|
22
|
+
*/
|
|
23
|
+
export function toVectorEmbedder(raw) {
|
|
24
|
+
if (typeof raw !== 'function')
|
|
25
|
+
return null;
|
|
26
|
+
return async (text) => {
|
|
27
|
+
const result = await raw(text);
|
|
28
|
+
const vector = Array.isArray(result) ? result : result?.embedding;
|
|
29
|
+
return Array.isArray(vector) && vector.length > 0 ? vector : null;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function isNonEmptyVector(value) {
|
|
33
|
+
return Array.isArray(value) && value.length > 0;
|
|
34
|
+
}
|
|
35
|
+
export async function resolveConfigOverrides(graphqlContext) {
|
|
36
|
+
const ctx = graphqlContext?.constructive;
|
|
37
|
+
if (!ctx?.useLlm)
|
|
38
|
+
return null;
|
|
39
|
+
try {
|
|
40
|
+
const llm = await ctx.useLlm();
|
|
41
|
+
if (!llm?.embeddingModel)
|
|
42
|
+
return null;
|
|
43
|
+
return {
|
|
44
|
+
embeddingModel: llm.embeddingModel,
|
|
45
|
+
embeddingBaseUrl: llm.embeddingBaseUrl,
|
|
46
|
+
chatModel: llm.chatModel ?? undefined,
|
|
47
|
+
chatBaseUrl: llm.chatBaseUrl ?? undefined
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Binds the embedder to one request's per-database model overrides, resolved on first use. */
|
|
55
|
+
function forRequest(embedder, graphqlContext) {
|
|
56
|
+
if (!embedder)
|
|
57
|
+
return null;
|
|
58
|
+
let overrides;
|
|
59
|
+
return async (text) => {
|
|
60
|
+
overrides ??= resolveConfigOverrides(graphqlContext);
|
|
61
|
+
const resolved = await overrides;
|
|
62
|
+
const startTime = Date.now();
|
|
63
|
+
const vector = await llmConfigStore.run(resolved, () => embedder(text));
|
|
64
|
+
if (vector) {
|
|
65
|
+
console.log(`[graphile-llm] embed: dims=${vector.length}, latency=${Date.now() - startTime}ms`);
|
|
66
|
+
}
|
|
67
|
+
return vector;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function createEmbedIndex(graphql) {
|
|
71
|
+
const { getNamedType, isInputObjectType, isListType, isNonNullType } = graphql;
|
|
72
|
+
const reachesMemo = new WeakMap();
|
|
73
|
+
const inputObjectFields = (type) => Object.values(type.getFields());
|
|
74
|
+
const namedInputObject = (field) => {
|
|
75
|
+
const named = getNamedType(field.type);
|
|
76
|
+
return isInputObjectType(named) ? named : null;
|
|
77
|
+
};
|
|
78
|
+
// Filter types are cyclic (AND/OR/NOT, forward and backward relations), so
|
|
79
|
+
// reachability is a fixed point over everything reachable from `type`; a
|
|
80
|
+
// depth-first walk that memoizes a provisional answer mid-cycle gets it wrong.
|
|
81
|
+
function reaches(type) {
|
|
82
|
+
const start = getNamedType(type);
|
|
83
|
+
if (!isInputObjectType(start))
|
|
84
|
+
return false;
|
|
85
|
+
const known = reachesMemo.get(start);
|
|
86
|
+
if (known !== undefined)
|
|
87
|
+
return known;
|
|
88
|
+
const pending = [];
|
|
89
|
+
const stack = [start];
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
while (stack.length > 0) {
|
|
92
|
+
const node = stack.pop();
|
|
93
|
+
if (seen.has(node) || reachesMemo.has(node))
|
|
94
|
+
continue;
|
|
95
|
+
seen.add(node);
|
|
96
|
+
pending.push(node);
|
|
97
|
+
for (const field of inputObjectFields(node)) {
|
|
98
|
+
const child = namedInputObject(field);
|
|
99
|
+
if (child)
|
|
100
|
+
stack.push(child);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const hit = new Set(pending.filter((node) => inputObjectFields(node).some((f) => f.extensions?.[EMBED_EXT])));
|
|
104
|
+
const isHit = (node) => hit.has(node) || reachesMemo.get(node) === true;
|
|
105
|
+
let changed = true;
|
|
106
|
+
while (changed) {
|
|
107
|
+
changed = false;
|
|
108
|
+
for (const node of pending) {
|
|
109
|
+
if (hit.has(node))
|
|
110
|
+
continue;
|
|
111
|
+
if (inputObjectFields(node).some((f) => {
|
|
112
|
+
const child = namedInputObject(f);
|
|
113
|
+
return child !== null && isHit(child);
|
|
114
|
+
})) {
|
|
115
|
+
hit.add(node);
|
|
116
|
+
changed = true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const node of pending)
|
|
121
|
+
reachesMemo.set(node, hit.has(node));
|
|
122
|
+
return reachesMemo.get(start);
|
|
123
|
+
}
|
|
124
|
+
async function applyRule(rule, fieldName, typeName, value, next, embed) {
|
|
125
|
+
const text = value[fieldName];
|
|
126
|
+
switch (rule.kind) {
|
|
127
|
+
case 'nearby': {
|
|
128
|
+
delete next[fieldName];
|
|
129
|
+
if (isNonEmptyVector(value[rule.into]))
|
|
130
|
+
return;
|
|
131
|
+
if (typeof text !== 'string') {
|
|
132
|
+
throw new Error(`${typeName}: provide either \`${rule.into}\` or \`${fieldName}\`.`);
|
|
133
|
+
}
|
|
134
|
+
if (!embed)
|
|
135
|
+
return;
|
|
136
|
+
const vector = await embed(text);
|
|
137
|
+
if (vector) {
|
|
138
|
+
next[rule.into] = vector;
|
|
139
|
+
}
|
|
140
|
+
else if (rule.onQuotaExceeded === 'throw') {
|
|
141
|
+
throw new Error(`${typeName}: embedding failed (quota exceeded or provider unavailable). ` +
|
|
142
|
+
'Upgrade your plan or adjust onQuotaExceeded to \'degrade\' for graceful fallback.');
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
case 'unified': {
|
|
147
|
+
if (typeof text !== 'string' || text.trim().length === 0 || !embed)
|
|
148
|
+
return;
|
|
149
|
+
const vector = await embed(text);
|
|
150
|
+
if (vector) {
|
|
151
|
+
next[fieldName] = { __text: text, __vector: vector };
|
|
152
|
+
}
|
|
153
|
+
else if (rule.onQuotaExceeded === 'throw') {
|
|
154
|
+
throw new Error(`${fieldName}: embedding failed (quota exceeded or provider unavailable). ` +
|
|
155
|
+
'onQuotaExceeded is set to \'throw\'. ' +
|
|
156
|
+
'Upgrade your plan or adjust onQuotaExceeded to \'degrade\' for text-only fallback.');
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
case 'companion': {
|
|
161
|
+
delete next[fieldName];
|
|
162
|
+
if (isNonEmptyVector(value[rule.into]))
|
|
163
|
+
return;
|
|
164
|
+
if (typeof text !== 'string') {
|
|
165
|
+
if (rule.required) {
|
|
166
|
+
throw new Error(`${typeName}: provide either \`${rule.into}\` or \`${fieldName}\`.`);
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (!embed) {
|
|
171
|
+
throw new Error(`EMBED_NOT_CONFIGURED: Cannot embed ${fieldName} — no embedding provider configured. ` +
|
|
172
|
+
'Set defaultEmbedder in GraphileLlmPreset options or EMBEDDER_PROVIDER env var.');
|
|
173
|
+
}
|
|
174
|
+
const vector = await embed(text);
|
|
175
|
+
if (!vector) {
|
|
176
|
+
throw new Error(`EMBED_QUOTA_EXCEEDED: Cannot embed ${fieldName} — embedding quota exceeded. ` +
|
|
177
|
+
'Upgrade your plan or wait for the next billing period.');
|
|
178
|
+
}
|
|
179
|
+
next[rule.into] = vector;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Returns `value` itself when its type cannot reach a tagged field. */
|
|
185
|
+
async function embedValue(type, value, embed) {
|
|
186
|
+
if (value == null)
|
|
187
|
+
return value;
|
|
188
|
+
const inner = isNonNullType(type) ? type.ofType : type;
|
|
189
|
+
if (isListType(inner)) {
|
|
190
|
+
if (!Array.isArray(value) || !reaches(inner.ofType))
|
|
191
|
+
return value;
|
|
192
|
+
return Promise.all(value.map((item) => embedValue(inner.ofType, item, embed)));
|
|
193
|
+
}
|
|
194
|
+
if (!isInputObjectType(inner) || !reaches(inner))
|
|
195
|
+
return value;
|
|
196
|
+
const next = { ...value };
|
|
197
|
+
const jobs = [];
|
|
198
|
+
for (const [name, field] of Object.entries(inner.getFields())) {
|
|
199
|
+
const rule = field.extensions?.[EMBED_EXT];
|
|
200
|
+
if (rule) {
|
|
201
|
+
jobs.push(applyRule(rule, name, inner.name, value, next, embed));
|
|
202
|
+
}
|
|
203
|
+
else if (value[name] != null && reaches(field.type)) {
|
|
204
|
+
jobs.push(embedValue(field.type, value[name], embed).then((embedded) => { next[name] = embedded; }));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
await Promise.all(jobs);
|
|
208
|
+
return next;
|
|
209
|
+
}
|
|
210
|
+
return { reaches, embedValue };
|
|
211
|
+
}
|
|
212
|
+
function withEmbeddedArgs(originalPlan, argNames, embeddable, index, embedder) {
|
|
213
|
+
return function planWithEmbeddedArgs(parent, fieldArgs, info) {
|
|
214
|
+
const originalGetRaw = fieldArgs.getRaw;
|
|
215
|
+
const roots = new Map();
|
|
216
|
+
const rootStep = (argName) => {
|
|
217
|
+
let $root = roots.get(argName);
|
|
218
|
+
if ($root)
|
|
219
|
+
return $root;
|
|
220
|
+
const $raw = originalGetRaw.call(fieldArgs, argName);
|
|
221
|
+
const absent = $raw instanceof ConstantStep && $raw.data === undefined;
|
|
222
|
+
if (absent || !embeddable.has(argName)) {
|
|
223
|
+
$root = $raw;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
const type = fieldArgs.typeAt(argName);
|
|
227
|
+
$root = $raw.withLayerPlan(() => lambda({ raw: $raw, ctx: context() }, ({ raw, ctx }) => index.embedValue(type, raw, forRequest(embedder, ctx))));
|
|
228
|
+
}
|
|
229
|
+
roots.set(argName, $root);
|
|
230
|
+
return $root;
|
|
231
|
+
};
|
|
232
|
+
// grafast reads `fieldArgs.getRaw` through the object (apply, processAfter,
|
|
233
|
+
// `$arg` getters, getBaked), so shadowing it here reaches every consumer.
|
|
234
|
+
fieldArgs.getRaw = function getRawEmbedded(path) {
|
|
235
|
+
if (path === undefined) {
|
|
236
|
+
const steps = Object.fromEntries(argNames.map((n) => [n, rootStep(n)]));
|
|
237
|
+
return steps[argNames[0]].withLayerPlan(() => object(steps));
|
|
238
|
+
}
|
|
239
|
+
const [argName, ...rest] = typeof path === 'string' ? [path] : path;
|
|
240
|
+
if (!embeddable.has(argName))
|
|
241
|
+
return originalGetRaw.call(fieldArgs, path);
|
|
242
|
+
const $root = rootStep(argName);
|
|
243
|
+
return (rest.length === 0 ? $root : $root.withLayerPlan(() => access($root, rest)));
|
|
244
|
+
};
|
|
245
|
+
return originalPlan(parent, fieldArgs, info);
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const processedSchemas = new WeakSet();
|
|
249
|
+
/**
|
|
250
|
+
* Wraps the plan of every object field whose arguments reach a tagged input
|
|
251
|
+
* field. Runs in `finalize` because args are attached after the
|
|
252
|
+
* `GraphQLObjectType_fields_field` hook. Idempotent per schema, so every plugin
|
|
253
|
+
* that tags fields can call it without depending on the others being loaded.
|
|
254
|
+
*/
|
|
255
|
+
export function planTimeEmbedding(schema, build) {
|
|
256
|
+
if (processedSchemas.has(schema))
|
|
257
|
+
return schema;
|
|
258
|
+
processedSchemas.add(schema);
|
|
259
|
+
const { graphql } = build;
|
|
260
|
+
const index = createEmbedIndex(graphql);
|
|
261
|
+
const embedder = toVectorEmbedder(build.llmEmbedder);
|
|
262
|
+
for (const type of Object.values(schema.getTypeMap())) {
|
|
263
|
+
if (!graphql.isObjectType(type) || type.name.startsWith('__'))
|
|
264
|
+
continue;
|
|
265
|
+
for (const field of Object.values(type.getFields())) {
|
|
266
|
+
const grafastExt = field.extensions?.grafast;
|
|
267
|
+
if (typeof grafastExt?.plan !== 'function')
|
|
268
|
+
continue;
|
|
269
|
+
const embeddable = new Set(field.args.filter((a) => index.reaches(a.type)).map((a) => a.name));
|
|
270
|
+
if (embeddable.size === 0)
|
|
271
|
+
continue;
|
|
272
|
+
const argNames = field.args.map((a) => a.name);
|
|
273
|
+
grafastExt.plan = withEmbeddedArgs(grafastExt.plan, argNames, embeddable, index, embedder);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return schema;
|
|
277
|
+
}
|
package/esm/embedder.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* embeddings. Currently supports Ollama via @agentic-kit/ollama.
|
|
6
6
|
*
|
|
7
7
|
* Per-request model/baseUrl overrides are read from AsyncLocalStorage
|
|
8
|
-
* (`llmConfigStore`).
|
|
8
|
+
* (`llmConfigStore`). embed-inputs.ts sets these overrides from
|
|
9
9
|
* the database's `llm_module` config via `ctx.useLlm()`. This means
|
|
10
10
|
* the same embedder function — and its metering wrapper — handle every
|
|
11
11
|
* request, regardless of which tenant's model config applies.
|
|
@@ -25,7 +25,7 @@ export interface LlmConfigOverrides {
|
|
|
25
25
|
/**
|
|
26
26
|
* AsyncLocalStorage for per-request LLM config overrides.
|
|
27
27
|
*
|
|
28
|
-
* Set by the
|
|
28
|
+
* Set by the plan-time embedding step in embed-inputs.ts (from ctx.useLlm()),
|
|
29
29
|
* read by the embedder at call time. The metering wrapper sits between
|
|
30
30
|
* them and is completely unaware — it just wraps the same function.
|
|
31
31
|
*/
|
package/esm/embedder.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* embeddings. Currently supports Ollama via @agentic-kit/ollama.
|
|
6
6
|
*
|
|
7
7
|
* Per-request model/baseUrl overrides are read from AsyncLocalStorage
|
|
8
|
-
* (`llmConfigStore`).
|
|
8
|
+
* (`llmConfigStore`). embed-inputs.ts sets these overrides from
|
|
9
9
|
* the database's `llm_module` config via `ctx.useLlm()`. This means
|
|
10
10
|
* the same embedder function — and its metering wrapper — handle every
|
|
11
11
|
* request, regardless of which tenant's model config applies.
|
|
@@ -16,7 +16,7 @@ import { getLlmEnvOptions } from './env';
|
|
|
16
16
|
/**
|
|
17
17
|
* AsyncLocalStorage for per-request LLM config overrides.
|
|
18
18
|
*
|
|
19
|
-
* Set by the
|
|
19
|
+
* Set by the plan-time embedding step in embed-inputs.ts (from ctx.useLlm()),
|
|
20
20
|
* read by the embedder at call time. The metering wrapper sits between
|
|
21
21
|
* them and is completely unaware — it just wraps the same function.
|
|
22
22
|
*/
|
package/esm/index.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export { createLlmModulePlugin } from './plugins/llm-module-plugin';
|
|
|
36
36
|
export { createLlmRagPlugin } from './plugins/rag-plugin';
|
|
37
37
|
export { createLlmTextMutationPlugin } from './plugins/text-mutation-plugin';
|
|
38
38
|
export { createLlmTextSearchPlugin, embedTextInWhere } from './plugins/text-search-plugin';
|
|
39
|
+
export type { EmbedRule } from './embed-inputs';
|
|
40
|
+
export { embedField, planTimeEmbedding } from './embed-inputs';
|
|
39
41
|
export { createLlmMeteringPlugin } from './plugins/metering-plugin';
|
|
40
42
|
export type { AgentDiscovery, AgentTableInfo } from './plugins/agent-discovery-plugin';
|
|
41
43
|
export { clearAgentDiscoveryCache, getAgentDiscovery } from './plugins/agent-discovery-plugin';
|
package/esm/index.js
CHANGED
|
@@ -37,6 +37,7 @@ export { createLlmModulePlugin } from './plugins/llm-module-plugin';
|
|
|
37
37
|
export { createLlmRagPlugin } from './plugins/rag-plugin';
|
|
38
38
|
export { createLlmTextMutationPlugin } from './plugins/text-mutation-plugin';
|
|
39
39
|
export { createLlmTextSearchPlugin, embedTextInWhere } from './plugins/text-search-plugin';
|
|
40
|
+
export { embedField, planTimeEmbedding } from './embed-inputs';
|
|
40
41
|
// Metering plugin (opt-in billing integration)
|
|
41
42
|
export { createLlmMeteringPlugin } from './plugins/metering-plugin';
|
|
42
43
|
export { clearAgentDiscoveryCache, getAgentDiscovery } from './plugins/agent-discovery-plugin';
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*
|
|
18
18
|
* Per-database model/baseUrl overrides (from `llm_module` via `ctx.useLlm()`)
|
|
19
19
|
* are applied at request time via `llmConfigStore` (AsyncLocalStorage) in
|
|
20
|
-
* the
|
|
20
|
+
* the plan-time embedding step (embed-inputs.ts). The same embedder function — and
|
|
21
21
|
* its metering wrapper — handles every request; only the model name and
|
|
22
22
|
* base URL parameters change per-tenant.
|
|
23
23
|
*
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*
|
|
18
18
|
* Per-database model/baseUrl overrides (from `llm_module` via `ctx.useLlm()`)
|
|
19
19
|
* are applied at request time via `llmConfigStore` (AsyncLocalStorage) in
|
|
20
|
-
* the
|
|
20
|
+
* the plan-time embedding step (embed-inputs.ts). The same embedder function — and
|
|
21
21
|
* its metering wrapper — handles every request; only the model name and
|
|
22
22
|
* base URL parameters change per-tenant.
|
|
23
23
|
*
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
* filter/query-side text-to-vector). Together they let clients work entirely
|
|
18
18
|
* with text/prompts instead of raw float vectors.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* The text is embedded while the mutation is planned (see embed-inputs.ts),
|
|
21
|
+
* before the INSERT/UPDATE is built. On a create input for a NOT NULL vector
|
|
22
|
+
* column the vector field becomes optional, and one of `{col}` or `{col}Text`
|
|
23
|
+
* is required at execution time. If both are given, the vector is used.
|
|
23
24
|
*
|
|
24
25
|
* The companion fields are only added when the LLM plugin is loaded.
|
|
25
26
|
* If no embedder is configured, the fields are still registered for schema
|
|
@@ -39,10 +40,7 @@ declare global {
|
|
|
39
40
|
* Creates the LlmTextMutationPlugin.
|
|
40
41
|
*
|
|
41
42
|
* Hooks into GraphQLInputObjectType_fields for create/update input types
|
|
42
|
-
* and adds `{columnName}Text: String` for each vector column
|
|
43
|
-
*
|
|
44
|
-
* Also wraps mutation resolvers via GraphQLObjectType_fields_field to
|
|
45
|
-
* intercept `*Text` companion field values, embed them, and inject the
|
|
46
|
-
* resulting vectors before the mutation executes.
|
|
43
|
+
* and adds `{columnName}Text: String` for each vector column, tagged for
|
|
44
|
+
* plan-time embedding.
|
|
47
45
|
*/
|
|
48
46
|
export declare function createLlmTextMutationPlugin(): GraphileConfig.Plugin;
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
* filter/query-side text-to-vector). Together they let clients work entirely
|
|
18
18
|
* with text/prompts instead of raw float vectors.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* The text is embedded while the mutation is planned (see embed-inputs.ts),
|
|
21
|
+
* before the INSERT/UPDATE is built. On a create input for a NOT NULL vector
|
|
22
|
+
* column the vector field becomes optional, and one of `{col}` or `{col}Text`
|
|
23
|
+
* is required at execution time. If both are given, the vector is used.
|
|
23
24
|
*
|
|
24
25
|
* The companion fields are only added when the LLM plugin is loaded.
|
|
25
26
|
* If no embedder is configured, the fields are still registered for schema
|
|
@@ -27,51 +28,24 @@
|
|
|
27
28
|
*/
|
|
28
29
|
import 'graphile-build';
|
|
29
30
|
import 'graphile-build-pg';
|
|
31
|
+
import { embedField, planTimeEmbedding } from '../embed-inputs';
|
|
30
32
|
/**
|
|
31
33
|
* Check if a codec is the pgvector `vector` type.
|
|
32
34
|
*/
|
|
33
35
|
function isVectorCodec(codec) {
|
|
34
36
|
return codec?.name === 'vector';
|
|
35
37
|
}
|
|
36
|
-
/**
|
|
37
|
-
* Collect the text→vector field mapping for a codec's vector columns.
|
|
38
|
-
* Returns a map of textFieldName → vectorFieldName.
|
|
39
|
-
*/
|
|
40
|
-
function getTextToVectorMapping(pgCodec, build) {
|
|
41
|
-
const mapping = {};
|
|
42
|
-
if (!pgCodec?.attributes)
|
|
43
|
-
return mapping;
|
|
44
|
-
const attributeFieldNames = new Set(Object.keys(pgCodec.attributes).map((attributeName) => build.inflection.attribute({ codec: pgCodec, attributeName })));
|
|
45
|
-
for (const [attributeName, attribute] of Object.entries(pgCodec.attributes)) {
|
|
46
|
-
if (isVectorCodec(attribute.codec)) {
|
|
47
|
-
const fieldName = build.inflection.attribute({
|
|
48
|
-
codec: pgCodec,
|
|
49
|
-
attributeName
|
|
50
|
-
});
|
|
51
|
-
const textFieldName = `${fieldName}Text`;
|
|
52
|
-
// Skip when a physical column already inflects to the companion name
|
|
53
|
-
// (e.g. `embedding_text` next to `embedding`) — the physical field owns it.
|
|
54
|
-
if (attributeFieldNames.has(textFieldName))
|
|
55
|
-
continue;
|
|
56
|
-
mapping[textFieldName] = fieldName;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return mapping;
|
|
60
|
-
}
|
|
61
38
|
/**
|
|
62
39
|
* Creates the LlmTextMutationPlugin.
|
|
63
40
|
*
|
|
64
41
|
* Hooks into GraphQLInputObjectType_fields for create/update input types
|
|
65
|
-
* and adds `{columnName}Text: String` for each vector column
|
|
66
|
-
*
|
|
67
|
-
* Also wraps mutation resolvers via GraphQLObjectType_fields_field to
|
|
68
|
-
* intercept `*Text` companion field values, embed them, and inject the
|
|
69
|
-
* resulting vectors before the mutation executes.
|
|
42
|
+
* and adds `{columnName}Text: String` for each vector column, tagged for
|
|
43
|
+
* plan-time embedding.
|
|
70
44
|
*/
|
|
71
45
|
export function createLlmTextMutationPlugin() {
|
|
72
46
|
return {
|
|
73
47
|
name: 'LlmTextMutationPlugin',
|
|
74
|
-
version: '0.
|
|
48
|
+
version: '0.3.0',
|
|
75
49
|
description: 'Adds text companion fields on mutation inputs for vector columns — ' +
|
|
76
50
|
'text is embedded server-side before storing',
|
|
77
51
|
after: [
|
|
@@ -106,7 +80,7 @@ export function createLlmTextMutationPlugin() {
|
|
|
106
80
|
if (!hasExplicitMutationScope && (!looksLikeMutationInput || isFilterOrCondition)) {
|
|
107
81
|
return fields;
|
|
108
82
|
}
|
|
109
|
-
const { graphql: { GraphQLString } } = build;
|
|
83
|
+
const { graphql: { GraphQLString, getNullableType, isNonNullType } } = build;
|
|
110
84
|
// Find vector columns on this table
|
|
111
85
|
const vectorColumns = [];
|
|
112
86
|
for (const [attributeName, attribute] of Object.entries(pgCodec.attributes)) {
|
|
@@ -131,94 +105,27 @@ export function createLlmTextMutationPlugin() {
|
|
|
131
105
|
if (textFieldName in newFields) {
|
|
132
106
|
continue;
|
|
133
107
|
}
|
|
108
|
+
const vectorField = newFields[fieldName];
|
|
109
|
+
const required = !!vectorField && isNonNullType(vectorField.type);
|
|
110
|
+
if (required) {
|
|
111
|
+
newFields = {
|
|
112
|
+
...newFields,
|
|
113
|
+
[fieldName]: { ...vectorField, type: getNullableType(vectorField.type) }
|
|
114
|
+
};
|
|
115
|
+
}
|
|
134
116
|
newFields = build.extend(newFields, {
|
|
135
|
-
[textFieldName]: {
|
|
117
|
+
[textFieldName]: embedField({ kind: 'companion', into: fieldName, required }, {
|
|
136
118
|
type: GraphQLString,
|
|
137
119
|
description: `Natural language text to embed server-side into the \`${fieldName}\` vector column. ` +
|
|
138
|
-
`
|
|
120
|
+
`Provide \`${fieldName}\` or \`${textFieldName}\`; if both are given, \`${fieldName}\` is used. ` +
|
|
139
121
|
'Requires the LLM plugin to be configured with an embedding provider.'
|
|
140
|
-
}
|
|
122
|
+
})
|
|
141
123
|
}, `LlmTextMutationPlugin adding ${textFieldName} companion field for vector column '${columnName}'`);
|
|
142
124
|
}
|
|
143
125
|
return newFields;
|
|
144
126
|
},
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
* field values, embed them using the configured embedder, and inject
|
|
148
|
-
* the resulting vector into the corresponding vector field.
|
|
149
|
-
*
|
|
150
|
-
* Uses the same v4-style resolver wrapping pattern as graphile-upload-plugin
|
|
151
|
-
* and graphile-bucket-provisioner-plugin. grafserv v5 supports this through
|
|
152
|
-
* its backwards-compatibility layer.
|
|
153
|
-
*
|
|
154
|
-
* If the embedder returns null (e.g. quota exceeded), throws an error.
|
|
155
|
-
*/
|
|
156
|
-
GraphQLObjectType_fields_field(field, build, context) {
|
|
157
|
-
const { scope: { isRootMutation, fieldName, pgCodec } } = context;
|
|
158
|
-
// Only wrap root mutation fields on tables with attributes
|
|
159
|
-
if (!isRootMutation || !pgCodec || !pgCodec.attributes) {
|
|
160
|
-
return field;
|
|
161
|
-
}
|
|
162
|
-
// Only wrap create/update mutations
|
|
163
|
-
const isCreate = fieldName.startsWith('create');
|
|
164
|
-
const isUpdate = fieldName.startsWith('update');
|
|
165
|
-
if (!isCreate && !isUpdate) {
|
|
166
|
-
return field;
|
|
167
|
-
}
|
|
168
|
-
// Build the text→vector mapping for this codec
|
|
169
|
-
const textToVectorMap = getTextToVectorMapping(pgCodec, build);
|
|
170
|
-
if (Object.keys(textToVectorMap).length === 0) {
|
|
171
|
-
return field;
|
|
172
|
-
}
|
|
173
|
-
const embedder = build.llmEmbedder;
|
|
174
|
-
const defaultResolver = (obj) => obj[fieldName];
|
|
175
|
-
const { resolve: oldResolve = defaultResolver, ...rest } = field;
|
|
176
|
-
return {
|
|
177
|
-
...rest,
|
|
178
|
-
async resolve(source, args, graphqlContext, info) {
|
|
179
|
-
// Walk through the input args and embed any *Text companion fields
|
|
180
|
-
async function embedTextFields(obj) {
|
|
181
|
-
if (!obj || typeof obj !== 'object')
|
|
182
|
-
return;
|
|
183
|
-
const pending = [];
|
|
184
|
-
for (const key of Object.keys(obj)) {
|
|
185
|
-
const value = obj[key];
|
|
186
|
-
// Check if this key is a *Text companion field
|
|
187
|
-
if (key in textToVectorMap && typeof value === 'string') {
|
|
188
|
-
const vectorFieldName = textToVectorMap[key];
|
|
189
|
-
pending.push((async () => {
|
|
190
|
-
if (!embedder) {
|
|
191
|
-
throw new Error(`EMBED_NOT_CONFIGURED: Cannot embed ${key} — no embedding provider configured. ` +
|
|
192
|
-
'Set defaultEmbedder in GraphileLlmPreset options or EMBEDDER_PROVIDER env var.');
|
|
193
|
-
}
|
|
194
|
-
const startTime = Date.now();
|
|
195
|
-
const vector = await embedder(value);
|
|
196
|
-
const latencyMs = Date.now() - startTime;
|
|
197
|
-
if (vector === null) {
|
|
198
|
-
throw new Error(`EMBED_QUOTA_EXCEEDED: Cannot embed ${key} — embedding quota exceeded. ` +
|
|
199
|
-
'Upgrade your plan or wait for the next billing period.');
|
|
200
|
-
}
|
|
201
|
-
console.log(`[graphile-llm] Mutation embed: field=${key}, dims=${vector.length}, latency=${latencyMs}ms`);
|
|
202
|
-
// Inject the vector into the corresponding field
|
|
203
|
-
obj[vectorFieldName] = vector;
|
|
204
|
-
// Remove the consumed *Text field
|
|
205
|
-
delete obj[key];
|
|
206
|
-
})());
|
|
207
|
-
continue;
|
|
208
|
-
}
|
|
209
|
-
// Recurse into nested objects (e.g. input.article.embeddingText)
|
|
210
|
-
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
211
|
-
pending.push(embedTextFields(value));
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
if (pending.length > 0) {
|
|
215
|
-
await Promise.all(pending);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
await embedTextFields(args);
|
|
219
|
-
return oldResolve(source, args, graphqlContext, info);
|
|
220
|
-
}
|
|
221
|
-
};
|
|
127
|
+
finalize(schema, build) {
|
|
128
|
+
return planTimeEmbedding(schema, build);
|
|
222
129
|
}
|
|
223
130
|
}
|
|
224
131
|
}
|
|
@@ -10,24 +10,21 @@
|
|
|
10
10
|
* accepts a compound input (point + distance) and the plugin handles
|
|
11
11
|
* the conversion to SQL internally.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Clients provide `text` or `vector`; `vector` becomes optional. When both
|
|
14
|
+
* are given the vector wins. `unifiedSearch` text is embedded too, so pgvector
|
|
15
|
+
* participates alongside the text adapters.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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.
|
|
17
|
+
* The text is embedded while the operation is planned (see embed-inputs.ts),
|
|
18
|
+
* before SQL is built.
|
|
22
19
|
*
|
|
23
|
-
* If
|
|
24
|
-
*
|
|
20
|
+
* If no embedder is configured, `text` is ignored and `unifiedSearch` runs
|
|
21
|
+
* on the text adapters only.
|
|
25
22
|
*
|
|
26
23
|
* If the embedder returns null (e.g. quota exceeded when the metering
|
|
27
24
|
* plugin is loaded), behavior depends on `onQuotaExceeded`:
|
|
28
|
-
* - `'degrade'` (default):
|
|
29
|
-
*
|
|
30
|
-
* - `'throw'`:
|
|
25
|
+
* - `'degrade'` (default): drops the text and continues without the vector
|
|
26
|
+
* filter (text-only search for `unifiedSearch`).
|
|
27
|
+
* - `'throw'`: throws an error.
|
|
31
28
|
*/
|
|
32
29
|
import type { GraphileConfig } from 'graphile-config';
|
|
33
30
|
declare global {
|
|
@@ -43,14 +40,18 @@ declare global {
|
|
|
43
40
|
*
|
|
44
41
|
* If the embedder returns null (e.g. quota exceeded), the text field is
|
|
45
42
|
* removed so the pgvector filter is skipped — graceful text-only fallback.
|
|
43
|
+
*
|
|
44
|
+
* @deprecated The plugins no longer call this: under Grafast a resolver runs
|
|
45
|
+
* after the plan's SQL, so embedding there is too late. They embed at plan
|
|
46
|
+
* time through `embedField` and `planTimeEmbedding` instead.
|
|
46
47
|
*/
|
|
47
48
|
export declare function embedTextInWhere(obj: any, embedder: (text: string) => Promise<number[] | null>, hasTextAdapters: boolean, onQuotaExceeded?: 'degrade' | 'throw'): Promise<void>;
|
|
48
49
|
/**
|
|
49
50
|
* Creates the LlmTextSearchPlugin.
|
|
50
51
|
*
|
|
51
52
|
* Hooks into VectorNearbyInput to add a `text` field alongside the
|
|
52
|
-
* existing `vector` field
|
|
53
|
-
*
|
|
53
|
+
* existing `vector` field, and tags `text` and `unifiedSearch` for
|
|
54
|
+
* plan-time embedding.
|
|
54
55
|
*/
|
|
55
56
|
export declare function createLlmTextSearchPlugin(options?: {
|
|
56
57
|
onQuotaExceeded?: 'degrade' | 'throw';
|