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:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user