feat(server): attachment embedding (#13348)

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

* **New Features**
* Added support for managing "blobs" in Copilot context, including
adding and removing blobs via new GraphQL mutations and UI fields.
* Introduced tracking and querying of blob embeddings within workspaces,
enabling search and similarity matching for blob content.
* Extended Copilot context and workspace APIs, schema, and UI to display
and manage blobs alongside existing documents and files.

* **Bug Fixes**
* Updated context and embedding status logic to handle blobs, ensuring
accurate status reporting and embedding management.

* **Tests**
* Added and updated test cases and snapshots to cover blob embedding
insertion, matching, and removal scenarios.

* **Documentation**
* Updated GraphQL schema and TypeScript types to reflect new
blob-related fields and mutations.

* **Chores**
* Refactored and cleaned up code to support new blob entity and
embedding logic, including renaming and updating internal methods and
types.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2025-07-31 06:07:28 +08:00
committed by GitHub
parent b6a5bc052e
commit feb42e34be
24 changed files with 689 additions and 84 deletions
@@ -7,6 +7,7 @@ import { Prisma, PrismaClient } from '@prisma/client';
import { PaginationInput } from '../base';
import { BaseModel } from './base';
import type {
BlobChunkSimilarity,
CopilotWorkspaceFile,
CopilotWorkspaceFileMetadata,
Embedding,
@@ -256,19 +257,19 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
async checkEmbeddingAvailable(): Promise<boolean> {
const [{ count }] = await this.db.$queryRaw<
{ count: number }[]
>`SELECT count(1) FROM pg_tables WHERE tablename in ('ai_workspace_embeddings', 'ai_workspace_file_embeddings')`;
return Number(count) === 2;
>`SELECT count(1) FROM pg_tables WHERE tablename in ('ai_workspace_embeddings', 'ai_workspace_file_embeddings', 'ai_workspace_blob_embeddings')`;
return Number(count) === 3;
}
private processEmbeddings(
workspaceId: string,
fileId: string,
fileOrBlobId: string,
embeddings: Embedding[]
) {
const groups = embeddings.map(e =>
[
workspaceId,
fileId,
fileOrBlobId,
e.index,
e.content,
Prisma.raw(`'[${e.embedding.join(',')}]'`),
@@ -378,6 +379,61 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
return similarityChunks.filter(c => Number(c.distance) <= threshold);
}
@Transactional()
async insertBlobEmbeddings(
workspaceId: string,
blobId: string,
embeddings: Embedding[]
) {
if (embeddings.length === 0) {
this.logger.warn(
`No embeddings provided for workspaceId: ${workspaceId}, blobId: ${blobId}. Skipping insertion.`
);
return;
}
const values = this.processEmbeddings(workspaceId, blobId, embeddings);
await this.db.$executeRaw`
INSERT INTO "ai_workspace_blob_embeddings"
("workspace_id", "blob_id", "chunk", "content", "embedding") VALUES ${values}
ON CONFLICT (workspace_id, blob_id, chunk) DO NOTHING;
`;
}
async matchBlobEmbedding(
workspaceId: string,
embedding: number[],
topK: number,
threshold: number
): Promise<BlobChunkSimilarity[]> {
if (!(await this.allowEmbedding(workspaceId))) {
return [];
}
const similarityChunks = await this.db.$queryRaw<
Array<BlobChunkSimilarity>
>`
SELECT
e."blob_id" as "blobId",
e."chunk",
e."content",
e."embedding" <=> ${embedding}::vector as "distance"
FROM "ai_workspace_blob_embeddings" e
WHERE e.workspace_id = ${workspaceId}
ORDER BY "distance" ASC
LIMIT ${topK};
`;
return similarityChunks.filter(c => Number(c.distance) <= threshold);
}
async removeBlob(workspaceId: string, blobId: string) {
await this.db.$executeRaw`
DELETE FROM "ai_workspace_blob_embeddings"
WHERE workspace_id = ${workspaceId} AND blob_id = ${blobId};
`;
return true;
}
async removeFile(workspaceId: string, fileId: string) {
// embeddings will be removed by foreign key constraint
await this.db.aiWorkspaceFiles.deleteMany({