fix: remove pgvector dependency from AI knowledge retrieval

This commit is contained in:
2026-07-06 10:36:21 -07:00
parent 0d5093ac6c
commit f74b7f3ca0
9 changed files with 78 additions and 30 deletions

View File

@@ -130,7 +130,7 @@ type RetrievedChunkRow = {
title: string;
category: string;
content: string;
distance: number;
embedding: number[];
};
type KnowledgeDatabaseFake = {
@@ -325,7 +325,7 @@ class SelectBuilder implements PromiseLike<SelectRow[]> {
title: chunk.sourceTitle,
category: chunk.sourceCategory,
content: chunk.content,
distance: 0.1 + rows.length * 0.1,
embedding: chunk.embedding,
});
}
}
@@ -407,6 +407,31 @@ describe("AI knowledge service", () => {
expect(embeddingMocks.embedDocuments).toHaveBeenCalledWith(["night fall risk"]);
});
it("orders retrieved chunks by cosine similarity after scope filtering", async () => {
const weakEntry = createEntry({ id: "entry-weak", title: "Weak guidance" });
const strongEntry = createEntry({ id: "entry-strong", title: "Strong guidance" });
useDatabase(createDatabaseFake({
entries: [weakEntry, strongEntry],
chunks: [
createChunk(weakEntry, { embedding: [0, 1, 0] }),
createChunk(strongEntry, { embedding: [1, 0, 0] }),
],
}));
embeddingMocks.embedDocuments.mockResolvedValueOnce([[1, 0, 0]]);
const result = await retrieveKnowledge("org-1", "night fall risk", { limit: 2 });
expect(result).toEqual({
success: true,
data: {
results: [
expect.objectContaining({ entryId: "entry-strong", score: 1 }),
expect.objectContaining({ entryId: "entry-weak", score: 0 }),
],
},
});
});
it("requires platform management permission before mutating platform knowledge", async () => {
const existingPlatformEntry = createEntry({ id: "entry-platform", scope: "platform", organizationId: null });
useDatabase(createDatabaseFake({ entries: [existingPlatformEntry] }));

View File

@@ -1,7 +1,7 @@
import "server-only";
import { OpenAIEmbeddings } from "@langchain/openai";
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
import { and, desc, eq, isNull, or } from "drizzle-orm";
import { getAiRuntimeConfig } from "@/modules/ai/server/config";
import type {
@@ -316,6 +316,29 @@ export async function deleteKnowledgeEntry(context: AuthContext, id: string): Pr
return { success: true, data: { entry: toKnowledgeEntry(row) } };
}
function calculateCosineSimilarity(left: readonly number[], right: readonly number[]): number {
if (left.length === 0 || right.length === 0 || left.length !== right.length) {
return 0;
}
let dotProduct = 0;
let leftMagnitude = 0;
let rightMagnitude = 0;
for (let index = 0; index < left.length; index += 1) {
const leftValue = left[index] ?? 0;
const rightValue = right[index] ?? 0;
dotProduct += leftValue * rightValue;
leftMagnitude += leftValue * leftValue;
rightMagnitude += rightValue * rightValue;
}
if (leftMagnitude === 0 || rightMagnitude === 0) {
return 0;
}
return dotProduct / (Math.sqrt(leftMagnitude) * Math.sqrt(rightMagnitude));
}
export async function retrieveKnowledge(
organizationId: string,
query: string,
@@ -333,7 +356,6 @@ export async function retrieveKnowledge(
}
const database = getDatabase();
const distance = sql<number>`${aiKnowledgeChunks.embedding} <=> ${JSON.stringify(embedding)}::vector`;
const rows = await database
.select({
entryId: aiKnowledgeChunks.entryId,
@@ -341,25 +363,26 @@ export async function retrieveKnowledge(
title: aiKnowledgeChunks.sourceTitle,
category: aiKnowledgeChunks.sourceCategory,
content: aiKnowledgeChunks.content,
distance,
embedding: aiKnowledgeChunks.embedding,
})
.from(aiKnowledgeChunks)
.innerJoin(aiKnowledgeEntries, eq(aiKnowledgeEntries.id, aiKnowledgeChunks.entryId))
.where(getEnabledChunkWhere(organizationId))
.orderBy(distance)
.limit(options?.limit ?? DEFAULT_RETRIEVAL_LIMIT);
.where(getEnabledChunkWhere(organizationId));
return {
success: true,
data: {
results: rows.map((row) => ({
entryId: row.entryId,
chunkId: row.chunkId,
title: row.title,
category: row.category,
content: row.content,
score: 1 - row.distance,
})),
results: rows
.map((row) => ({
entryId: row.entryId,
chunkId: row.chunkId,
title: row.title,
category: row.category,
content: row.content,
score: calculateCosineSimilarity(row.embedding, embedding),
}))
.sort((left, right) => right.score - left.score)
.slice(0, options?.limit ?? DEFAULT_RETRIEVAL_LIMIT),
},
};
}

View File

@@ -11,7 +11,6 @@ import {
timestamp,
uniqueIndex,
uuid,
vector,
} from "drizzle-orm/pg-core";
export const accountStatusEnum = pgEnum("account_status", ["active", "disabled", "pending"]);
@@ -555,7 +554,7 @@ export const aiKnowledgeChunks = pgTable("ai_knowledge_chunks", {
scope: aiKnowledgeScopeEnum("scope").notNull(),
chunkIndex: integer("chunk_index").notNull(),
content: text("content").notNull(),
embedding: vector("embedding", { dimensions: AI_KNOWLEDGE_EMBEDDING_DIMENSIONS }).notNull(),
embedding: jsonb("embedding").$type<number[]>().notNull().default(sql`'[]'::jsonb`),
sourceTitle: text("source_title").notNull(),
sourceCategory: text("source_category").notNull().default(""),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),