feat: add graphile-llm plugin — embedding, RAG, and text-to-vector for PostGraphile#1008
Conversation
…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
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
…ment, Ollama integration Three test suites: 1. Embedder abstraction: unit tests for buildEmbedder, buildEmbedderFromModule, buildEmbedderFromEnv 2. Schema enrichment: verifies text field on VectorNearbyInput and embeddingText on mutation inputs (requires PostgreSQL + pgvector) 3. Real Ollama embedding: tests nomic-embed-text produces correct 768-dim vectors and semantic similarity (requires Ollama) Tests gracefully skip when PostgreSQL or Ollama are not available.
…tead of raw fetch - Tests now WILL fail if PostgreSQL or Ollama are unavailable (no more silent skipping) - Replaced raw fetch() calls with OllamaClient from @agentic-kit/ollama (listModels, pullModel, generateEmbedding) - Added direct OllamaClient.generateEmbedding test - Stricter assertions on mutation input types (expect defined, not soft if-checks)
| const { | ||
| scope: { | ||
| isPgRowType, | ||
| isPgPatch, | ||
| isPgBaseInput, | ||
| pgCodec, | ||
| }, | ||
| } = context as any; | ||
|
|
||
| // Only intercept create/update input types for table rows | ||
| if (!pgCodec?.attributes || (!isPgRowType && !isPgPatch && !isPgBaseInput)) { | ||
| return fields; | ||
| } |
There was a problem hiding this comment.
🔴 text-mutation-plugin uses isPgRowType instead of isMutationInput, deviating from established repo pattern
The scope-flag check at graphile/graphile-llm/src/plugins/text-mutation-plugin.ts:79 uses isPgRowType to identify mutation input types, but the established pattern throughout the repo uses isMutationInput. See graphile/graphile-upload-plugin/src/plugin.ts:184-193 and graphile/graphile-settings/src/plugins/required-input-plugin.ts:42-51, which both use isPgPatch || isPgBaseInput || isMutationInput. isPgRowType is the scope flag for output row types, not mutation input types. This means: (1) mutation input types that only have isMutationInput: true (but not isPgBaseInput or isPgPatch) will silently not receive text companion fields, and (2) if any input type has isPgRowType set on its scope, it would incorrectly receive companion fields.
| const { | |
| scope: { | |
| isPgRowType, | |
| isPgPatch, | |
| isPgBaseInput, | |
| pgCodec, | |
| }, | |
| } = context as any; | |
| // Only intercept create/update input types for table rows | |
| if (!pgCodec?.attributes || (!isPgRowType && !isPgPatch && !isPgBaseInput)) { | |
| return fields; | |
| } | |
| const { | |
| scope: { | |
| isPgPatch, | |
| isPgBaseInput, | |
| isMutationInput, | |
| pgCodec, | |
| }, | |
| } = context as any; | |
| // Only intercept create/update input types for table rows | |
| if (!pgCodec?.attributes || (!isPgPatch && !isPgBaseInput && !isMutationInput)) { | |
| return fields; | |
| } |
Was this helpful? React with 👍 or 👎 to provide feedback.
| teardown = connections.teardown; | ||
| query = connections.query; | ||
|
|
||
| await db.client.query('BEGIN'); |
There was a problem hiding this comment.
🟡 Test uses manual BEGIN/ROLLBACK that interferes with pgsql-test savepoint-based transaction management
The test suite at graphile/graphile-llm/src/__tests__/graphile-llm.test.ts:167 calls await db.client.query('BEGIN') in beforeAll and await db.client.query('ROLLBACK') in afterAll, while also using db.beforeEach()/db.afterEach(). The db.beforeEach() internally calls this.begin() (postgres/pgsql-client/src/client.ts:53-55), which issues a second BEGIN inside the already-open transaction, generating a PostgreSQL warning. The first db.afterEach() call then COMMITs the outer manual transaction, making the ROLLBACK in afterAll a no-op (no open transaction). The canonical graphile-test pattern (see graphile/graphile-test/__tests__/graphile-test.test.ts:35-37) does not use manual BEGIN/ROLLBACK — the framework handles transactions via savepoints automatically.
| await db.client.query('BEGIN'); |
Was this helpful? React with 👍 or 👎 to provide feedback.
- Replace isPgRowType with isMutationInput in text-mutation-plugin (matches repo pattern in graphile-upload-plugin, required-input-plugin) - Remove manual BEGIN/ROLLBACK from test suite — let pgsql-test handle transactions via savepoints
…dding - Add chat.ts: chat completion provider abstraction (Ollama via @agentic-kit/ollama) - Add rag-plugin.ts: LlmRagPlugin with ragQuery and embedText root query fields - Detects @hasChunks smart tags to discover chunk tables - RAG flow: embed prompt → search chunks → assemble context → chat LLM → return answer - Adds RagResponse, RagSource, EmbedTextResponse types to schema - Wire resolve-time embedding in text-search-plugin (resolver wrapper on queries) - Wire resolve-time embedding in text-mutation-plugin (resolver wrapper on mutations) - Update llm-module-plugin to resolve chat completer on build - Add enableRag toggle to GraphileLlmPreset (default false) - Add chat completion types (ChatFunction, ChatConfig, ChatMessage, etc.) - Add RagDefaults, ChunkTableInfo types - Add integration tests: RAG schema enrichment, ragQuery execution, embedText, chat completion abstraction unit tests, preset toggle tests - Add articles_chunks seed table for RAG testing
Summary
Adds the
graphile-llmpackage undergraphile/graphile-llm/. This is a standalone PostGraphile v5 plugin (not added to ConstructivePreset) that brings server-side text-to-vector embedding, resolve-time vector injection, and RAG (Retrieval-Augmented Generation) capabilities for pgvector columns using@agentic-kit/ollama.What's included:
EmbedderFunction/EmbedderConfigtypes and a provider-basedbuildEmbedder()abstraction (Ollama built-in, extensible for OpenAI etc.)ChatFunction/ChatConfigtypes and a provider-basedbuildChatCompleter()abstraction for chat/completion (Ollama via@agentic-kit/ollama)LlmModulePlugin— resolves both embedder and chat completer from preset options or env vars and stores them on the Graphile build contextLlmTextSearchPlugin— addstext: StringtoVectorNearbyInputwith resolve-time embedding via v4-style resolver wrapping (interceptswhereargs, embeds text, injects vector before pgvector processes the query)LlmTextMutationPlugin— adds{column}Text: Stringcompanion fields on create/update inputs for vector columns with resolve-time embedding (wraps mutation resolvers, intercepts*Textfields, embeds and injects vectors)LlmRagPlugin— detects@hasChunkssmart tags on tables, addsragQuery(prompt, contextLimit, minSimilarity, systemPrompt)andembedText(text)root query fields. RAG flow: embed prompt → search chunks across all@hasChunkstables → assemble context → call chat LLM → return answer with sourcesGraphileLlmPreset()— factory function that bundles all plugins withenableTextSearch/enableTextMutations/enableRagtogglesTests (31 total across 6 suites):
buildEmbedder(),buildEmbedderFromModule(),buildEmbedderFromEnv()textonVectorNearbyInputandembeddingTexton mutation inputs. Requires PostgreSQL.nomic-embed-textviaOllamaClient, verifies 768-dim vectors and semantic similarity. Requires Ollama.buildChatCompleter(),buildChatCompleterFromModule(),buildChatCompleterFromEnv()@hasChunkssmart tags, mock embedder/chat, executesragQueryandembedTextGraphQL queries against real PostgreSQL with seeded chunks table. Verifies schema types (RagResponse,RagSource,EmbedTextResponse), answer content, and source metadata. Requires PostgreSQL.enableTextSearch,enableTextMutations,enableRagtoggles correctly include/exclude pluginsTests do not gracefully skip. If PostgreSQL or Ollama are missing, the relevant suites will fail — this is intentional per project convention.
Refs: constructive-io/constructive-planning#743
Updates since last revision
rag-plugin.ts) — discovers@hasChunkstables via pgRegistry scanning, addsragQueryandembedTextroot query fields usingextendSchema+ grafastlambdapattern for async operations at execution timechat.ts) — mirrors embedder abstraction; creates Ollama chat completers viaOllamaClient.generate()with messages arraywhere/filterargs to find VectorNearbyInput values withtext, embeds them, and replaces with vectors before plan execution*Textcompanion field values, embeds them, injects vectors, removes consumed*Textkeysbuild.llmEmbedderandbuild.llmChatCompleterenableRagtoggle toGraphileLlmPreset(defaultfalse; text search and mutations defaulttrue)graphile-utilspeer dependency (optional, formakeExtendSchemaPlugin)ChatFunction,ChatConfig,ChatMessage,ChatOptions,RagDefaults,ChunkTableInfoarticles_chunkstable with 6 chunks linked to articles for RAG integration testsReview & Testing Checklist for Human
GraphQLObjectType_fields_fieldhook wrapsresolvefunctions and mutatesargsobjects before calling the original resolver. This is the pattern used bygraphile-upload-pluginandgraphile-bucket-provisioner-plugin, but verify it works correctly with Grafast v5's plan-based execution. If Grafast compiles plans before resolve is called, the injected vectors may not reach the SQL layer.buildChunkSearchSql()constructs raw SQL with table/column names from@hasChunkssmart tag config. The names come from trusted codec metadata (not user input), but review for correctness — especially the1 - (embedding <=> $1::vector)distance calculation andORDER BY embedding <=> $1::vectorordering.embedTextInWhereheuristic for detecting VectorNearbyInput. The function detects objects with shape{ text: string, !vector }and assumes they are VectorNearbyInput values. This could false-positive on unrelated filter objects that happen to have atextkey but novectorkey. Verify this doesn't cause issues with other filter types.process.envaccess inbuildEmbedderFromEnv()andbuildChatCompleterFromEnv()— repo convention (AGENTS.md) says to usegetEnvOptions(). Should these be updated?Suggested test plan:
cd graphile/graphile-llm && pnpm testin an environment with PostgreSQL + pgvector and Ollama +nomic-embed-text— all 31 tests should pass@hasChunksand verifyragQueryandembedTextappear in the schemaragQuerywith a real Ollama embedder + chat model and verify the response includes relevant source chunks and a synthesized answerenableRag: falseand verifyragQuerydisappears from the schema whiletextonVectorNearbyInputremainsembeddingText: "some text"and verify the vector column is populatedNotes
enableRagdefaults tofalse(opt-in) whileenableTextSearchandenableTextMutationsdefault totrue. This is intentional since RAG requires both an embedder and a chat completer to be configured.as anycasting in plugins for scope access and build context — consistent with other graphile plugins in this repo (graphile-upload-plugin,graphile-bucket-provisioner-plugin).graphile-postgis,graphile-search) — pre-existing ESLint v9 config issue, not introduced by this PR.pnpm-lock.yamldiff is large due to formatting normalization by pnpm, not dependency changes.Link to Devin session: https://app.devin.ai/sessions/720693f565cc4b8fb4b198ddf75814cc
Requested by: @pyramation