feat: add semantic search with pgvector (closes #68, #69, #70)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed

Issues resolved:
- #68: pgvector Setup
  * Added pgvector vector index migration for knowledge_embeddings
  * Vector index uses HNSW algorithm with cosine distance
  * Optimized for 1536-dimension OpenAI embeddings

- #69: Embedding Generation Pipeline
  * Created EmbeddingService with OpenAI integration
  * Automatic embedding generation on entry create/update
  * Batch processing endpoint for existing entries
  * Async generation to avoid blocking API responses
  * Content preparation with title weighting

- #70: Semantic Search API
  * POST /api/knowledge/search/semantic - pure vector search
  * POST /api/knowledge/search/hybrid - RRF combined search
  * POST /api/knowledge/embeddings/batch - batch generation
  * Comprehensive test coverage
  * Full documentation in docs/SEMANTIC_SEARCH.md

Technical details:
- Uses OpenAI text-embedding-3-small model (1536 dims)
- HNSW index for O(log n) similarity search
- Reciprocal Rank Fusion for hybrid search
- Graceful degradation when OpenAI not configured
- Async embedding generation for performance

Configuration:
- Added OPENAI_API_KEY to .env.example
- Optional feature - disabled if API key not set
- Falls back to keyword search in hybrid mode
This commit is contained in:
Jason Woltje
2026-01-30 00:24:41 -06:00
parent 22cd68811d
commit 3ec2059470
14 changed files with 1408 additions and 5 deletions

View File

@@ -18,6 +18,7 @@ import type {
import { renderMarkdown } from "./utils/markdown";
import { LinkSyncService } from "./services/link-sync.service";
import { KnowledgeCacheService } from "./services/cache.service";
import { EmbeddingService } from "./services/embedding.service";
/**
* Service for managing knowledge entries
@@ -27,7 +28,8 @@ export class KnowledgeService {
constructor(
private readonly prisma: PrismaService,
private readonly linkSync: LinkSyncService,
private readonly cache: KnowledgeCacheService
private readonly cache: KnowledgeCacheService,
private readonly embedding: EmbeddingService
) {}
@@ -250,6 +252,13 @@ export class KnowledgeService {
// Sync wiki links after entry creation
await this.linkSync.syncLinks(workspaceId, result.id, createDto.content);
// Generate and store embedding asynchronously (don't block the response)
this.generateEntryEmbedding(result.id, result.title, result.content).catch(
(error) => {
console.error(`Failed to generate embedding for entry ${result.id}:`, error);
}
);
// Invalidate search and graph caches (new entry affects search results)
await this.cache.invalidateSearches(workspaceId);
await this.cache.invalidateGraphs(workspaceId);
@@ -408,6 +417,15 @@ export class KnowledgeService {
await this.linkSync.syncLinks(workspaceId, result.id, result.content);
}
// Regenerate embedding if content or title changed (async, don't block response)
if (updateDto.content !== undefined || updateDto.title !== undefined) {
this.generateEntryEmbedding(result.id, result.title, result.content).catch(
(error) => {
console.error(`Failed to generate embedding for entry ${result.id}:`, error);
}
);
}
// Invalidate caches
// Invalidate old slug cache if slug changed
if (newSlug !== slug) {
@@ -863,4 +881,64 @@ export class KnowledgeService {
)
);
}
/**
* Generate and store embedding for a knowledge entry
* Private helper method called asynchronously after entry create/update
*/
private async generateEntryEmbedding(
entryId: string,
title: string,
content: string
): Promise<void> {
const combinedContent = this.embedding.prepareContentForEmbedding(
title,
content
);
await this.embedding.generateAndStoreEmbedding(entryId, combinedContent);
}
/**
* Batch generate embeddings for all entries in a workspace
* Useful for populating embeddings for existing entries
*
* @param workspaceId - The workspace ID
* @param status - Optional status filter (default: not ARCHIVED)
* @returns Number of embeddings successfully generated
*/
async batchGenerateEmbeddings(
workspaceId: string,
status?: EntryStatus
): Promise<{ total: number; success: number }> {
const where: Prisma.KnowledgeEntryWhereInput = {
workspaceId,
status: status || { not: EntryStatus.ARCHIVED },
};
const entries = await this.prisma.knowledgeEntry.findMany({
where,
select: {
id: true,
title: true,
content: true,
},
});
const entriesForEmbedding = entries.map((entry) => ({
id: entry.id,
content: this.embedding.prepareContentForEmbedding(
entry.title,
entry.content
),
}));
const successCount = await this.embedding.batchGenerateEmbeddings(
entriesForEmbedding
);
return {
total: entries.length,
success: successCount,
};
}
}