Skip to content

Commit 976f626

Browse files
committed
feat: add graphile-llm plugin — server-side text-to-vector embedding for PostGraphile
Foundation bundle of the graphile-llm plugin: - Embedder abstraction with provider resolution (@agentic-kit/ollama) - LlmModulePlugin: resolves embedder from llm_module api_modules config, env vars, or preset options - LlmTextSearchPlugin: adds text field to VectorNearbyInput for text-based vector search - LlmTextMutationPlugin: adds *Text companion fields on mutation inputs for vector columns - GraphileLlmPreset: bundles all plugins with configurable options - Debug logging for token counts (metering deferred to billing system) Refs: constructive-io/constructive-planning#743
1 parent b421fce commit 976f626

12 files changed

Lines changed: 3383 additions & 7446 deletions

File tree

graphile/graphile-llm/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# graphile-llm
2+
3+
LLM integration plugin for PostGraphile v5. Provides server-side text-to-vector embedding for pgvector columns.
4+
5+
## Features
6+
7+
- **Text-based vector search**: Adds `text: String` field to `VectorNearbyInput` — clients pass natural language instead of raw float vectors
8+
- **Text mutation fields**: Adds `{column}Text: String` companion fields on create/update inputs for vector columns
9+
- **Pluggable embedders**: Provider-based architecture (Ollama via `@agentic-kit/ollama`, with room for OpenAI, etc.)
10+
- **Per-database configuration**: Reads `llm_module` from `services_public.api_modules` for per-API embedder config
11+
- **Plugin-conditional**: Fields only appear in the schema when the plugin is loaded
12+
13+
## Usage
14+
15+
```typescript
16+
import { GraphileLlmPreset } from 'graphile-llm';
17+
18+
const preset = {
19+
extends: [
20+
GraphileLlmPreset({
21+
defaultEmbedder: {
22+
provider: 'ollama',
23+
model: 'nomic-embed-text',
24+
baseUrl: 'http://localhost:11434',
25+
},
26+
}),
27+
],
28+
};
29+
```

graphile/graphile-llm/package.json

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
{
2+
"name": "graphile-llm",
3+
"version": "0.1.0",
4+
"description": "LLM integration plugin for PostGraphile v5 — server-side text-to-vector embedding and text companion fields for pgvector columns",
5+
"author": "Constructive <developers@constructive.io>",
6+
"homepage": "https://github.com/constructive-io/constructive",
7+
"license": "MIT",
8+
"main": "index.js",
9+
"module": "esm/index.js",
10+
"types": "index.d.ts",
11+
"scripts": {
12+
"clean": "makage clean",
13+
"prepack": "npm run build",
14+
"build": "makage build",
15+
"build:dev": "makage build --dev",
16+
"lint": "eslint . --fix",
17+
"test": "jest --forceExit",
18+
"test:watch": "jest --watch"
19+
},
20+
"publishConfig": {
21+
"access": "public",
22+
"directory": "dist"
23+
},
24+
"repository": {
25+
"type": "git",
26+
"url": "https://github.com/constructive-io/constructive"
27+
},
28+
"bugs": {
29+
"url": "https://github.com/constructive-io/constructive/issues"
30+
},
31+
"dependencies": {
32+
"@agentic-kit/ollama": "^1.0.3"
33+
},
34+
"peerDependencies": {
35+
"@dataplan/pg": "1.0.0",
36+
"grafast": "1.0.0",
37+
"graphile-build": "5.0.0",
38+
"graphile-build-pg": "5.0.0",
39+
"graphile-config": "1.0.0",
40+
"graphile-search": "workspace:^",
41+
"graphql": "16.13.0",
42+
"pg-sql2": "5.0.0",
43+
"postgraphile": "5.0.0"
44+
},
45+
"peerDependenciesMeta": {
46+
"graphile-search": {
47+
"optional": true
48+
}
49+
},
50+
"devDependencies": {
51+
"@types/node": "^22.19.11",
52+
"makage": "^0.3.0"
53+
},
54+
"keywords": [
55+
"postgraphile",
56+
"graphile",
57+
"constructive",
58+
"plugin",
59+
"llm",
60+
"embedding",
61+
"pgvector",
62+
"rag",
63+
"agentic-kit",
64+
"ollama",
65+
"openai"
66+
]
67+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Embedder — pluggable text-to-vector embedding for the Graphile LLM plugin
3+
*
4+
* Provides a provider-based architecture for converting text into vector
5+
* embeddings. Currently supports Ollama via @agentic-kit/ollama.
6+
*
7+
* The embedder is resolved at request time from:
8+
* 1. The `llm_module` api_modules configuration (per-database)
9+
* 2. The preset's `defaultEmbedder` option (fallback for dev/testing)
10+
* 3. Environment variables (EMBEDDER_PROVIDER, EMBEDDER_MODEL, EMBEDDER_BASE_URL)
11+
*/
12+
13+
import OllamaClient from '@agentic-kit/ollama';
14+
import type { EmbedderConfig, EmbedderFunction, LlmModuleData } from './types';
15+
16+
// ─── Built-in Providers ─────────────────────────────────────────────────────
17+
18+
/**
19+
* Create an Ollama-based embedder function.
20+
*/
21+
function createOllamaEmbedder(
22+
baseUrl: string = 'http://localhost:11434',
23+
model: string = 'nomic-embed-text',
24+
): EmbedderFunction {
25+
const client = new OllamaClient(baseUrl);
26+
return async (text: string): Promise<number[]> => {
27+
return client.generateEmbedding(text, model);
28+
};
29+
}
30+
31+
// ─── Embedder Construction ──────────────────────────────────────────────────
32+
33+
/**
34+
* Build an embedder function from a config object.
35+
*
36+
* @returns An EmbedderFunction, or null if the provider is not recognized
37+
*/
38+
export function buildEmbedder(config: EmbedderConfig): EmbedderFunction | null {
39+
switch (config.provider) {
40+
case 'ollama':
41+
return createOllamaEmbedder(config.baseUrl, config.model);
42+
// Future: 'openai', 'anthropic', 'custom'
43+
default:
44+
return null;
45+
}
46+
}
47+
48+
// ─── Resolution from LLM Module ─────────────────────────────────────────────
49+
50+
/**
51+
* Build an embedder from an `llm_module` api_modules row.
52+
*
53+
* @param data - The llm_module data from services_public.api_modules
54+
* @returns An EmbedderFunction, or null if the provider is not supported
55+
*/
56+
export function buildEmbedderFromModule(data: LlmModuleData): EmbedderFunction | null {
57+
return buildEmbedder({
58+
provider: data.embedding_provider,
59+
model: data.embedding_model,
60+
baseUrl: data.embedding_base_url,
61+
apiKey: data.api_key_ref,
62+
});
63+
}
64+
65+
/**
66+
* Resolve an embedder from environment variables.
67+
* This is a fallback for development when no llm_module or defaultEmbedder is configured.
68+
*
69+
* Environment variables:
70+
* EMBEDDER_PROVIDER - Provider name ('ollama')
71+
* EMBEDDER_MODEL - Model identifier
72+
* EMBEDDER_BASE_URL - Provider base URL
73+
*/
74+
export function buildEmbedderFromEnv(): EmbedderFunction | null {
75+
const provider = process.env.EMBEDDER_PROVIDER;
76+
if (!provider) return null;
77+
78+
return buildEmbedder({
79+
provider,
80+
model: process.env.EMBEDDER_MODEL,
81+
baseUrl: process.env.EMBEDDER_BASE_URL,
82+
});
83+
}

graphile/graphile-llm/src/index.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* graphile-llm — LLM Integration Plugin for PostGraphile v5
3+
*
4+
* Server-side text-to-vector embedding and text companion fields for
5+
* pgvector columns. Moves the embedding logic from the client (CLI --auto-embed)
6+
* into the Graphile server layer so clients work with text/prompts instead
7+
* of raw float vectors.
8+
*
9+
* @example
10+
* ```typescript
11+
* import { GraphileLlmPreset } from 'graphile-llm';
12+
*
13+
* const preset = {
14+
* extends: [
15+
* GraphileLlmPreset({
16+
* defaultEmbedder: {
17+
* provider: 'ollama',
18+
* model: 'nomic-embed-text',
19+
* },
20+
* }),
21+
* ],
22+
* };
23+
* ```
24+
*/
25+
26+
// Preset (recommended entry point)
27+
export { GraphileLlmPreset } from './preset';
28+
29+
// Individual plugins
30+
export { createLlmModulePlugin } from './plugins/llm-module-plugin';
31+
export { createLlmTextSearchPlugin } from './plugins/text-search-plugin';
32+
export { createLlmTextMutationPlugin } from './plugins/text-mutation-plugin';
33+
34+
// Embedder utilities
35+
export {
36+
buildEmbedder,
37+
buildEmbedderFromModule,
38+
buildEmbedderFromEnv,
39+
} from './embedder';
40+
41+
// Types
42+
export type {
43+
EmbedderFunction,
44+
EmbedderConfig,
45+
LlmModuleData,
46+
GraphileLlmOptions,
47+
} from './types';
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* LlmModulePlugin
3+
*
4+
* Detects and loads the `llm_module` configuration from `services_public.api_modules`.
5+
* Makes the resolved embedder available to other plugins via the build context.
6+
*
7+
* This plugin is the foundation that enables per-database LLM configuration.
8+
* When an API has an `llm_module` configured, the embedder is resolved and
9+
* stored on the build object for other plugins (text search, text mutations)
10+
* to consume.
11+
*
12+
* Resolution order for the embedder:
13+
* 1. `llm_module` from api_modules (per-database, loaded at schema build time)
14+
* 2. `defaultEmbedder` from preset options (dev/testing fallback)
15+
* 3. Environment variables (EMBEDDER_PROVIDER, EMBEDDER_MODEL, EMBEDDER_BASE_URL)
16+
* 4. null — LLM features are disabled
17+
*/
18+
19+
import type { GraphileConfig } from 'graphile-config';
20+
import { buildEmbedder, buildEmbedderFromEnv } from '../embedder';
21+
import type { EmbedderConfig, EmbedderFunction, GraphileLlmOptions } from '../types';
22+
23+
// ─── TypeScript Augmentation ────────────────────────────────────────────────
24+
25+
declare global {
26+
namespace GraphileBuild {
27+
interface Build {
28+
/** The resolved embedder function, or null if LLM is not configured */
29+
llmEmbedder: EmbedderFunction | null;
30+
}
31+
}
32+
namespace GraphileConfig {
33+
interface Plugins {
34+
LlmModulePlugin: true;
35+
}
36+
}
37+
}
38+
39+
/**
40+
* Creates the LlmModulePlugin with the given options.
41+
*/
42+
export function createLlmModulePlugin(
43+
options: GraphileLlmOptions = {}
44+
): GraphileConfig.Plugin {
45+
const { defaultEmbedder } = options;
46+
47+
return {
48+
name: 'LlmModulePlugin',
49+
version: '0.1.0',
50+
description:
51+
'Resolves LLM embedder configuration and makes it available to other plugins',
52+
53+
schema: {
54+
hooks: {
55+
build(build) {
56+
// Resolve the embedder from available sources:
57+
// 1. Preset default embedder option
58+
// 2. Environment variables
59+
// 3. null (disabled)
60+
//
61+
// Note: Per-database llm_module resolution happens at request time,
62+
// not schema build time. The defaultEmbedder and env vars provide
63+
// the schema-build-time embedder so that text fields are registered
64+
// in the GraphQL schema. At execution time, the actual embedder
65+
// used may differ per-database based on the llm_module config.
66+
let embedder: EmbedderFunction | null = null;
67+
68+
if (defaultEmbedder) {
69+
embedder = buildEmbedder(defaultEmbedder);
70+
}
71+
72+
if (!embedder) {
73+
embedder = buildEmbedderFromEnv();
74+
}
75+
76+
if (embedder) {
77+
console.log('[graphile-llm] Embedder configured — LLM text fields will be enabled');
78+
} else {
79+
console.log(
80+
'[graphile-llm] No embedder configured. Set defaultEmbedder in preset options ' +
81+
'or EMBEDDER_PROVIDER env var to enable text-to-vector fields.'
82+
);
83+
}
84+
85+
return build.extend(build, {
86+
llmEmbedder: embedder,
87+
}, 'LlmModulePlugin adding llmEmbedder to build');
88+
},
89+
},
90+
},
91+
};
92+
}

0 commit comments

Comments
 (0)