From fb3c308efdf2a80a40bce41ab33200d2008dd44c Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 13 Mar 2026 08:49:19 -0500 Subject: [PATCH] =?UTF-8?q?feat(P4-002):=20semantic=20search=20=E2=80=94?= =?UTF-8?q?=20pgvector=20embeddings=20+=20search=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add EmbeddingService using OpenAI-compatible embeddings API (supports text-embedding-3-small, configurable via EMBEDDING_MODEL and EMBEDDING_API_URL env vars). Wire embedding generation into insight creation and semantic search endpoint. POST /api/memory/search now generates a query embedding and performs cosine distance search via pgvector when OPENAI_API_KEY is configured. Co-Authored-By: Claude Opus 4.6 --- apps/gateway/src/memory/embedding.service.ts | 69 ++++++++++++++++++++ apps/gateway/src/memory/memory.controller.ts | 36 +++++++--- apps/gateway/src/memory/memory.module.ts | 4 +- docs/TASKS.md | 4 +- 4 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 apps/gateway/src/memory/embedding.service.ts diff --git a/apps/gateway/src/memory/embedding.service.ts b/apps/gateway/src/memory/embedding.service.ts new file mode 100644 index 0000000..14f8551 --- /dev/null +++ b/apps/gateway/src/memory/embedding.service.ts @@ -0,0 +1,69 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { EmbeddingProvider } from '@mosaic/memory'; + +const DEFAULT_MODEL = 'text-embedding-3-small'; +const DEFAULT_DIMENSIONS = 1536; + +interface EmbeddingResponse { + data: Array<{ embedding: number[]; index: number }>; + model: string; + usage: { prompt_tokens: number; total_tokens: number }; +} + +/** + * Generates embeddings via the OpenAI-compatible embeddings API. + * Supports OpenAI, Azure OpenAI, and any provider with a compatible endpoint. + */ +@Injectable() +export class EmbeddingService implements EmbeddingProvider { + private readonly logger = new Logger(EmbeddingService.name); + private readonly apiKey: string | undefined; + private readonly baseUrl: string; + private readonly model: string; + + readonly dimensions = DEFAULT_DIMENSIONS; + + constructor() { + this.apiKey = process.env['OPENAI_API_KEY']; + this.baseUrl = process.env['EMBEDDING_API_URL'] ?? 'https://api.openai.com/v1'; + this.model = process.env['EMBEDDING_MODEL'] ?? DEFAULT_MODEL; + } + + get available(): boolean { + return !!this.apiKey; + } + + async embed(text: string): Promise { + const results = await this.embedBatch([text]); + return results[0]!; + } + + async embedBatch(texts: string[]): Promise { + if (!this.apiKey) { + this.logger.warn('No OPENAI_API_KEY configured — returning zero vectors'); + return texts.map(() => new Array(this.dimensions).fill(0)); + } + + const response = await fetch(`${this.baseUrl}/embeddings`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: this.model, + input: texts, + dimensions: this.dimensions, + }), + }); + + if (!response.ok) { + const body = await response.text(); + this.logger.error(`Embedding API error: ${response.status} ${body}`); + throw new Error(`Embedding API returned ${response.status}`); + } + + const json = (await response.json()) as EmbeddingResponse; + return json.data.sort((a, b) => a.index - b.index).map((d) => d.embedding); + } +} diff --git a/apps/gateway/src/memory/memory.controller.ts b/apps/gateway/src/memory/memory.controller.ts index 85c2f06..ff69238 100644 --- a/apps/gateway/src/memory/memory.controller.ts +++ b/apps/gateway/src/memory/memory.controller.ts @@ -15,12 +15,16 @@ import { import type { Memory } from '@mosaic/memory'; import { MEMORY } from './memory.tokens.js'; import { AuthGuard } from '../auth/auth.guard.js'; +import { EmbeddingService } from './embedding.service.js'; import type { UpsertPreferenceDto, CreateInsightDto, SearchMemoryDto } from './memory.dto.js'; @Controller('api/memory') @UseGuards(AuthGuard) export class MemoryController { - constructor(@Inject(MEMORY) private readonly memory: Memory) {} + constructor( + @Inject(MEMORY) private readonly memory: Memory, + private readonly embeddings: EmbeddingService, + ) {} // ─── Preferences ──────────────────────────────────────────────────── @@ -76,12 +80,17 @@ export class MemoryController { @Post('insights') async createInsight(@Query('userId') userId: string, @Body() dto: CreateInsightDto) { + const embedding = this.embeddings.available + ? await this.embeddings.embed(dto.content) + : undefined; + return this.memory.insights.create({ userId, content: dto.content, source: dto.source, category: dto.category, metadata: dto.metadata, + embedding: embedding ?? null, }); } @@ -96,13 +105,22 @@ export class MemoryController { @Post('search') async searchMemory(@Query('userId') userId: string, @Body() dto: SearchMemoryDto) { - // Search requires an embedding provider to be configured. - // For now, return empty results if no embedding is available. - // P4-002 will implement the full embedding + search pipeline. - return { - query: dto.query, - results: [], - message: 'Semantic search requires embedding provider (P4-002)', - }; + if (!this.embeddings.available) { + return { + query: dto.query, + results: [], + message: 'Semantic search requires OPENAI_API_KEY for embeddings', + }; + } + + const queryEmbedding = await this.embeddings.embed(dto.query); + const results = await this.memory.insights.searchByEmbedding( + userId, + queryEmbedding, + dto.limit ?? 10, + dto.maxDistance ?? 0.8, + ); + + return { query: dto.query, results }; } } diff --git a/apps/gateway/src/memory/memory.module.ts b/apps/gateway/src/memory/memory.module.ts index 10705c6..0108047 100644 --- a/apps/gateway/src/memory/memory.module.ts +++ b/apps/gateway/src/memory/memory.module.ts @@ -4,6 +4,7 @@ import type { Db } from '@mosaic/db'; import { DB } from '../database/database.module.js'; import { MEMORY } from './memory.tokens.js'; import { MemoryController } from './memory.controller.js'; +import { EmbeddingService } from './embedding.service.js'; @Global() @Module({ @@ -13,8 +14,9 @@ import { MemoryController } from './memory.controller.js'; useFactory: (db: Db): Memory => createMemory(db), inject: [DB], }, + EmbeddingService, ], controllers: [MemoryController], - exports: [MEMORY], + exports: [MEMORY, EmbeddingService], }) export class MemoryModule {} diff --git a/docs/TASKS.md b/docs/TASKS.md index 49fd6db..92c8c2c 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -37,8 +37,8 @@ | P3-006 | done | Phase 3 | Settings — provider config, profile, integrations | #88 | #31 | | P3-007 | done | Phase 3 | Admin panel — user management, RBAC | #89 | #32 | | P3-008 | done | Phase 3 | Verify Phase 3 — web dashboard functional E2E | — | #33 | -| P4-001 | not-started | Phase 4 | @mosaic/memory — preference + insight stores | — | #34 | -| P4-002 | not-started | Phase 4 | Semantic search — pgvector embeddings + search API | — | #35 | +| P4-001 | in-progress | Phase 4 | @mosaic/memory — preference + insight stores | — | #34 | +| P4-002 | in-progress | Phase 4 | Semantic search — pgvector embeddings + search API | — | #35 | | P4-003 | not-started | Phase 4 | @mosaic/log — log ingest, parsing, tiered storage | — | #36 | | P4-004 | not-started | Phase 4 | Summarization pipeline — Haiku-tier LLM + cron | — | #37 | | P4-005 | not-started | Phase 4 | Memory integration — inject into agent sessions | — | #38 |