feat(server): trigger workspace embedding (#12328)

fix AI-127

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Added automated event handling for workspace updates and document embedding, streamlining document embedding workflows.
  - Introduced detection and queuing of documents needing embedding, excluding ignored documents.
- **Improvements**
  - Enhanced performance of embedding-related searches by filtering results at the database level.
  - Increased concurrency for embedding job processing to improve throughput.
- **Bug Fixes**
  - Improved error handling and fallback for missing document titles during embedding.
  - Added safeguards to skip invalid embedding jobs based on document identifiers.
- **Tests**
  - Expanded test coverage for document embedding and ignored document filtering.
  - Updated end-to-end tests to use dynamic content for improved reliability.
  - Added synchronization waits in document creation utilities to improve test stability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
darkskygit
2025-05-20 05:16:45 +00:00
parent 3c982d2b91
commit 6f9361caee
13 changed files with 213 additions and 35 deletions
+2 -2
View File
@@ -48,14 +48,14 @@
}, },
"queues.copilot": { "queues.copilot": {
"type": "object", "type": "object",
"description": "The config for copilot job queue\n@default {\"concurrency\":1}", "description": "The config for copilot job queue\n@default {\"concurrency\":5}",
"properties": { "properties": {
"concurrency": { "concurrency": {
"type": "number" "type": "number"
} }
}, },
"default": { "default": {
"concurrency": 1 "concurrency": 5
} }
}, },
"queues.doc": { "queues.doc": {
@@ -73,3 +73,19 @@ Generated by [AVA](https://avajs.dev).
name: 'file1', name: 'file1',
}, },
] ]
> should find docs to embed
1
> should not find docs to embed
0
> should find docs to embed
1
> should not find docs to embed
0
@@ -1,8 +1,12 @@
import { randomUUID } from 'node:crypto';
import { PrismaClient, User, Workspace } from '@prisma/client'; import { PrismaClient, User, Workspace } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
import { Config } from '../../base'; import { Config } from '../../base';
import { CopilotContextModel } from '../../models/copilot-context';
import { CopilotWorkspaceConfigModel } from '../../models/copilot-workspace'; import { CopilotWorkspaceConfigModel } from '../../models/copilot-workspace';
import { DocModel } from '../../models/doc';
import { UserModel } from '../../models/user'; import { UserModel } from '../../models/user';
import { WorkspaceModel } from '../../models/workspace'; import { WorkspaceModel } from '../../models/workspace';
import { createTestingModule, type TestingModule } from '../utils'; import { createTestingModule, type TestingModule } from '../utils';
@@ -12,8 +16,10 @@ interface Context {
config: Config; config: Config;
module: TestingModule; module: TestingModule;
db: PrismaClient; db: PrismaClient;
doc: DocModel;
user: UserModel; user: UserModel;
workspace: WorkspaceModel; workspace: WorkspaceModel;
copilotContext: CopilotContextModel;
copilotWorkspace: CopilotWorkspaceConfigModel; copilotWorkspace: CopilotWorkspaceConfigModel;
} }
@@ -23,8 +29,10 @@ test.before(async t => {
const module = await createTestingModule(); const module = await createTestingModule();
t.context.user = module.get(UserModel); t.context.user = module.get(UserModel);
t.context.workspace = module.get(WorkspaceModel); t.context.workspace = module.get(WorkspaceModel);
t.context.copilotContext = module.get(CopilotContextModel);
t.context.copilotWorkspace = module.get(CopilotWorkspaceConfigModel); t.context.copilotWorkspace = module.get(CopilotWorkspaceConfigModel);
t.context.db = module.get(PrismaClient); t.context.db = module.get(PrismaClient);
t.context.doc = module.get(DocModel);
t.context.config = module.get(Config); t.context.config = module.get(Config);
t.context.module = module; t.context.module = module;
}); });
@@ -136,6 +144,61 @@ test('should insert and search embedding', async t => {
); );
} }
} }
{
const docId = randomUUID();
await t.context.doc.upsert({
spaceId: workspace.id,
docId,
blob: Uint8Array.from([1, 2, 3]),
timestamp: Date.now(),
editorId: user.id,
});
const toBeEmbedDocIds = await t.context.copilotWorkspace.findDocsToEmbed(
workspace.id
);
t.snapshot(toBeEmbedDocIds.length, 'should find docs to embed');
await t.context.copilotContext.insertWorkspaceEmbedding(
workspace.id,
docId,
[
{
index: 0,
content: 'content',
embedding: Array.from({ length: 1024 }, () => 1),
},
]
);
const afterInsertEmbedding =
await t.context.copilotWorkspace.findDocsToEmbed(workspace.id);
t.snapshot(afterInsertEmbedding.length, 'should not find docs to embed');
}
{
const docId = randomUUID();
await t.context.doc.upsert({
spaceId: workspace.id,
docId,
blob: Uint8Array.from([1, 2, 3]),
timestamp: Date.now(),
editorId: user.id,
});
const toBeEmbedDocIds = await t.context.copilotWorkspace.findDocsToEmbed(
workspace.id
);
t.snapshot(toBeEmbedDocIds.length, 'should find docs to embed');
await t.context.copilotWorkspace.updateIgnoredDocs(workspace.id, [docId]);
const afterAddIgnoreDocs = await t.context.copilotWorkspace.findDocsToEmbed(
workspace.id
);
t.snapshot(afterAddIgnoreDocs.length, 'should not find docs to embed');
}
}); });
test('should check embedding table', async t => { test('should check embedding table', async t => {
@@ -48,7 +48,7 @@ defineModuleConfig('job', {
'queues.copilot': { 'queues.copilot': {
desc: 'The config for copilot job queue', desc: 'The config for copilot job queue',
default: { default: {
concurrency: 1, concurrency: 5,
}, },
schema, schema,
}, },
@@ -119,6 +119,11 @@ export class CopilotContextModel extends BaseModel {
} }
async hasWorkspaceEmbedding(workspaceId: string, docIds: string[]) { async hasWorkspaceEmbedding(workspaceId: string, docIds: string[]) {
const canEmbedding = await this.checkEmbeddingAvailable();
if (!canEmbedding) {
return new Set();
}
const existsIds = await this.db.aiWorkspaceEmbedding const existsIds = await this.db.aiWorkspaceEmbedding
.findMany({ .findMany({
where: { where: {
@@ -238,10 +243,11 @@ export class CopilotContextModel extends BaseModel {
WHERE WHERE
w."workspace_id" = ${workspaceId} w."workspace_id" = ${workspaceId}
AND i."doc_id" IS NULL AND i."doc_id" IS NULL
AND (w."embedding" <=> ${embedding}::vector) <= ${threshold}
ORDER BY "distance" ASC ORDER BY "distance" ASC
LIMIT ${topK}; LIMIT ${topK};
`; `;
return similarityChunks.filter(c => Number(c.distance) <= threshold); return similarityChunks;
} }
} }
@@ -34,6 +34,33 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
}); });
} }
/**
* find docs to embed, excluding ignored and already embedded docs
* newer docs will be list first
* @param workspaceId id of the workspace
* @returns docIds
*/
@Transactional()
async findDocsToEmbed(workspaceId: string): Promise<string[]> {
const docIds = await this.db.snapshot
.findMany({
where: {
workspaceId,
embedding: {
is: null,
},
},
select: { id: true },
})
.then(r => r.map(doc => doc.id));
const skipDocIds = await this.listIgnoredDocIds(workspaceId).then(
r => new Set(r.map(r => r.docId))
);
return docIds.filter(id => !skipDocIds.has(id));
}
@Transactional() @Transactional()
async updateIgnoredDocs( async updateIgnoredDocs(
workspaceId: string, workspaceId: string,
@@ -7,6 +7,7 @@ import { BaseModel } from './base';
declare global { declare global {
interface Events { interface Events {
'workspace.updated': Workspace;
'workspace.deleted': { 'workspace.deleted': {
id: string; id: string;
}; };
@@ -58,6 +59,9 @@ export class WorkspaceModel extends BaseModel {
this.logger.debug( this.logger.debug(
`Updated workspace ${workspaceId} with data ${JSON.stringify(data)}` `Updated workspace ${workspaceId} with data ${JSON.stringify(data)}`
); );
this.event.emit('workspace.updated', workspace);
return workspace; return workspace;
} }
@@ -4,6 +4,7 @@ import {
AFFiNELogger, AFFiNELogger,
BlobNotFound, BlobNotFound,
Config, Config,
DocNotFound,
EventBus, EventBus,
JobQueue, JobQueue,
mapAnyError, mapAnyError,
@@ -87,11 +88,37 @@ export class CopilotContextDocJob {
} }
} }
// @OnEvent('doc.indexer.updated') @OnEvent('workspace.updated')
async addDocEmbeddingQueueFromEvent( async onWorkspaceConfigUpdate({
// TODO(@darkskygit): replace this with real event type id,
doc: { workspaceId: string; docId: string } //Events['doc.indexer.updated'], enableDocEmbedding,
) { }: Events['workspace.updated']) {
if (enableDocEmbedding) {
// trigger workspace embedding
this.event.emit('workspace.embedding', {
workspaceId: id,
});
}
}
@OnEvent('workspace.embedding')
async addWorkspaceEmbeddingQueue({
workspaceId,
}: Events['workspace.embedding']) {
if (!this.supportEmbedding) return;
const toBeEmbedDocIds =
await this.models.copilotWorkspace.findDocsToEmbed(workspaceId);
for (const docId of toBeEmbedDocIds) {
await this.queue.add('copilot.embedding.docs', {
workspaceId,
docId,
});
}
}
@OnEvent('doc.indexer.updated')
async addDocEmbeddingQueueFromEvent(doc: Events['doc.indexer.updated']) {
if (!this.supportEmbedding) return; if (!this.supportEmbedding) return;
await this.queue.add('copilot.embedding.docs', { await this.queue.add('copilot.embedding.docs', {
@@ -100,11 +127,10 @@ export class CopilotContextDocJob {
}); });
} }
// @OnEvent('doc.indexer.deleted') @OnEvent('doc.indexer.deleted')
async deleteDocEmbeddingQueueFromEvent( async deleteDocEmbeddingQueueFromEvent(doc: Events['doc.indexer.deleted']) {
// TODO(@darkskygit): replace this with real event type if (!this.supportEmbedding) return;
doc: { workspaceId: string; docId: string } //Events['doc.indexer.deleted'],
) {
await this.models.copilotContext.deleteWorkspaceEmbedding( await this.models.copilotContext.deleteWorkspaceEmbedding(
doc.workspaceId, doc.workspaceId,
doc.docId doc.docId
@@ -193,13 +219,14 @@ export class CopilotContextDocJob {
docId, docId,
}: Jobs['copilot.embedding.docs']) { }: Jobs['copilot.embedding.docs']) {
if (!this.supportEmbedding) return; if (!this.supportEmbedding) return;
if (workspaceId === docId || docId.includes('$')) return;
try { try {
const content = await this.doc.getFullDocContent(workspaceId, docId); const content = await this.doc.getFullDocContent(workspaceId, docId);
if (content) { if (content) {
// no need to check if embeddings is empty, will throw internally // no need to check if embeddings is empty, will throw internally
const embeddings = await this.embeddingClient.getFileEmbeddings( const embeddings = await this.embeddingClient.getFileEmbeddings(
new File([content.summary], `${content.title}.md`) new File([content.summary], `${content.title || 'Untitled'}.md`)
); );
for (const chunks of embeddings) { for (const chunks of embeddings) {
@@ -209,6 +236,8 @@ export class CopilotContextDocJob {
chunks chunks
); );
} }
} else if (contextId) {
throw new DocNotFound({ spaceId: workspaceId, docId });
} }
} catch (error: any) { } catch (error: any) {
if (contextId) { if (contextId) {
@@ -8,6 +8,10 @@ import { parseDoc } from '../../../native';
declare global { declare global {
interface Events { interface Events {
'workspace.embedding': {
workspaceId: string;
};
'workspace.doc.embedding': Array<{ 'workspace.doc.embedding': Array<{
workspaceId: string; workspaceId: string;
docId: string; docId: string;
@@ -22,19 +22,23 @@ test.describe('AIChatWith/Collections', () => {
loggedInPage: page, loggedInPage: page,
utils, utils,
}) => { }) => {
const randomStr = Math.random().toString(36).substring(2, 6);
// Create two collections // Create two collections
await utils.editor.createCollectionAndDoc( await utils.editor.createCollectionAndDoc(
page, page,
'Collection 1', 'Collection 1',
'CollectionAAaa is a cute dog' `Collection${randomStr} is a cute dog`
); );
await utils.chatPanel.chatWithCollections(page, ['Collection 1']); await utils.chatPanel.chatWithCollections(page, ['Collection 1']);
await utils.chatPanel.makeChat(page, 'What is CollectionAAaa(Use English)'); await utils.chatPanel.makeChat(
page,
`What is Collection${randomStr}(Use English)`
);
await utils.chatPanel.waitForHistory(page, [ await utils.chatPanel.waitForHistory(page, [
{ {
role: 'user', role: 'user',
content: 'What is CollectionAAaa(Use English)', content: `What is Collection${randomStr}(Use English)`,
}, },
{ {
role: 'assistant', role: 'assistant',
@@ -45,7 +49,7 @@ test.describe('AIChatWith/Collections', () => {
await expect(async () => { await expect(async () => {
const { content, message } = const { content, message } =
await utils.chatPanel.getLatestAssistantMessage(page); await utils.chatPanel.getLatestAssistantMessage(page);
expect(content).toMatch(/CollectionAAaa.*dog/); expect(content).toMatch(new RegExp(`Collection${randomStr}.*dog`));
expect(await message.locator('affine-footnote-node').count()).toBe(1); expect(await message.locator('affine-footnote-node').count()).toBe(1);
}).toPass(); }).toPass();
}); });
@@ -54,17 +58,19 @@ test.describe('AIChatWith/Collections', () => {
loggedInPage: page, loggedInPage: page,
utils, utils,
}) => { }) => {
const randomStr1 = Math.random().toString(36).substring(2, 6);
const randomStr2 = Math.random().toString(36).substring(2, 6);
// Create two collections // Create two collections
await utils.editor.createCollectionAndDoc( await utils.editor.createCollectionAndDoc(
page, page,
'Collection 2', 'Collection 2',
'CollectionEEee is a cute cat' `Collection${randomStr1} is a cute cat`
); );
await utils.editor.createCollectionAndDoc( await utils.editor.createCollectionAndDoc(
page, page,
'Collection 3', 'Collection 3',
'CollectionFFff is a cute dog' `Collection${randomStr2} is a cute dog`
); );
await utils.chatPanel.chatWithCollections(page, [ await utils.chatPanel.chatWithCollections(page, [
@@ -73,12 +79,12 @@ test.describe('AIChatWith/Collections', () => {
]); ]);
await utils.chatPanel.makeChat( await utils.chatPanel.makeChat(
page, page,
'What is CollectionEEee? What is CollectionFFff?(Use English)' `What is Collection${randomStr1}? What is Collection${randomStr2}?(Use English)`
); );
await utils.chatPanel.waitForHistory(page, [ await utils.chatPanel.waitForHistory(page, [
{ {
role: 'user', role: 'user',
content: 'What is CollectionEEee? What is CollectionFFff?(Use English)', content: `What is Collection${randomStr1}? What is Collection${randomStr2}?(Use English)`,
}, },
{ {
role: 'assistant', role: 'assistant',
@@ -89,8 +95,8 @@ test.describe('AIChatWith/Collections', () => {
await expect(async () => { await expect(async () => {
const { content, message } = const { content, message } =
await utils.chatPanel.getLatestAssistantMessage(page); await utils.chatPanel.getLatestAssistantMessage(page);
expect(content).toMatch(/CollectionEEee.*cat/); expect(content).toMatch(new RegExp(`Collection${randomStr1}.*cat`));
expect(content).toMatch(/CollectionFFff.*dog/); expect(content).toMatch(new RegExp(`Collection${randomStr2}.*dog`));
expect(await message.locator('affine-footnote-node').count()).toBe(2); expect(await message.locator('affine-footnote-node').count()).toBe(2);
}).toPass(); }).toPass();
}); });
@@ -20,13 +20,21 @@ test.describe('AIChatWith/tags', () => {
loggedInPage: page, loggedInPage: page,
utils, utils,
}) => { }) => {
await utils.editor.createTagAndDoc(page, 'Tag 1', 'TagAAaa is a cute cat'); const randomStr = Math.random().toString(36).substring(2, 6);
await utils.editor.createTagAndDoc(
page,
'Tag 1',
`Tag${randomStr} is a cute cat`
);
await utils.chatPanel.chatWithTags(page, ['Tag 1']); await utils.chatPanel.chatWithTags(page, ['Tag 1']);
await utils.chatPanel.makeChat(page, 'What is TagAAaa(Use English)'); await utils.chatPanel.makeChat(
page,
`What is Tag${randomStr}(Use English)`
);
await utils.chatPanel.waitForHistory(page, [ await utils.chatPanel.waitForHistory(page, [
{ {
role: 'user', role: 'user',
content: 'What is TagAAaa(Use English)', content: `What is Tag${randomStr}(Use English)`,
}, },
{ {
role: 'assistant', role: 'assistant',
@@ -36,7 +44,7 @@ test.describe('AIChatWith/tags', () => {
await expect(async () => { await expect(async () => {
const { content, message } = const { content, message } =
await utils.chatPanel.getLatestAssistantMessage(page); await utils.chatPanel.getLatestAssistantMessage(page);
expect(content).toMatch(/TagAAaa.*cat/); expect(content).toMatch(new RegExp(`Tag${randomStr}.*cat`));
await expect(message.locator('affine-footnote-node')).toHaveCount(1); await expect(message.locator('affine-footnote-node')).toHaveCount(1);
}).toPass(); }).toPass();
}); });
@@ -45,17 +53,28 @@ test.describe('AIChatWith/tags', () => {
loggedInPage: page, loggedInPage: page,
utils, utils,
}) => { }) => {
await utils.editor.createTagAndDoc(page, 'Tag 2', 'TagEEee is a cute cat'); const randomStr1 = Math.random().toString(36).substring(2, 6);
await utils.editor.createTagAndDoc(page, 'Tag 3', 'TagFFff is a cute dog'); const randomStr2 = Math.random().toString(36).substring(2, 6);
await utils.editor.createTagAndDoc(
page,
'Tag 2',
`Tag${randomStr1} is a cute cat`
);
await utils.editor.createTagAndDoc(
page,
'Tag 3',
`Tag${randomStr2} is a cute dog`
);
await utils.chatPanel.chatWithTags(page, ['Tag 2', 'Tag 3']); await utils.chatPanel.chatWithTags(page, ['Tag 2', 'Tag 3']);
await utils.chatPanel.makeChat( await utils.chatPanel.makeChat(
page, page,
'What is TagEEee? What is TagFFff?(Use English)' `What is Tag${randomStr1}? What is Tag${randomStr2}?(Use English)`
); );
await utils.chatPanel.waitForHistory(page, [ await utils.chatPanel.waitForHistory(page, [
{ {
role: 'user', role: 'user',
content: 'What is TagEEee? What is TagFFff?(Use English)', content: `What is Tag${randomStr1}? What is Tag${randomStr2}?(Use English)`,
}, },
{ {
role: 'assistant', role: 'assistant',
@@ -65,8 +84,8 @@ test.describe('AIChatWith/tags', () => {
await expect(async () => { await expect(async () => {
const { content, message } = const { content, message } =
await utils.chatPanel.getLatestAssistantMessage(page); await utils.chatPanel.getLatestAssistantMessage(page);
expect(content).toMatch(/TagEEee.*cat/); expect(content).toMatch(new RegExp(`Tag${randomStr1}.*cat`));
expect(content).toMatch(/TagFFff.*dog/); expect(content).toMatch(new RegExp(`Tag${randomStr2}.*dog`));
await expect(message.locator('affine-footnote-node')).toHaveCount(2); await expect(message.locator('affine-footnote-node')).toHaveCount(2);
}).toPass(); }).toPass();
}); });
@@ -334,6 +334,8 @@ export class EditorUtils {
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
} }
} }
// sleep 1 sec to wait the doc sync
await page.waitForTimeout(1000);
} }
public static async createTagAndDoc( public static async createTagAndDoc(
@@ -362,6 +364,8 @@ export class EditorUtils {
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
} }
} }
// sleep 1 sec to wait the doc sync
await page.waitForTimeout(1000);
} }
public static async selectElementInEdgeless(page: Page, elements: string[]) { public static async selectElementInEdgeless(page: Page, elements: string[]) {