feat(ee): turbopuffer ai vector search driver (#2402)

* feat(ai): add AI_VECTOR_DRIVER and turbopuffer configuration

* feat(ai): carry workspace and target space ids on vector lifecycle events

* feat(ai): add vector driver interface and turbopuffer request helpers

* feat(ai): add pgvector driver behind the vector driver interface

* refactor(ai): route vector reads and writes through the vector driver

* feat(ai): add turbopuffer vector driver

* feat(ai): rebuild turbopuffer namespaces when the embedding model changes

* feat(ai): warm the vector namespace cache on session start

* fix(ai): harden turbopuffer misconfiguration and reset failure paths

* fix(ai): collapse blank-line runs in extracted page text

* fix(ai): skip full re-embed when ai search is re-enabled within the delete grace window

* sync

* fix(ai): store real embedding dimensions instead of serialized vector length

* fix(ai): filter search hits by the page's current space at query time

* fix(ai): retry the page moved-to-space vector patch job

* sync

* feat(ai): pre-warm the vector namespace

* fix(ai): pass AI_VECTOR_DRIVER through the client build config
This commit is contained in:
Philip Okugbe
2026-08-17 21:18:16 +01:00
committed by GitHub
parent 911c1057d6
commit 232beda471
19 changed files with 171 additions and 8 deletions
+1
View File
@@ -66,6 +66,7 @@
"@nestjs/websockets": "11.1.28",
"@node-saml/passport-saml": "5.1.0",
"@socket.io/redis-adapter": "8.3.0",
"@turbopuffer/turbopuffer": "^2.8.0",
"ai": "6.0.134",
"ai-sdk-ollama": "3.8.1",
"bcrypt": "6.0.0",
@@ -57,6 +57,7 @@ import {
JSONContent,
} from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
import { collapseBlankLines } from '../common/helpers';
// @tiptap/html library works best for generating prosemirror json state but not HTML
// see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089
@@ -146,7 +147,7 @@ export function htmlToJson(html: string) {
}
export function jsonToText(tiptapJson: JSONContent) {
return generateText(tiptapJson, tiptapExtensions);
return collapseBlankLines(generateText(tiptapJson, tiptapExtensions));
}
export function jsonToNode(tiptapJson: JSONContent) {
+1
View File
@@ -1,4 +1,5 @@
export * from './utils';
export * from './text.utils';
export * from './nanoid.utils';
export * from './file.helper';
export * from './constants';
@@ -0,0 +1,14 @@
import { collapseBlankLines } from './text.utils';
describe('collapseBlankLines', () => {
it.each([
['a\n\n\n\nb', 'a\n\nb'],
['a\n\nb', 'a\n\nb'],
['a\nb', 'a\nb'],
['\n\n\n\na\n\n\n', '\n\na\n\n'],
['no newlines', 'no newlines'],
['', ''],
])('collapses %j to %j', (input, expected) => {
expect(collapseBlankLines(input)).toBe(expected);
});
});
@@ -0,0 +1,3 @@
export function collapseBlankLines(text: string): string {
return text.replace(/\n{2,}/g, '\n\n');
}
@@ -496,10 +496,21 @@ export class PageService {
},
);
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
pageIds: pageIdsToMove,
workspaceId: rootPage.workspaceId,
});
await this.aiQueue.add(
QueueJob.PAGE_MOVED_TO_SPACE,
{
pageIds: pageIdsToMove,
spaceId,
workspaceId: rootPage.workspaceId,
},
{
attempts: 2,
backoff: {
type: 'fixed',
delay: 2 * 60 * 1000,
},
},
);
}
});
@@ -8,6 +8,7 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
export class SpaceEvent {
spaceId: string;
workspaceId: string;
}
@Injectable()
@@ -22,12 +23,12 @@ export class SpaceListener {
@OnEvent(EventName.SPACE_DELETED)
async handleSpaceDeleted(event: SpaceEvent) {
const { spaceId } = event;
const { spaceId, workspaceId } = event;
if (this.isTypesense()) {
await this.searchQueue.add(QueueJob.SPACE_DELETED, { spaceId });
}
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId });
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId, workspaceId });
}
isTypesense(): boolean {
@@ -230,6 +230,7 @@ export class SpaceRepo {
this.eventEmitter.emit(EventName.SPACE_DELETED, {
spaceId,
workspaceId,
});
}
}
@@ -211,6 +211,24 @@ export class WorkspaceRepo {
.executeTakeFirst();
}
async updateAiEmbeddingFingerprint(
workspaceId: string,
fingerprint: { driver: string; model: string; dimensions: number },
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|| jsonb_build_object('embedding', ${JSON.stringify(fingerprint)}::text::jsonb))`,
updatedAt: new Date(),
})
.where('id', '=', workspaceId)
.execute();
}
async updateSharingSettings(
workspaceId: string,
prefKey: string,
@@ -310,6 +310,31 @@ export class EnvironmentService {
return val === 'true';
}
getAiVectorDriver(): string {
return this.configService
.get<string>('AI_VECTOR_DRIVER', 'pgvector')
.toLowerCase();
}
getTurbopufferApiKey(): string {
return this.configService.get<string>('TURBOPUFFER_API_KEY');
}
getTurbopufferRegion(): string {
return this.configService.get<string>('TURBOPUFFER_REGION');
}
getTurbopufferBaseUrl(): string {
return this.configService.get<string>('TURBOPUFFER_BASE_URL');
}
getTurbopufferNamespacePrefix(): string {
return this.configService.get<string>(
'TURBOPUFFER_NAMESPACE_PREFIX',
'docmost',
);
}
getOpenAiApiKey(): string {
return this.configService.get<string>('OPENAI_API_KEY');
}
@@ -5,6 +5,7 @@ import {
IsOptional,
IsString,
IsUrl,
Matches,
MinLength,
ValidateIf,
validateSync,
@@ -108,6 +109,41 @@ export class EnvironmentVariables {
@IsString()
AI_DRIVER: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_VECTOR_DRIVER)
@IsIn(['pgvector', 'turbopuffer'])
@IsString()
AI_VECTOR_DRIVER: string;
@ValidateIf((obj) => obj.AI_VECTOR_DRIVER === 'turbopuffer')
@IsNotEmpty()
@IsString()
TURBOPUFFER_API_KEY: string;
@ValidateIf(
(obj) =>
obj.AI_VECTOR_DRIVER === 'turbopuffer' && !obj.TURBOPUFFER_BASE_URL,
)
@IsNotEmpty({
message:
'TURBOPUFFER_REGION is required when AI_VECTOR_DRIVER is turbopuffer, unless TURBOPUFFER_BASE_URL is set',
})
@IsString()
TURBOPUFFER_REGION: string;
@IsOptional()
@ValidateIf((obj) => obj.TURBOPUFFER_BASE_URL != '' && obj.TURBOPUFFER_BASE_URL != null)
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
TURBOPUFFER_BASE_URL: string;
@IsOptional()
@IsString()
@Matches(/^[A-Za-z0-9\-_.]{1,90}$/, {
message:
'TURBOPUFFER_NAMESPACE_PREFIX may only contain letters, digits, dot, dash, underscore (max 90 chars)',
})
TURBOPUFFER_NAMESPACE_PREFIX: string;
@IsOptional()
@IsString()
AI_EMBEDDING_MODEL: string;
@@ -61,6 +61,7 @@ export enum QueueJob {
WORKSPACE_DELETED = 'workspace-deleted',
WORKSPACE_CREATE_EMBEDDINGS = 'workspace-create-embeddings',
WORKSPACE_DELETE_EMBEDDINGS = 'workspace-delete-embeddings',
WORKSPACE_RESET_EMBEDDINGS = 'workspace-reset-embeddings',
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
@@ -49,6 +49,10 @@ export class StaticModule implements OnModuleInit {
: undefined,
POSTHOG_HOST: this.environmentService.getPostHogHost(),
POSTHOG_KEY: this.environmentService.getPostHogKey(),
AI_VECTOR_DRIVER:
this.environmentService.getAiVectorDriver() === 'turbopuffer'
? 'turbopuffer'
: undefined,
};
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;