Looking at your schema, I can see the actual structure is:
knowledgebase_entries (tenant_id, nucleus_id) → kb_content → kb_blocks (content)
Here are the only changes needed for your Prisma schema to support full-text search:
model kb_blocks {
id String @id @db.VarChar(255)
blockNumber Int @default(autoincrement())
kb_content_id String
block_type String @default("text")
content String?
content_search String? // Add this line for tsvector
sequence Int
word_count Int?
char_count Int?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
prev_block_id String?
next_block_id String?
images images[]
kb_content kb_content @relation(fields: [kb_content_id], references: [id], onDelete: Cascade)
@@index([block_type])
@@index([kb_content_id, sequence])
@@index([content_search]) // Add this index
}Create this as a custom migration file:
-- Add tsvector column
ALTER TABLE kb_blocks
ADD COLUMN IF NOT EXISTS content_search tsvector;
-- Create GIN index for fast full-text searching
CREATE INDEX IF NOT EXISTS idx_kb_blocks_content_search
ON kb_blocks USING GIN(content_search);
-- Function to update search vector
CREATE OR REPLACE FUNCTION update_kb_content_search()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.content IS NOT NULL THEN
NEW.content_search = to_tsvector('english', NEW.content);
ELSE
NEW.content_search = NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to auto-update search vector on insert/update
DROP TRIGGER IF EXISTS trigger_update_kb_content_search ON kb_blocks;
CREATE TRIGGER trigger_update_kb_content_search
BEFORE INSERT OR UPDATE ON kb_blocks
FOR EACH ROW EXECUTE FUNCTION update_kb_content_search();
-- Update existing records
UPDATE kb_blocks
SET content_search = to_tsvector('english', content)
WHERE content IS NOT NULL AND content_search IS NULL;// services/knowledgeBaseSearch.ts
import { PrismaClient } from '@prisma/client';
export interface SearchResult {
blockId: string;
content: string;
rank: number;
kbContentId: string;
knowledgebaseId: string;
knowledgebaseName: string;
}
export interface SearchFilters {
tenantId: string;
nucleusId: string;
knowledgebaseIds?: string[]; // Optional additional filtering
contentTypes?: string[]; // Filter by content type
tags?: string[]; // Filter by tags
}
export class KnowledgeBaseSearchService {
constructor(private prisma: PrismaClient) {}
async search(
query: string,
filters: SearchFilters,
options: { limit?: number; offset?: number } = {}
): Promise<SearchResult[]> {
const { limit = 10, offset = 0 } = options;
// Build dynamic WHERE conditions for additional filters
const additionalConditions = [];
if (filters.knowledgebaseIds?.length) {
additionalConditions.push(`AND ke.id = ANY(ARRAY[${filters.knowledgebaseIds.map(id => `'${id}'`).join(',')}])`);
}
if (filters.contentTypes?.length) {
additionalConditions.push(`AND ke."contentType" = ANY(ARRAY[${filters.contentTypes.map(type => `'${type}'`).join(',')}])`);
}
if (filters.tags?.length) {
additionalConditions.push(`
AND EXISTS (
SELECT 1 FROM tags_on_knowledgebase_entries tke
JOIN tags t ON tke."tagId" = t.id
WHERE tke."knowledgebaseEntryId" = ke.id
AND t.name = ANY(ARRAY[${filters.tags.map(tag => `'${tag}'`).join(',')}])
)
`);
}
const results = await this.prisma.$queryRaw<SearchResult[]>`
SELECT
kb.id as "blockId",
kb.content,
ts_rank(kb.content_search, plainto_tsquery('english', ${query})) as rank,
kc.id as "kbContentId",
ke.id as "knowledgebaseId",
ke.knowledgebase_name as "knowledgebaseName"
FROM kb_blocks kb
JOIN kb_content kc ON kb.kb_content_id = kc.id
JOIN knowledgebase_entries ke ON kc.knowledgebase_id = ke.id
WHERE
ke.tenant_id = ${filters.tenantId}
AND ke.nucleus_id = ${filters.nucleusId}
AND kb.content_search @@ plainto_tsquery('english', ${query})
AND kb.content IS NOT NULL
${additionalConditions.length > 0 ?
Prisma.raw(additionalConditions.join(' ')) :
Prisma.empty
}
ORDER BY rank DESC
LIMIT ${limit}
OFFSET ${offset}
`;
return results;
}
// Get available knowledge bases for filtering
async getKnowledgeBases(tenantId: string, nucleusId: string) {
return await this.prisma.knowledgebase_entries.findMany({
where: {
tenant_id: tenantId,
nucleus_id: nucleusId,
status: 'published', // or whatever status indicates active
},
select: {
id: true,
knowledgebase_name: true,
contentType: true,
_count: {
select: {
kb_content: {
where: {
kb_blocks: {
some: {
content: {
not: null
}
}
}
}
}
}
}
}
});
}
// Get available tags for filtering
async getTags(tenantId: string, nucleusId: string) {
return await this.prisma.tags.findMany({
where: {
nucleus_id: nucleusId,
nucleuses: {
tenant_id: tenantId
}
},
select: {
id: true,
name: true,
priority: true
},
orderBy: {
priority: 'desc'
}
});
}
}// Example usage
import { PrismaClient } from '@prisma/client';
import { KnowledgeBaseSearchService } from './services/knowledgeBaseSearch';
const prisma = new PrismaClient();
const searchService = new KnowledgeBaseSearchService(prisma);
async function searchExample() {
const results = await searchService.search(
'machine learning algorithms',
{
tenantId: 'tenant123',
nucleusId: 'nucleus456',
contentTypes: ['text', 'document'], // Optional
tags: ['AI', 'Technology'] // Optional
},
{ limit: 20, offset: 0 }
);
console.log('Search results:', results);
}
// Get available filters
async function getFilters() {
const knowledgeBases = await searchService.getKnowledgeBases('tenant123', 'nucleus456');
const tags = await searchService.getTags('tenant123', 'nucleus456');
return { knowledgeBases, tags };
}That's it! These minimal changes will give you:
✅ Full-text search with PostgreSQL's native capabilities
✅ Proper filtering by tenant_id and nucleus_id
✅ Additional filters for knowledge bases, content types, and tags
✅ Automatic search index updates via database triggers
✅ Type-safe Prisma integration
The search will be fast and scalable, leveraging PostgreSQL's optimized full-text search engine while working seamlessly with your existing Prisma setup.