diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 956e117d3..cee928ae6 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -28,6 +28,7 @@ concurrency: # unit-tests → no services (pure JS/TS) # pg-tests → PostgreSQL only # integration-tests → PostgreSQL + MinIO +# ai-tests → PostgreSQL + Ollama # --------------------------------------------------------------------------- jobs: @@ -338,3 +339,81 @@ jobs: - name: Test ${{ matrix.package }} run: cd ./${{ matrix.package }} && pnpm test env: ${{ matrix.env }} + + # ========================================================================= + # TIER 4 – AI integration tests (PostgreSQL + Ollama) + # ========================================================================= + ai-tests: + needs: build + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - package: graphile/graphile-llm + env: {} + + env: + PGHOST: localhost + PGPORT: 5432 + PGUSER: postgres + PGPASSWORD: password + + services: + pg_db: + image: ghcr.io/constructive-io/docker/postgres-plus:18 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Download workspace + uses: actions/download-artifact@v4 + with: + name: workspace-build + + - name: Extract workspace + run: tar -xzf workspace.tar.gz && rm workspace.tar.gz + + - name: Configure Git (for tests) + run: | + git config --global user.name "CI Test User" + git config --global user.email "ci@example.com" + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Seed app_user + run: | + pnpm --filter pgpm exec node dist/index.js admin-users bootstrap --yes + pnpm --filter pgpm exec node dist/index.js admin-users add --test --yes + + - name: Install Ollama + run: | + curl -fsSL https://ollama.com/install.sh | sh + ollama serve & + sleep 3 + ollama pull nomic-embed-text + + - name: Test ${{ matrix.package }} + run: cd ./${{ matrix.package }} && pnpm test + env: ${{ matrix.env }} diff --git a/graphile/graphile-llm/src/__tests__/graphile-llm.test.ts b/graphile/graphile-llm/src/__tests__/graphile-llm.test.ts index d9fa5e93e..b30542eeb 100644 --- a/graphile/graphile-llm/src/__tests__/graphile-llm.test.ts +++ b/graphile/graphile-llm/src/__tests__/graphile-llm.test.ts @@ -2,6 +2,8 @@ import OllamaClient from '@agentic-kit/ollama'; import type { GraphileConfig } from 'graphile-config'; import { ConnectionFilterPreset } from 'graphile-connection-filter'; import { createPgvectorAdapter } from 'graphile-search/adapters/pgvector'; +import { createTsvectorAdapter } from 'graphile-search/adapters/tsvector'; +import { TsvectorCodecPlugin } from 'graphile-search/codecs/tsvector-codec'; import { VectorCodecPlugin } from 'graphile-search/codecs/vector-codec'; import { createUnifiedSearchPlugin } from 'graphile-search/plugin'; import type { GraphQLResponse } from 'graphile-test'; @@ -22,8 +24,8 @@ import { import { createLlmModulePlugin } from '../../src/plugins/llm-module-plugin'; import { createLlmRagPlugin } from '../../src/plugins/rag-plugin'; import { createLlmTextMutationPlugin } from '../../src/plugins/text-mutation-plugin'; -import { createLlmTextSearchPlugin } from '../../src/plugins/text-search-plugin'; -import type { LlmModuleData } from '../../src/types'; +import { createLlmTextSearchPlugin, embedTextInWhere } from '../../src/plugins/text-search-plugin'; +import type { EmbedderFunction, LlmModuleData } from '../../src/types'; // ─── @agentic-kit/ollama client ───────────────────────────────────────────── @@ -256,12 +258,12 @@ describe('graphile-llm schema enrichment', () => { // ─── Mutation text companion fields ────────────────────────────────────── describe('Mutation text companion fields', () => { - it('adds embeddingText field to CreateArticleInput', async () => { + it('adds embeddingText field to ArticleInput (create input)', async () => { const result = await query<{ __type: { inputFields: Array<{ name: string; type: { name: string } }> }; }>(` query { - __type(name: "CreateArticleInput") { + __type(name: "ArticleInput") { inputFields { name type { name } @@ -275,9 +277,7 @@ describe('graphile-llm schema enrichment', () => { expect(inputType).toBeDefined(); const fieldNames = inputType!.inputFields.map((f) => f.name); - // Original embedding field expect(fieldNames).toContain('embedding'); - // Companion text field from LlmTextMutationPlugin expect(fieldNames).toContain('embeddingText'); const textField = inputType!.inputFields.find( @@ -538,13 +538,17 @@ function makeTestSmartTagsPlugin( return { name: 'TestSmartTagsPlugin', version: '1.0.0', + before: ['LlmRagPlugin'], schema: { hooks: { - init: { - before: ['UnifiedSearchPlugin', 'LlmRagPlugin'], - callback(_, build) { - for (const codec of Object.values(build.input.pgRegistry.pgCodecs)) { + build: { + before: ['LlmRagPlugin'], + callback(build) { + const pgRegistry = build.input?.pgRegistry ?? (build as any).pgRegistry; + if (!pgRegistry) return build; + + for (const codec of Object.values(pgRegistry.pgCodecs || {})) { const c = codec as any; if (!c.attributes || !c.name) continue; @@ -556,7 +560,7 @@ function makeTestSmartTagsPlugin( Object.assign(c.extensions.tags, tags); } - return _; + return build; } } } @@ -603,31 +607,11 @@ describe('RAG plugin schema enrichment', () => { }; }; - const testPreset = { - extends: [ConnectionFilterPreset()], - plugins: [ - VectorCodecPlugin, - unifiedPlugin, - smartTagsPlugin, - createLlmModulePlugin({ - defaultEmbedder: { - provider: 'ollama', - model: 'nomic-embed-text', - baseUrl: 'http://localhost:11434' - } - }), - createLlmTextSearchPlugin(), - createLlmTextMutationPlugin(), - createLlmRagPlugin() - ] - }; - - // Override the embedder and chat completer on the build context - // by wrapping the LlmModulePlugin's build hook - const overridePlugin: GraphileConfig.Plugin = { - name: 'TestOverridePlugin', + // Provide mock embedder/chat directly instead of using createLlmModulePlugin + // to avoid build.extend naming conflicts when overriding + const mockLlmPlugin: GraphileConfig.Plugin = { + name: 'TestMockLlmPlugin', version: '1.0.0', - after: ['LlmModulePlugin'], schema: { hooks: { build(build) { @@ -635,22 +619,34 @@ describe('RAG plugin schema enrichment', () => { build, { llmEmbedder: mockEmbedder, - llmChatCompleter: mockChatCompleter + llmChatCompleter: mockChatCompleter, + llmEmbeddingModel: 'test-mock-model', + llmChatModel: 'test-mock-chat' }, - 'TestOverridePlugin overriding embedder and chat completer' + 'TestMockLlmPlugin providing mock embedder and chat completer' ); } } } }; + const testPreset = { + extends: [ConnectionFilterPreset()], + plugins: [ + VectorCodecPlugin, + unifiedPlugin, + smartTagsPlugin, + mockLlmPlugin, + createLlmTextSearchPlugin(), + createLlmTextMutationPlugin(), + createLlmRagPlugin() + ] + }; + const connections = await getConnections( { schemas: ['llm_test'], - preset: { - ...testPreset, - plugins: [...testPreset.plugins, overridePlugin] - }, + preset: testPreset, useRoot: true, authRole: 'postgres' }, @@ -884,3 +880,350 @@ describe('GraphileLlmPreset toggles', () => { expect(pluginNames).not.toContain('LlmRagPlugin'); }); }); + +// ============================================================================= +// Suite 7: Real Ollama + unifiedSearch + pgvector RRF integration +// +// End-to-end: embeds article text with real Ollama nomic-embed-text (768 dims), +// seeds the DB, then runs hybrid text+vector queries verifying RRF fusion, +// semantic relevance, and the embedTextInWhere pipeline. +// +// Requires: PostgreSQL with pgvector + Ollama with nomic-embed-text. +// ============================================================================= + +const INTEGRATION_ARTICLES = [ + { id: 1, title: 'Introduction to Machine Learning', body: 'Machine learning is a subset of artificial intelligence that focuses on algorithms and statistical models.' }, + { id: 2, title: 'Cooking Italian Pasta', body: 'The best Italian pasta is made with fresh ingredients like San Marzano tomatoes and homemade egg noodles.' }, + { id: 3, title: 'Deep Learning with Neural Networks', body: 'Deep learning uses neural networks with multiple layers to learn hierarchical representations of data.' }, + { id: 4, title: 'Gardening Tips for Spring', body: 'Start your spring garden by preparing soil, choosing the right seeds, and ensuring proper sunlight and watering.' }, + { id: 5, title: 'Natural Language Processing', body: 'NLP combines computational linguistics with machine learning to enable computers to understand human language.' }, +]; + +const INTEGRATION_SETUP_SQL = ` + CREATE EXTENSION IF NOT EXISTS vector; + CREATE SCHEMA IF NOT EXISTS llm_integration; + + CREATE TABLE llm_integration.articles ( + id serial PRIMARY KEY, + title text NOT NULL, + body text NOT NULL, + tsv tsvector GENERATED ALWAYS AS ( + setweight(to_tsvector('english', title), 'A') || + setweight(to_tsvector('english', body), 'B') + ) STORED, + embedding vector(768) NOT NULL + ); + + CREATE INDEX idx_int_articles_tsv ON llm_integration.articles USING gin(tsv); + CREATE INDEX idx_int_articles_embedding ON llm_integration.articles + USING hnsw(embedding vector_cosine_ops); +`; + +describe('Real Ollama + unifiedSearch + pgvector RRF integration', () => { + let db: PgTestClient; + let teardown: () => Promise; + let query: QueryFn; + let embedder: EmbedderFunction; + // Pre-computed query vectors (embedded once in beforeAll) + let mlQueryVector: number[]; + let cookingQueryVector: number[]; + + beforeAll(async () => { + // Build a real Ollama embedder + const built = buildEmbedder({ + provider: 'ollama', + model: 'nomic-embed-text', + baseUrl: 'http://localhost:11434', + }); + if (!built) throw new Error('Failed to build Ollama embedder'); + embedder = built; + + await ensureNomicModel(); + + // Embed all article bodies in parallel + const embeddings = await Promise.all( + INTEGRATION_ARTICLES.map((a) => embedder(`${a.title}. ${a.body}`)) + ); + + // Build seed SQL with real 768-dim embeddings + const values = INTEGRATION_ARTICLES.map((a, i) => { + const vecStr = `'[${embeddings[i].embedding.join(',')}]'`; + const titleEsc = a.title.replace(/'/g, "''"); + const bodyEsc = a.body.replace(/'/g, "''"); + return `(${a.id}, '${titleEsc}', '${bodyEsc}', ${vecStr})`; + }).join(',\n '); + + const seedSql = ` + INSERT INTO llm_integration.articles (id, title, body, embedding) + VALUES ${values}; + SELECT setval('llm_integration.articles_id_seq', ${INTEGRATION_ARTICLES.length}); + `; + + // Pre-compute search query vectors + const [mlResult, cookResult] = await Promise.all([ + embedder('machine learning artificial intelligence algorithms'), + embedder('Italian cooking pasta recipes tomatoes'), + ]); + mlQueryVector = mlResult.embedding; + cookingQueryVector = cookResult.embedding; + + // Set up PostGraphile with tsvector + pgvector adapters + LLM plugin + const unifiedPlugin = createUnifiedSearchPlugin({ + adapters: [ + createTsvectorAdapter(), + createPgvectorAdapter(), + ], + enableSearchScore: true, + enableUnifiedSearch: true, + rrfK: 60, + }); + + const testPreset = { + extends: [ConnectionFilterPreset()], + plugins: [ + TsvectorCodecPlugin, + VectorCodecPlugin, + unifiedPlugin, + createLlmModulePlugin({ + defaultEmbedder: { + provider: 'ollama', + model: 'nomic-embed-text', + baseUrl: 'http://localhost:11434', + }, + }), + createLlmTextSearchPlugin(), + ], + }; + + const connections = await getConnections({ + schemas: ['llm_integration'], + preset: testPreset, + useRoot: true, + authRole: 'postgres', + }, [ + seed.fn(async (ctx) => { + await ctx.pg.query(INTEGRATION_SETUP_SQL); + await ctx.pg.query(seedSql); + }), + ]); + + db = connections.db; + teardown = connections.teardown; + query = connections.query; + + await db.client.query('BEGIN'); + }, 120_000); + + afterAll(async () => { + if (db) { + try { await db.client.query('ROLLBACK'); } catch {} + } + if (teardown) await teardown(); + }); + + beforeEach(async () => { await db.beforeEach(); }); + afterEach(async () => { await db.afterEach(); }); + + // ─── embedTextInWhere with real Ollama ────────────────────────────────── + + it('embedTextInWhere transforms unifiedSearch with real 768-dim Ollama vector', async () => { + const realEmbedder = async (text: string) => { + const result = await embedder(text); + return result.embedding; + }; + + const where: any = { unifiedSearch: 'machine learning algorithms' }; + await embedTextInWhere(where, realEmbedder, true); + + expect(where.unifiedSearch).toHaveProperty('__text', 'machine learning algorithms'); + expect(where.unifiedSearch).toHaveProperty('__vector'); + expect(Array.isArray(where.unifiedSearch.__vector)).toBe(true); + expect(where.unifiedSearch.__vector.length).toBe(768); + + for (const v of where.unifiedSearch.__vector) { + expect(typeof v).toBe('number'); + expect(Number.isFinite(v)).toBe(true); + } + }, 30_000); + + // ─── unifiedSearch text-only (no vector injection) ───────────────────── + + it('unifiedSearch text-only uses tsvector without pgvector', async () => { + const result = await query<{ + allArticles: { nodes: Array<{ rowId: number; title: string; tsvRank: number | null; embeddingVectorDistance: number | null; searchScore: number }> }; + }>(` + query { + allArticles(where: { unifiedSearch: "machine learning" }) { + nodes { rowId title tsvRank embeddingVectorDistance searchScore } + } + } + `); + + expect(result.errors).toBeUndefined(); + const nodes = result.data?.allArticles?.nodes ?? []; + expect(nodes.length).toBeGreaterThan(0); + + // tsvector should be active + const hasTsv = nodes.some((n) => n.tsvRank !== null); + expect(hasTsv).toBe(true); + + // pgvector should NOT participate (no vector injection in graphile-test) + for (const node of nodes) { + expect(node.embeddingVectorDistance).toBeNull(); + } + }); + + // ─── unifiedSearch + real pgvector: hybrid RRF ───────────────────────── + + it('unifiedSearch + real vector embedding fuses tsvector + pgvector via RRF', async () => { + const vecStr = `[${mlQueryVector.join(',')}]`; + const result = await query<{ + allArticles: { nodes: Array<{ rowId: number; title: string; tsvRank: number | null; embeddingVectorDistance: number | null; searchScore: number }> }; + }>(` + query($vec: [Float!]!) { + allArticles(where: { + unifiedSearch: "machine learning" + vectorEmbedding: { vector: $vec, metric: COSINE } + }) { + nodes { rowId title tsvRank embeddingVectorDistance searchScore } + } + } + `, { vec: mlQueryVector }); + + expect(result.errors).toBeUndefined(); + const nodes = result.data?.allArticles?.nodes ?? []; + expect(nodes.length).toBeGreaterThan(0); + + // Both adapters should be active + const hasTsv = nodes.some((n) => n.tsvRank !== null); + const hasVector = nodes.some((n) => n.embeddingVectorDistance !== null); + expect(hasTsv).toBe(true); + expect(hasVector).toBe(true); + + // searchScore must be normalized [0,1] + for (const node of nodes) { + expect(node.searchScore).toBeGreaterThanOrEqual(0); + expect(node.searchScore).toBeLessThanOrEqual(1); + } + }); + + // ─── Semantic relevance with real embeddings ─────────────────────────── + + it('ML query ranks ML articles higher than cooking/gardening with real embeddings', async () => { + const result = await query<{ + allArticles: { nodes: Array<{ rowId: number; title: string; searchScore: number }> }; + }>(` + query($vec: [Float!]!) { + allArticles(where: { + unifiedSearch: "machine learning" + vectorEmbedding: { vector: $vec, metric: COSINE } + }) { + nodes { rowId title searchScore } + } + } + `, { vec: mlQueryVector }); + + expect(result.errors).toBeUndefined(); + const nodes = result.data?.allArticles?.nodes ?? []; + + // ML-related articles (1, 3, 5) should score higher than non-ML (2, 4) + const mlArticles = nodes.filter((n) => [1, 3, 5].includes(n.rowId)); + const nonMlArticles = nodes.filter((n) => [2, 4].includes(n.rowId)); + + if (mlArticles.length > 0 && nonMlArticles.length > 0) { + const bestMlScore = Math.max(...mlArticles.map((n) => n.searchScore)); + const bestNonMlScore = Math.max(...nonMlArticles.map((n) => n.searchScore)); + expect(bestMlScore).toBeGreaterThan(bestNonMlScore); + } + }); + + it('cooking query ranks cooking article higher than ML articles with real embeddings', async () => { + const result = await query<{ + allArticles: { nodes: Array<{ rowId: number; title: string; searchScore: number }> }; + }>(` + query($vec: [Float!]!) { + allArticles(where: { + unifiedSearch: "Italian pasta cooking" + vectorEmbedding: { vector: $vec, metric: COSINE } + }) { + nodes { rowId title searchScore } + } + } + `, { vec: cookingQueryVector }); + + expect(result.errors).toBeUndefined(); + const nodes = result.data?.allArticles?.nodes ?? []; + + // Cooking article (id=2) should rank highest + const cookingArticle = nodes.find((n) => n.rowId === 2); + const otherArticles = nodes.filter((n) => n.rowId !== 2); + + if (cookingArticle && otherArticles.length > 0) { + const bestOtherScore = Math.max(...otherArticles.map((n) => n.searchScore)); + expect(cookingArticle.searchScore).toBeGreaterThan(bestOtherScore); + } + }); + + // ─── Vector-only search with real embeddings ─────────────────────────── + + it('VectorNearbyInput with real embedding returns semantically relevant results', async () => { + const result = await query<{ + allArticles: { nodes: Array<{ rowId: number; title: string; embeddingVectorDistance: number }> }; + }>(` + query($vec: [Float!]!) { + allArticles(where: { + vectorEmbedding: { vector: $vec, metric: COSINE } + }) { + nodes { rowId title embeddingVectorDistance } + } + } + `, { vec: mlQueryVector }); + + expect(result.errors).toBeUndefined(); + const nodes = result.data?.allArticles?.nodes ?? []; + expect(nodes.length).toBe(5); + + // Nearest to ML query should be ML articles + const sorted = [...nodes].sort((a, b) => a.embeddingVectorDistance - b.embeddingVectorDistance); + // Top result should be an ML-related article (1, 3, or 5) + expect([1, 3, 5]).toContain(sorted[0].rowId); + }); + + // ─── RRF boost: text+vector scores higher than text-only ─────────────── + + it('hybrid text+vector search scores higher than text-only for relevant docs', async () => { + const textOnly = await query<{ + allArticles: { nodes: Array<{ rowId: number; searchScore: number }> }; + }>(` + query { + allArticles(where: { unifiedSearch: "machine learning" }) { + nodes { rowId searchScore } + } + } + `); + + const hybrid = await query<{ + allArticles: { nodes: Array<{ rowId: number; searchScore: number }> }; + }>(` + query($vec: [Float!]!) { + allArticles(where: { + unifiedSearch: "machine learning" + vectorEmbedding: { vector: $vec, metric: COSINE } + }) { + nodes { rowId searchScore } + } + } + `, { vec: mlQueryVector }); + + expect(textOnly.errors).toBeUndefined(); + expect(hybrid.errors).toBeUndefined(); + + const doc1TextOnly = textOnly.data?.allArticles?.nodes?.find((n) => n.rowId === 1); + const doc1Hybrid = hybrid.data?.allArticles?.nodes?.find((n) => n.rowId === 1); + + // Doc 1 (ML article) should score at least as high with hybrid + if (doc1TextOnly && doc1Hybrid) { + expect(doc1Hybrid.searchScore).toBeGreaterThanOrEqual(doc1TextOnly.searchScore); + } + }); +}); diff --git a/graphile/graphile-llm/src/__tests__/unified-search-embedding.test.ts b/graphile/graphile-llm/src/__tests__/unified-search-embedding.test.ts new file mode 100644 index 000000000..0ad6f0af3 --- /dev/null +++ b/graphile/graphile-llm/src/__tests__/unified-search-embedding.test.ts @@ -0,0 +1,173 @@ +/** + * Unit tests for LlmTextSearchPlugin's embedTextInWhere function. + * + * Tests the transformation logic: + * - unifiedSearch: "text" → unifiedSearch: { __text: "text", __vector: [...] } + * - VectorNearbyInput.text → VectorNearbyInput.vector (existing behavior) + * + * Pure unit tests — no database or Ollama required. + */ + +import { embedTextInWhere } from '../../src/plugins/text-search-plugin'; + +describe('unifiedSearch embedding integration', () => { + const mockVector = [0.1, 0.2, 0.3, 0.4, 0.5]; + const mockEmbedder = jest.fn(async (_text: string) => mockVector); + const nullEmbedder = jest.fn(async (_text: string) => null as number[] | null); + + beforeEach(() => { + mockEmbedder.mockClear(); + nullEmbedder.mockClear(); + }); + + describe('unifiedSearch text → { __text, __vector } transformation', () => { + it('transforms unifiedSearch string to object with __text and __vector', async () => { + const where = { unifiedSearch: 'HIPAA compliance' }; + await embedTextInWhere(where, mockEmbedder, true); + + expect(where.unifiedSearch).toEqual({ + __text: 'HIPAA compliance', + __vector: mockVector, + }); + expect(mockEmbedder).toHaveBeenCalledWith('HIPAA compliance'); + }); + + it('leaves unifiedSearch as plain string when embedder returns null (graceful degradation)', async () => { + const where = { unifiedSearch: 'database normalization' }; + await embedTextInWhere(where, nullEmbedder, true); + + // Should remain as string — text adapters handle it + expect(where.unifiedSearch).toBe('database normalization'); + expect(nullEmbedder).toHaveBeenCalledWith('database normalization'); + }); + + it('throws when embedder returns null and no text adapters available', async () => { + const where = { unifiedSearch: 'vector only query' }; + + await expect( + embedTextInWhere(where, nullEmbedder, false) + ).rejects.toThrow('embedding quota exceeded'); + }); + + it('does not embed empty unifiedSearch strings', async () => { + const where = { unifiedSearch: ' ' }; + await embedTextInWhere(where, mockEmbedder, true); + + expect(where.unifiedSearch).toBe(' '); + expect(mockEmbedder).not.toHaveBeenCalled(); + }); + + it('does not embed null unifiedSearch', async () => { + const where: any = { unifiedSearch: null }; + await embedTextInWhere(where, mockEmbedder, true); + + expect(where.unifiedSearch).toBeNull(); + expect(mockEmbedder).not.toHaveBeenCalled(); + }); + }); + + describe('VectorNearbyInput text → vector (existing behavior)', () => { + it('transforms VectorNearbyInput text to vector', async () => { + const where = { vectorEmbedding: { text: 'semantic query' } }; + await embedTextInWhere(where, mockEmbedder, true); + + expect(where.vectorEmbedding).toEqual({ vector: mockVector }); + expect(mockEmbedder).toHaveBeenCalledWith('semantic query'); + }); + + it('removes text field when embedder returns null', async () => { + const where = { vectorEmbedding: { text: 'failed query' } }; + await embedTextInWhere(where, nullEmbedder, true); + + expect(where.vectorEmbedding).toEqual({}); + expect(nullEmbedder).toHaveBeenCalledWith('failed query'); + }); + + it('does not modify VectorNearbyInput with existing vector', async () => { + const existingVector = [1, 2, 3]; + const where = { vectorEmbedding: { vector: existingVector, text: 'ignored' } }; + await embedTextInWhere(where, mockEmbedder, true); + + // text + vector present → not modified (vector takes precedence) + expect(where.vectorEmbedding.vector).toBe(existingVector); + expect(mockEmbedder).not.toHaveBeenCalled(); + }); + }); + + describe('combined unifiedSearch + VectorNearbyInput', () => { + it('embeds both unifiedSearch and VectorNearbyInput.text in parallel', async () => { + const where = { + unifiedSearch: 'hybrid search query', + vectorEmbedding: { text: 'vector part' }, + }; + + await embedTextInWhere(where, mockEmbedder, true); + + expect(where.unifiedSearch).toEqual({ + __text: 'hybrid search query', + __vector: mockVector, + }); + expect(where.vectorEmbedding).toEqual({ vector: mockVector }); + expect(mockEmbedder).toHaveBeenCalledTimes(2); + }); + }); + + describe('nested filter structures (AND, OR)', () => { + it('handles unifiedSearch inside nested AND/OR filters', async () => { + const where = { + AND: [ + { unifiedSearch: 'first query' }, + { unifiedSearch: 'second query' }, + ], + }; + + await embedTextInWhere(where, mockEmbedder, true); + + expect(where.AND[0].unifiedSearch).toEqual({ + __text: 'first query', + __vector: mockVector, + }); + expect(where.AND[1].unifiedSearch).toEqual({ + __text: 'second query', + __vector: mockVector, + }); + }); + }); + + 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 + const val = { __text: 'HIPAA compliance', __vector: [0.1, 0.2, 0.3] }; + + let text: string; + let vector: number[] | null = null; + + if (typeof val === 'object' && val.__text) { + text = val.__text; + vector = val.__vector ?? null; + } else { + text = typeof val === 'string' ? val : String(val); + } + + expect(text).toBe('HIPAA compliance'); + expect(vector).toEqual([0.1, 0.2, 0.3]); + }); + + it('plugin.ts apply function handles plain string correctly', () => { + const val = 'plain text search' as any; + + let text: string; + let vector: number[] | null = null; + + if (typeof val === 'object' && val.__text) { + text = val.__text; + vector = val.__vector ?? null; + } else { + text = typeof val === 'string' ? val : String(val); + } + + expect(text).toBe('plain text search'); + expect(vector).toBeNull(); + }); + }); +}); diff --git a/graphile/graphile-llm/src/index.ts b/graphile/graphile-llm/src/index.ts index d27095d64..ea448fbaf 100644 --- a/graphile/graphile-llm/src/index.ts +++ b/graphile/graphile-llm/src/index.ts @@ -41,7 +41,7 @@ export { GraphileLlmPreset } from './preset'; export { createLlmModulePlugin } from './plugins/llm-module-plugin'; export { createLlmRagPlugin } from './plugins/rag-plugin'; export { createLlmTextMutationPlugin } from './plugins/text-mutation-plugin'; -export { createLlmTextSearchPlugin } from './plugins/text-search-plugin'; +export { createLlmTextSearchPlugin, embedTextInWhere } from './plugins/text-search-plugin'; // Metering plugin (opt-in billing integration) export { createLlmMeteringPlugin } from './plugins/metering-plugin'; diff --git a/graphile/graphile-llm/src/plugins/rag-plugin.ts b/graphile/graphile-llm/src/plugins/rag-plugin.ts index 508e3010a..3c1a3e15c 100644 --- a/graphile/graphile-llm/src/plugins/rag-plugin.ts +++ b/graphile/graphile-llm/src/plugins/rag-plugin.ts @@ -89,18 +89,18 @@ function parseHasChunksTag(raw: any, codec: any): ChunkTableInfo | null { */ function discoverChunkTables(build: any): ChunkTableInfo[] { const chunkTables: ChunkTableInfo[] = []; - const pgRegistry = build.pgRegistry; + const pgRegistry = build.input?.pgRegistry ?? build.pgRegistry; if (!pgRegistry) return chunkTables; // Scan all codecs for @hasChunks smart tag - for (const source of Object.values(pgRegistry.pgResources || {})) { - const codec = (source as any)?.codec; - if (!codec?.attributes) continue; + for (const codec of Object.values(pgRegistry.pgCodecs || {})) { + const c = codec as any; + if (!c?.attributes) continue; - const tags = codec.extensions?.tags; + const tags = c.extensions?.tags; if (!tags?.hasChunks) continue; - const info = parseHasChunksTag(tags.hasChunks, codec); + const info = parseHasChunksTag(tags.hasChunks, c); if (info) { chunkTables.push(info); } @@ -175,23 +175,6 @@ export function createLlmRagPlugin( let chatCompleter: ChatFunction | null = null; const schemaExtension = extendSchema((build) => { - // Discover chunk-aware tables from pgRegistry - chunkTables = discoverChunkTables(build); - embedder = (build as any).llmEmbedder || null; - chatCompleter = (build as any).llmChatCompleter || null; - - if (chunkTables.length > 0) { - console.log( - `[graphile-llm] RAG plugin discovered ${chunkTables.length} chunk-aware table(s): ` + - chunkTables.map((t) => t.parentCodecName).join(', ') - ); - } else { - console.log( - '[graphile-llm] RAG plugin found no @hasChunks tables. ' + - 'ragQuery will still work if chunks tables are queried directly.' - ); - } - return { typeDefs: gql` """A source chunk retrieved during RAG context assembly.""" @@ -416,9 +399,9 @@ export function createLlmRagPlugin( } } }; - }); + }, 'LlmRagPlugin'); - return { + const plugin: GraphileConfig.Plugin = { ...schemaExtension, name: 'LlmRagPlugin', version: '0.1.0', @@ -431,4 +414,34 @@ export function createLlmRagPlugin( 'VectorCodecPlugin' ] }; + + // Wrap the build hook to also discover chunk tables. + // The build hook runs after all init hooks (including smart tag injection), + // so @hasChunks tags are guaranteed to be visible. + const existingBuildHook = plugin.schema!.hooks!.build as (build: any) => any; + (plugin.schema!.hooks!.build as any) = (build: any) => { + // Run extendSchema's build hook first (sets up graphql ref) + build = existingBuildHook(build) || build; + + // Discover chunk tables — runs during build phase when all smart tags are applied + chunkTables = discoverChunkTables(build); + embedder = (build as any).llmEmbedder || null; + chatCompleter = (build as any).llmChatCompleter || null; + + if (chunkTables.length > 0) { + console.log( + `[graphile-llm] RAG plugin discovered ${chunkTables.length} chunk-aware table(s): ` + + chunkTables.map((t) => t.parentCodecName).join(', ') + ); + } else { + console.log( + '[graphile-llm] RAG plugin found no @hasChunks tables. ' + + 'ragQuery will still work if chunks tables are queried directly.' + ); + } + + return build; + }; + + return plugin; } diff --git a/graphile/graphile-llm/src/plugins/text-mutation-plugin.ts b/graphile/graphile-llm/src/plugins/text-mutation-plugin.ts index 7df7da971..7c57830fa 100644 --- a/graphile/graphile-llm/src/plugins/text-mutation-plugin.ts +++ b/graphile/graphile-llm/src/plugins/text-mutation-plugin.ts @@ -115,8 +115,22 @@ export function createLlmTextMutationPlugin(): GraphileConfig.Plugin { } } = context as any; - // Only intercept create/update input types for table rows - if (!pgCodec?.attributes || (!isPgPatch && !isPgBaseInput && !isMutationInput)) { + // Only intercept mutation-related input types for table rows. + // PostGraphile v5 sets isPgPatch on update types, isPgBaseInput on + // create base types. For tables with `serial PRIMARY KEY`, the create + // input type (e.g. ArticleInput) has pgCodec set but none of the + // explicit mutation scope flags — detect via type name convention. + if (!pgCodec?.attributes) return fields; + + const typeName = context.Self.name; + const hasExplicitMutationScope = isPgPatch || isPgBaseInput || isMutationInput; + const looksLikeMutationInput = typeName.endsWith('Input') || typeName.endsWith('Patch'); + const isFilterOrCondition = typeName.endsWith('FilterInput') + || typeName.endsWith('Filter') + || typeName.endsWith('Condition') + || typeName === 'VectorNearbyInput'; + + if (!hasExplicitMutationScope && (!looksLikeMutationInput || isFilterOrCondition)) { return fields; } diff --git a/graphile/graphile-llm/src/plugins/text-search-plugin.ts b/graphile/graphile-llm/src/plugins/text-search-plugin.ts index 525989826..95472bf26 100644 --- a/graphile/graphile-llm/src/plugins/text-search-plugin.ts +++ b/graphile/graphile-llm/src/plugins/text-search-plugin.ts @@ -59,9 +59,10 @@ function hasVectorColumns(pgCodec: any): boolean { * If the embedder returns null (e.g. quota exceeded), the text field is * removed so the pgvector filter is skipped — graceful text-only fallback. */ -async function embedTextInWhere( +export async function embedTextInWhere( obj: any, - embedder: (text: string) => Promise + embedder: (text: string) => Promise, + hasTextAdapters: boolean ): Promise { if (!obj || typeof obj !== 'object') return; @@ -69,6 +70,37 @@ async function embedTextInWhere( for (const key of Object.keys(obj)) { const value = obj[key]; + + // Handle unifiedSearch: embed text and transform to { __text, __vector } + if (key === 'unifiedSearch' && typeof value === 'string' && value.trim().length > 0) { + pending.push((async () => { + const startTime = Date.now(); + const vector = await embedder(value); + 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 + throw new Error( + 'unifiedSearch: embedding quota exceeded and no text search adapters available. ' + + 'Upgrade your plan or add text search columns for graceful fallback.' + ); + } + // Graceful degradation: leave as plain string, text adapters still work + return; + } + + console.log( + `[graphile-llm] unifiedSearch embed: dims=${vector.length}, latency=${latencyMs}ms` + ); + + // Transform to object shape that graphile-search understands + obj[key] = { __text: value, __vector: vector }; + })()); + continue; + } + if (!value || typeof value !== 'object') continue; // Detect VectorNearbyInput shape: has `text` and no `vector` @@ -97,11 +129,11 @@ async function embedTextInWhere( // Recurse into nested filter objects (AND, OR, etc.) if (!Array.isArray(value)) { - pending.push(embedTextInWhere(value, embedder)); + pending.push(embedTextInWhere(value, embedder, hasTextAdapters)); } else { // Handle arrays (e.g. AND: [...], OR: [...]) for (const item of value) { - pending.push(embedTextInWhere(item, embedder)); + pending.push(embedTextInWhere(item, embedder, hasTextAdapters)); } } } @@ -139,11 +171,9 @@ export function createLlmTextSearchPlugin(): GraphileConfig.Plugin { * The field is optional — clients provide either `text` or `vector`. */ GraphQLInputObjectType_fields(fields, build, context) { - const { - scope: { inputObjectTypeName } - } = context as any; + const typeName = context.Self.name; - if (inputObjectTypeName !== 'VectorNearbyInput') { + if (typeName !== 'VectorNearbyInput') { return fields; } @@ -168,8 +198,11 @@ export function createLlmTextSearchPlugin(): GraphileConfig.Plugin { /** * Wrap connection query resolvers to intercept `where` arguments that - * contain VectorNearbyInput with `text`, embed the text, and replace - * it with the resulting vector before the plan executes. + * contain VectorNearbyInput with `text` or `unifiedSearch` with text, + * embed the text, and inject the resulting vector before the plan executes. + * + * For tables with vector columns: embeds VectorNearbyInput.text → vector + * For ALL tables with unifiedSearch: embeds unifiedSearch text → { __text, __vector } * * Uses the same v4-style resolver wrapping pattern as graphile-upload-plugin * and graphile-bucket-provisioner-plugin. @@ -179,30 +212,43 @@ export function createLlmTextSearchPlugin(): GraphileConfig.Plugin { scope: { isRootQuery, pgCodec } } = context as any; - // Only wrap root query fields on tables with vector columns - if (!isRootQuery || !pgCodec || !hasVectorColumns(pgCodec)) { - return field; - } + if (!isRootQuery || !pgCodec) return field; + + // Wrap if the table has vector columns OR has any searchable columns + // (for unifiedSearch embedding support) + const hasVector = hasVectorColumns(pgCodec); + const hasSearchableColumns = pgCodec.attributes && Object.values( + pgCodec.attributes as Record + ).some((attr: any) => + attr.codec?.name === 'tsvector' || attr.codec?.name === 'vector' + ); + + if (!hasVector && !hasSearchableColumns) return field; const embedder = (build as any).llmEmbedder as | ((text: string) => Promise) | null; if (!embedder) return field; + // Determine if this table has text-based search adapters for fallback logic + const hasTextAdapters = pgCodec.attributes && Object.values( + pgCodec.attributes as Record + ).some((attr: any) => attr.codec?.name === 'tsvector'); + const defaultResolver = (obj: any) => obj[context.scope.fieldName]; const { resolve: oldResolve = defaultResolver, ...rest } = field; return { ...rest, async resolve(source: any, args: any, graphqlContext: any, info: any) { - // If the query has a `where` argument, check for text fields + // If the query has a `where` argument, check for text/unifiedSearch fields if (args?.where) { - await embedTextInWhere(args.where, embedder); + await embedTextInWhere(args.where, embedder, !!hasTextAdapters); } // Also handle `filter` for relay-style connections if (args?.filter) { - await embedTextInWhere(args.filter, embedder); + await embedTextInWhere(args.filter, embedder, !!hasTextAdapters); } return oldResolve(source, args, graphqlContext, info); diff --git a/graphile/graphile-search/src/__tests__/rrf-scoring.test.ts b/graphile/graphile-search/src/__tests__/rrf-scoring.test.ts index 14ecaac2f..6d9647181 100644 --- a/graphile/graphile-search/src/__tests__/rrf-scoring.test.ts +++ b/graphile/graphile-search/src/__tests__/rrf-scoring.test.ts @@ -543,6 +543,207 @@ describe('RRF scoring — multi-adapter combinations', () => { }); }); +// ─── Test Suite: unifiedSearch + pgvector RRF Fusion ───────────────────────── +// +// When graphile-llm is active, it transforms unifiedSearch: "text" into +// { __text, __vector } via a resolver wrapper. The apply function then +// includes pgvector in the OR + RRF fusion. +// +// Testing the full resolver-wrapper → apply flow requires PostGraphile +// server-level execution (Grafast plan-based fields skip `resolve` in +// direct schema execution). Unit tests for the object-shape handling +// are in graphile-llm/src/__tests__/unified-search-embedding.test.ts. +// +// Here we verify the equivalent end-user behavior: when both unifiedSearch +// (text) AND vectorEmbedding (vector) filters are applied, RRF fuses all +// adapter ranks correctly — which is what the LLM integration produces. + +describe('RRF scoring — unifiedSearch + pgvector fusion (simulating LLM path)', () => { + let db: PgTestClient; + let teardown: () => Promise; + let query: QueryFn; + + beforeAll(async () => { + const unifiedPlugin = createUnifiedSearchPlugin({ + adapters: [ + createTsvectorAdapter(), + createBm25Adapter(), + createTrgmAdapter({ defaultThreshold: 0.1 }), + createPgvectorAdapter(), + ], + enableSearchScore: true, + enableUnifiedSearch: true, + rrfK: 60, + }); + + const testPreset = { + extends: [ConnectionFilterPreset()], + plugins: [ + TsvectorCodecPlugin, + Bm25CodecPlugin, + VectorCodecPlugin, + unifiedPlugin, + ], + }; + + const connections = await getConnections({ + schemas: ['unified_search_test'], + preset: testPreset, + useRoot: true, + authRole: 'postgres', + }, [ + seed.sqlfile([join(__dirname, './setup.sql')]) + ]); + + db = connections.db; + teardown = connections.teardown; + query = connections.query; + + await db.client.query('BEGIN'); + }); + + afterAll(async () => { + if (db) { + try { await db.client.query('ROLLBACK'); } catch {} + } + if (teardown) await teardown(); + }); + + beforeEach(async () => { await db.beforeEach(); }); + afterEach(async () => { await db.afterEach(); }); + + it('text-only unifiedSearch does NOT activate pgvector', async () => { + const result = await query(` + query { + allDocuments(where: { unifiedSearch: "machine learning" }) { + nodes { + rowId + tsvRank + bodyBm25Score + embeddingVectorDistance + searchScore + } + } + } + `); + + expect(result.errors).toBeUndefined(); + const nodes = result.data?.allDocuments?.nodes ?? []; + expect(nodes.length).toBeGreaterThan(0); + + // Text adapters should work + const hasTextScore = nodes.some((n) => n.tsvRank !== null || n.bodyBm25Score !== null); + expect(hasTextScore).toBe(true); + + // pgvector should NOT participate without LLM injection + for (const node of nodes) { + expect(node.embeddingVectorDistance).toBeNull(); + } + }); + + it('unifiedSearch + vectorEmbedding fuses all 4 adapter ranks via RRF', async () => { + // This is the equivalent end-state of what graphile-llm produces: + // text adapters handle the keyword matching, pgvector handles semantic + const result = await query(` + query { + allDocuments(where: { + unifiedSearch: "machine learning" + vectorEmbedding: { vector: [1, 0, 0], metric: COSINE } + }) { + nodes { + rowId + title + tsvRank + bodyBm25Score + titleTrgmSimilarity + embeddingVectorDistance + searchScore + } + } + } + `); + + expect(result.errors).toBeUndefined(); + const nodes = result.data?.allDocuments?.nodes ?? []; + expect(nodes.length).toBeGreaterThan(0); + + for (const node of nodes) { + expect(typeof node.searchScore).toBe('number'); + expect(node.searchScore).toBeGreaterThanOrEqual(0); + expect(node.searchScore).toBeLessThanOrEqual(1); + } + + // pgvector should participate + const hasVectorScore = nodes.some((n) => n.embeddingVectorDistance !== null); + expect(hasVectorScore).toBe(true); + + // Document 1 has embedding [1,0,0] (exact match) AND contains "machine learning" + const doc1 = nodes.find((n) => n.rowId === 1); + if (doc1) { + expect(doc1.searchScore).toBeGreaterThan(0.5); + expect(doc1.embeddingVectorDistance).not.toBeNull(); + } + }); + + it('doc scores higher with text + vector vs text-only (RRF boost)', async () => { + const textOnly = await query(` + query { + allDocuments(where: { unifiedSearch: "machine learning" }) { + nodes { rowId searchScore } + } + } + `); + + const textAndVector = await query(` + query { + allDocuments(where: { + unifiedSearch: "machine learning" + vectorEmbedding: { vector: [1, 0, 0], metric: COSINE } + }) { + nodes { rowId searchScore } + } + } + `); + + expect(textOnly.errors).toBeUndefined(); + expect(textAndVector.errors).toBeUndefined(); + + const textOnlyNodes = textOnly.data?.allDocuments?.nodes ?? []; + const vectorNodes = textAndVector.data?.allDocuments?.nodes ?? []; + + // Doc 1 should score higher when vector also contributes + const doc1TextOnly = textOnlyNodes.find((n) => n.rowId === 1); + const doc1Vector = vectorNodes.find((n) => n.rowId === 1); + + if (doc1TextOnly && doc1Vector) { + expect(doc1Vector.searchScore).toBeGreaterThanOrEqual(doc1TextOnly.searchScore ?? 0); + } + }); + + it('searchScore stays [0,1] with all adapters active', async () => { + const result = await query(` + query { + allDocuments(where: { + unifiedSearch: "neural networks deep learning" + vectorEmbedding: { vector: [0.5, 0.5, 0], metric: COSINE } + }) { + nodes { rowId title searchScore } + } + } + `); + + expect(result.errors).toBeUndefined(); + const nodes = result.data?.allDocuments?.nodes ?? []; + + for (const node of nodes) { + if (node.searchScore !== null) { + expect(node.searchScore).toBeGreaterThanOrEqual(0); + expect(node.searchScore).toBeLessThanOrEqual(1); + } + } + }); +}); + // ─── Test Suite: Chunk-Aware Tables ────────────────────────────────────────── describe('RRF scoring — chunk-aware tables', () => { diff --git a/graphile/graphile-search/src/plugin.ts b/graphile/graphile-search/src/plugin.ts index a54757d26..26206605c 100644 --- a/graphile/graphile-search/src/plugin.ts +++ b/graphile/graphile-search/src/plugin.ts @@ -786,6 +786,50 @@ export function createUnifiedSearchPlugin( fieldWithHooks, } = context; + /** + * Inject a single adapter's score expression + rank window function + * into the query builder, and apply any pending ORDER BY direction. + * + * Shared by per-adapter filter fields AND the unifiedSearch + * composite filter (both text and vector adapter branches). + */ + function injectScoreAndRank( + qb: any, + adapter: SearchAdapter, + column: SearchableColumn, + result: { scoreExpression: any }, + codec_: PgCodecWithAttributes, + $condition: any, + ): void { + if (!qb || qb.mode !== 'normal') return; + + const baseFieldName = inflection.attribute({ + codec: codec_ as any, + attributeName: column.attributeName, + }); + const scoreMetaKey = `__unified_search_${adapter.name}_${baseFieldName}`; + const wrappedScoreSql = sql`${sql.parens(result.scoreExpression)}::text`; + const scoreIndex = qb.selectAndReturnIndex(wrappedScoreSql); + qb.setMeta(scoreMetaKey, { selectIndex: scoreIndex } as SearchScoreDetails); + + const rankMetaKey = `${scoreMetaKey}__rank`; + const orderDirection = adapter.scoreSemantics.lowerIsBetter ? 'ASC' : 'DESC'; + const rankSql = sql`(ROW_NUMBER() OVER (ORDER BY ${sql.parens(result.scoreExpression)} ${orderDirection === 'ASC' ? sql.fragment`ASC` : sql.fragment`DESC`} NULLS LAST))::text`; + const rankIndex = qb.selectAndReturnIndex(rankSql); + qb.setMeta(rankMetaKey, { selectIndex: rankIndex } as SearchScoreDetails); + + const orderKey = `unified_order_${adapter.name}_${baseFieldName}`; + const dirs = _pendingOrderDirections.get($condition.alias); + const explicitDir = dirs?.[orderKey]; + if (explicitDir) { + qb.orderBy({ + fragment: result.scoreExpression, + codec: TYPES.float, + direction: explicitDir, + }); + } + } + if ( !isPgConnectionFilter || !pgCodec || @@ -866,41 +910,8 @@ export function createUnifiedSearchPlugin( $condition.where(result.whereClause); } - // Get the query builder for SELECT/ORDER BY injection const qb = getQueryBuilder(build, $condition); - - if (qb && qb.mode === 'normal') { - // Add score to the SELECT list - const wrappedScoreSql = sql`${sql.parens(result.scoreExpression)}::text`; - const scoreIndex = qb.selectAndReturnIndex(wrappedScoreSql); - - // Store the select index in meta for the output field plan - qb.setMeta(scoreMetaKey, { - selectIndex: scoreIndex, - } as SearchScoreDetails); - - // Add rank (ROW_NUMBER window function) for RRF scoring - const rankMetaKey = `${scoreMetaKey}__rank`; - const orderDirection = adapter.scoreSemantics.lowerIsBetter ? 'ASC' : 'DESC'; - const rankSql = sql`(ROW_NUMBER() OVER (ORDER BY ${sql.parens(result.scoreExpression)} ${orderDirection === 'ASC' ? sql.fragment`ASC` : sql.fragment`DESC`} NULLS LAST))::text`; - const rankIndex = qb.selectAndReturnIndex(rankSql); - qb.setMeta(rankMetaKey, { - selectIndex: rankIndex, - } as SearchScoreDetails); - - // ORDER BY: read the direction stored by the orderBy - // enum (which ran first) via the shared alias key. - const orderKey = `unified_order_${adapter.name}_${baseFieldName}`; - const dirs = _pendingOrderDirections.get($condition.alias); - const explicitDir = dirs?.[orderKey]; - if (explicitDir) { - qb.orderBy({ - fragment: result.scoreExpression, - codec: TYPES.float, - direction: explicitDir, - }); - } - } + injectScoreAndRank(qb, adapter, column, result, codec, $condition); }, } ), @@ -914,11 +925,18 @@ export function createUnifiedSearchPlugin( // Adds a single `unifiedSearch: String` field that fans out the same // text query to all adapters where supportsTextSearch is true. // WHERE clauses are combined with OR (match ANY algorithm). + // + // When graphile-llm is active, the resolver wrapper transforms the + // String value into { __text, __vector } so pgvector also participates. if (enableUnifiedSearch) { // Collect text-compatible adapters and their columns for this codec const textAdapterColumns = adapterColumns.filter( (ac) => ac.adapter.supportsTextSearch && ac.adapter.buildTextSearchInput ); + // Collect vector adapters (participate when __vector is injected by LLM plugin) + const vectorAdapterColumns = adapterColumns.filter( + (ac) => ac.adapter.name === 'vector' + ); if (textAdapterColumns.length > 0) { const fieldName = 'unifiedSearch'; @@ -935,19 +953,31 @@ export function createUnifiedSearchPlugin( description: build.wrapDescription( 'Composite unified search. Provide a search string and it will be dispatched ' + 'to all text-compatible search algorithms (tsvector, BM25, pg_trgm) simultaneously. ' + + 'When the LLM plugin is active, pgvector also participates via auto-embedding. ' + 'Rows matching ANY algorithm are returned. All matching score fields are populated.', 'field' ), type: build.graphql.GraphQLString as any, apply: function plan($condition: any, val: any) { - if (val == null || (typeof val === 'string' && val.trim().length === 0)) return; + if (val == null) return; + + // Support both plain string and { __text, __vector } from LLM plugin + let text: string; + let vector: number[] | null = null; + if (typeof val === 'object' && val.__text) { + text = val.__text; + vector = val.__vector ?? null; + } else { + text = typeof val === 'string' ? val : String(val); + if (text.trim().length === 0) return; + } - const text = typeof val === 'string' ? val : String(val); const qb = getQueryBuilder(build, $condition); // Collect all WHERE clauses (combined with OR) const whereClauses: any[] = []; + // Text adapters (tsvector, BM25, trgm) for (const { adapter, columns } of textAdapterColumns) { for (const column of columns) { // Convert text to adapter-specific filter input @@ -962,45 +992,30 @@ export function createUnifiedSearchPlugin( ); if (!result) continue; - // Collect WHERE clause for OR combination if (result.whereClause) { whereClauses.push(result.whereClause); } + injectScoreAndRank(qb, adapter, column, result, codec, $condition); + } + } - // Still inject score into SELECT so score fields are populated - if (qb && qb.mode === 'normal') { - const baseFieldName = inflection.attribute({ - codec: pgCodec as any, - attributeName: column.attributeName, - }); - const scoreMetaKey = `__unified_search_${adapter.name}_${baseFieldName}`; - const wrappedScoreSql = sql`${sql.parens(result.scoreExpression)}::text`; - const scoreIndex = qb.selectAndReturnIndex(wrappedScoreSql); - qb.setMeta(scoreMetaKey, { - selectIndex: scoreIndex, - } as SearchScoreDetails); - - // Add rank (ROW_NUMBER window function) for RRF scoring - const rankMetaKey = `${scoreMetaKey}__rank`; - const orderDirection = adapter.scoreSemantics.lowerIsBetter ? 'ASC' : 'DESC'; - const rankSql = sql`(ROW_NUMBER() OVER (ORDER BY ${sql.parens(result.scoreExpression)} ${orderDirection === 'ASC' ? sql.fragment`ASC` : sql.fragment`DESC`} NULLS LAST))::text`; - const rankIndex = qb.selectAndReturnIndex(rankSql); - qb.setMeta(rankMetaKey, { - selectIndex: rankIndex, - } as SearchScoreDetails); - - // ORDER BY: read the direction stored by the orderBy - // enum (which ran first) via the shared alias key. - const orderKey = `unified_order_${adapter.name}_${baseFieldName}`; - const dirs = _pendingOrderDirections.get($condition.alias); - const explicitDir = dirs?.[orderKey]; - if (explicitDir) { - qb.orderBy({ - fragment: result.scoreExpression, - codec: TYPES.float, - direction: explicitDir, - }); + // Vector adapters (pgvector) — only when __vector is injected + if (vector && vectorAdapterColumns.length > 0) { + for (const { adapter, columns } of vectorAdapterColumns) { + for (const column of columns) { + const result = adapter.buildFilterApply( + sql, + $condition.alias, + column, + { vector, metric: 'COSINE' }, + build, + ); + if (!result) continue; + + if (result.whereClause) { + whereClauses.push(result.whereClause); } + injectScoreAndRank(qb, adapter, column, result, codec, $condition); } } } @@ -1013,6 +1028,13 @@ export function createUnifiedSearchPlugin( const combined = sql.fragment`(${sql.join(whereClauses, ' OR ')})`; $condition.where(combined); } + } else if (vector && textAdapterColumns.length === 0) { + // Vector-only table with no text adapters and embedding failed + // This shouldn't happen (caught in resolver wrapper) but safety net + throw new Error( + 'unifiedSearch: no text adapters available and vector search requires an embedding. ' + + 'Ensure the LLM plugin is configured or add text search columns.' + ); } }, }