Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe('unifiedSearch embedding integration', () => {

await expect(
embedTextInWhere(where, nullEmbedder, false)
).rejects.toThrow('embedding quota exceeded');
).rejects.toThrow('embedding failed');
});

it('does not embed empty unifiedSearch strings', async () => {
Expand Down Expand Up @@ -134,6 +134,60 @@ describe('unifiedSearch embedding integration', () => {
});
});

describe('onQuotaExceeded option', () => {
it('degrade mode (default): leaves unifiedSearch as string when embedder returns null', async () => {
const where = { unifiedSearch: 'some query' };
await embedTextInWhere(where, nullEmbedder, true, 'degrade');

expect(where.unifiedSearch).toBe('some query');
});

it('throw mode: throws on unifiedSearch when embedder returns null even with text adapters', async () => {
const where = { unifiedSearch: 'some query' };

await expect(
embedTextInWhere(where, nullEmbedder, true, 'throw')
).rejects.toThrow('onQuotaExceeded is set to \'throw\'');
});

it('throw mode: throws on VectorNearbyInput.text when embedder returns null', async () => {
const where = { vectorEmbedding: { text: 'semantic query' } };

await expect(
embedTextInWhere(where, nullEmbedder, true, 'throw')
).rejects.toThrow('VectorNearbyInput: embedding failed');
});

it('degrade mode: silently removes VectorNearbyInput.text when embedder returns null', async () => {
const where = { vectorEmbedding: { text: 'failed query' } };
await embedTextInWhere(where, nullEmbedder, true, 'degrade');

expect(where.vectorEmbedding).toEqual({});
});

it('throw mode: still works normally when embedder succeeds', async () => {
const where = { unifiedSearch: 'good query' };
await embedTextInWhere(where, mockEmbedder, true, 'throw');

expect(where.unifiedSearch).toEqual({
__text: 'good query',
__vector: mockVector,
});
});

it('no text adapters: throws regardless of onQuotaExceeded setting', async () => {
const where1 = { unifiedSearch: 'query1' };
await expect(
embedTextInWhere(where1, nullEmbedder, false, 'degrade')
).rejects.toThrow('No text search adapters available');

const where2 = { unifiedSearch: 'query2' };
await expect(
embedTextInWhere(where2, nullEmbedder, false, 'throw')
).rejects.toThrow('No text search adapters available');
});
});

describe('apply function object shape handling', () => {
it('plugin.ts apply function handles { __text, __vector } correctly', () => {
// Simulate what the apply function does with the transformed value
Expand Down
41 changes: 26 additions & 15 deletions graphile/graphile-llm/src/plugins/text-search-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@
* (so the schema is stable) but will return a clear error at execution time.
*
* If the embedder returns null (e.g. quota exceeded when the metering
* plugin is loaded), the text field is silently removed — the query
* continues with text-only search as a graceful fallback.
* plugin is loaded), behavior depends on `onQuotaExceeded`:
* - `'degrade'` (default): silently removes the text field and continues
* with text-only search as a graceful fallback.
* - `'throw'`: always throws an error, even if text adapters could handle it.
*/

import type { GraphileConfig } from 'graphile-config';
Expand Down Expand Up @@ -62,7 +64,8 @@ function hasVectorColumns(pgCodec: any): boolean {
export async function embedTextInWhere(
obj: any,
embedder: (text: string) => Promise<number[] | null>,
hasTextAdapters: boolean
hasTextAdapters: boolean,
onQuotaExceeded: 'degrade' | 'throw' = 'degrade'
): Promise<void> {
if (!obj || typeof obj !== 'object') return;

Expand All @@ -79,15 +82,15 @@ export async function embedTextInWhere(
const latencyMs = Date.now() - startTime;

if (vector === null) {
// Embedder returned null (e.g. quota exceeded)
if (!hasTextAdapters) {
// No text adapters to fall back to — throw error
if (onQuotaExceeded === 'throw' || !hasTextAdapters) {
throw new Error(
'unifiedSearch: embedding quota exceeded and no text search adapters available. ' +
'Upgrade your plan or add text search columns for graceful fallback.'
'unifiedSearch: embedding failed (quota exceeded or provider unavailable). ' +
(!hasTextAdapters
? 'No text search adapters available for fallback. '
: 'onQuotaExceeded is set to \'throw\'. ') +
'Upgrade your plan or adjust onQuotaExceeded to \'degrade\' for text-only fallback.'
);
}
// Graceful degradation: leave as plain string, text adapters still work
return;
}

Expand All @@ -111,7 +114,12 @@ export async function embedTextInWhere(
const latencyMs = Date.now() - startTime;

if (vector === null) {
// Embedder returned null (e.g. quota exceeded) — skip vector search
if (onQuotaExceeded === 'throw') {
throw new Error(
'VectorNearbyInput: embedding failed (quota exceeded or provider unavailable). ' +
'Upgrade your plan or adjust onQuotaExceeded to \'degrade\' for graceful fallback.'
);
}
delete value.text;
return;
}
Expand All @@ -129,11 +137,11 @@ export async function embedTextInWhere(

// Recurse into nested filter objects (AND, OR, etc.)
if (!Array.isArray(value)) {
pending.push(embedTextInWhere(value, embedder, hasTextAdapters));
pending.push(embedTextInWhere(value, embedder, hasTextAdapters, onQuotaExceeded));
} else {
// Handle arrays (e.g. AND: [...], OR: [...])
for (const item of value) {
pending.push(embedTextInWhere(item, embedder, hasTextAdapters));
pending.push(embedTextInWhere(item, embedder, hasTextAdapters, onQuotaExceeded));
}
}
}
Expand All @@ -150,7 +158,10 @@ export async function embedTextInWhere(
* existing `vector` field. When a user provides `text`, the plugin's
* resolver wrapper embeds it before passing to pgvector.
*/
export function createLlmTextSearchPlugin(): GraphileConfig.Plugin {
export function createLlmTextSearchPlugin(
options: { onQuotaExceeded?: 'degrade' | 'throw' } = {}
): GraphileConfig.Plugin {
const { onQuotaExceeded = 'degrade' } = options;
return {
name: 'LlmTextSearchPlugin',
version: '0.2.0',
Expand Down Expand Up @@ -243,12 +254,12 @@ export function createLlmTextSearchPlugin(): GraphileConfig.Plugin {
async resolve(source: any, args: any, graphqlContext: any, info: any) {
// If the query has a `where` argument, check for text/unifiedSearch fields
if (args?.where) {
await embedTextInWhere(args.where, embedder, !!hasTextAdapters);
await embedTextInWhere(args.where, embedder, !!hasTextAdapters, onQuotaExceeded);
}

// Also handle `filter` for relay-style connections
if (args?.filter) {
await embedTextInWhere(args.filter, embedder, !!hasTextAdapters);
await embedTextInWhere(args.filter, embedder, !!hasTextAdapters, onQuotaExceeded);
}

return oldResolve(source, args, graphqlContext, info);
Expand Down
14 changes: 13 additions & 1 deletion graphile/graphile-llm/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@
* })
* ```
*
* @example With strict quota enforcement (fail if embedding unavailable):
* ```typescript
* GraphileLlmPreset({
* defaultEmbedder: { provider: 'openai', model: 'text-embedding-3-small' },
* metering: true,
* onQuotaExceeded: 'throw',
* // → if billing quota is exceeded, queries with unifiedSearch throw an error
* // → without this, queries silently fall back to text-only search
* })
* ```
*
* @example With custom entity_id resolution (bill per-database):
* ```typescript
* GraphileLlmPreset({
Expand Down Expand Up @@ -86,6 +97,7 @@ export function GraphileLlmPreset(
enableTextSearch = true,
enableTextMutations = true,
enableRag = false,
onQuotaExceeded,
ragDefaults,
metering
} = options;
Expand All @@ -102,7 +114,7 @@ export function GraphileLlmPreset(
}

if (enableTextSearch) {
plugins.push(createLlmTextSearchPlugin());
plugins.push(createLlmTextSearchPlugin({ onQuotaExceeded }));
}

if (enableTextMutations) {
Expand Down
14 changes: 14 additions & 0 deletions graphile/graphile-llm/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,20 @@ export interface GraphileLlmOptions {
*/
enableTextSearch?: boolean;

/**
* Controls behavior when the embedder returns null (e.g. billing quota exceeded).
*
* - `'degrade'` (default): Skip the vector search silently and fall back to
* text-only adapters (tsvector, BM25, trgm). If no text adapters are
* available, throws an error regardless of this setting.
* - `'throw'`: Always throw an error when the embedder fails, even if
* text adapters could handle the query. Use this when semantic search
* is critical and keyword-only results are unacceptable.
*
* @default 'degrade'
*/
onQuotaExceeded?: 'degrade' | 'throw';

/**
* Whether to add `*Text` companion fields on mutation inputs for vector columns.
* @default true
Expand Down
Loading