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

@@ -350,7 +350,7 @@ GOOGLE_GENERATIVE_AI_API_KEY=...
ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_API_KEY=sk-ant-...
``` ```
## Scenario: Elder AI Analysis MVP With LangChain And pgvector ## Scenario: Elder AI Analysis MVP With LangChain And PostgreSQL JSONB Embeddings
### 1. Scope / Trigger ### 1. Scope / Trigger
@@ -369,7 +369,7 @@ ANTHROPIC_API_KEY=sk-ant-...
- `DELETE /api/ai/knowledge/[id]` - `DELETE /api/ai/knowledge/[id]`
- DB: - DB:
- `ai_knowledge_entries` - `ai_knowledge_entries`
- `ai_knowledge_chunks` with `vector(1536)` - `ai_knowledge_chunks` with `jsonb` `embedding` arrays
- `elder_ai_analyses` - `elder_ai_analyses`
- Runtime env: - Runtime env:
- `AI_API_KEY` required for generation and embedding. - `AI_API_KEY` required for generation and embedding.
@@ -394,6 +394,7 @@ ANTHROPIC_API_KEY=sk-ant-...
- `errorCategory` / `errorReason`: sanitized only for failed rows - `errorCategory` / `errorReason`: sanitized only for failed rows
- Failed rows must not store prompts, full resident context snapshots, API keys, or raw provider errors. - Failed rows must not store prompts, full resident context snapshots, API keys, or raw provider errors.
- Knowledge retrieval may return enabled platform knowledge and enabled active-organization knowledge only. - Knowledge retrieval may return enabled platform knowledge and enabled active-organization knowledge only.
- Knowledge retrieval first filters enabled platform / active-organization chunks in SQL, then computes cosine similarity in `modules/ai/server/knowledge.ts`; this keeps the MVP deployable on plain PostgreSQL without the `vector` extension.
- Completed elder analysis `resultJson.citations` must be derived from citation IDs actually referenced by `keyFindings[].citationIds` and `recommendations[].citationIds`; do not append unused available citations. - Completed elder analysis `resultJson.citations` must be derived from citation IDs actually referenced by `keyFindings[].citationIds` and `recommendations[].citationIds`; do not append unused available citations.
### 4. Validation & Error Matrix ### 4. Validation & Error Matrix
@@ -420,7 +421,7 @@ ANTHROPIC_API_KEY=sk-ant-...
### 6. Tests Required ### 6. Tests Required
- Permission seeding assertions for `ai:*` and `knowledge:*` default role grants. - Permission seeding assertions for `ai:*` and `knowledge:*` default role grants.
- Knowledge retrieval tests for platform shared, own organization, other organization, and disabled entry filtering. - Knowledge retrieval tests for platform shared, own organization, other organization, disabled entry filtering, and cosine-score ordering from JSONB embeddings.
- Analysis tests for missing config, schema validation failure, failed-history sanitization, and data-scope redaction. - Analysis tests for missing config, schema validation failure, failed-history sanitization, and data-scope redaction.
- Analysis tests for unknown citation rejection and referenced-only citation persistence. - Analysis tests for unknown citation rejection and referenced-only citation persistence.
- Route tests for auth/permission failures and standard response shape. - Route tests for auth/permission failures and standard response shape.

View File

@@ -63,8 +63,8 @@ Tests should mock database/provider boundaries and assert observable contracts,
## Compatibility ## Compatibility
No database migration is required for this hardening pass.
No new dependency is required. No new dependency is required.
Deployment hardening additionally changes the still-unapplied `0007` AI migration from pgvector to JSONB embeddings, so production PostgreSQL 18 Alpine can run the migration without a `vector` extension.
Existing persisted `resultJson.citations` remain readable; new analyses will have stricter citation lists. Existing persisted `resultJson.citations` remain readable; new analyses will have stricter citation lists.
## Rollback ## Rollback

View File

@@ -9,7 +9,8 @@
5. Add AI route tests for permission failures, invalid input, service failure propagation, success responses, and audit calls. 5. Add AI route tests for permission failures, invalid input, service failure propagation, success responses, and audit calls.
6. Improve knowledge UI and analysis dialog failure copy. 6. Improve knowledge UI and analysis dialog failure copy.
7. Clarify `ai:manage` permission description. 7. Clarify `ai:manage` permission description.
8. Run targeted AI tests, then project verification. 8. Align AI knowledge embedding storage to JSONB for production PostgreSQL without pgvector.
9. Run targeted AI tests, then project verification.
## Validation Commands ## Validation Commands

View File

@@ -6,13 +6,13 @@ Harden the existing elder AI analysis and knowledge retrieval MVP so the critica
## Background ## Background
The current MVP already implements LangChain-backed elder AI analysis, pgvector-backed knowledge retrieval, permission-scoped resident context, persisted analysis history, and knowledge management UI. The current MVP now implements LangChain-backed elder AI analysis, PostgreSQL JSONB-backed knowledge retrieval, permission-scoped resident context, persisted analysis history, and knowledge management UI.
Current evidence from repository inspection: Current evidence from repository inspection:
- AI service files live under `modules/ai/`. - AI service files live under `modules/ai/`.
- API routes live under `app/api/ai/`. - API routes live under `app/api/ai/`.
- AI tables and vector extension are in `modules/core/server/schema.ts` and `drizzle/0007_purple_puff_adder.sql`. - AI tables and JSONB embedding storage are in `modules/core/server/schema.ts` and `drizzle/0007_purple_puff_adder.sql`; the production target does not require PostgreSQL `vector` extension support.
- Existing project verification passes: `pnpm lint`, `pnpm type-check`, and `pnpm test`. - Existing project verification passes: `pnpm lint`, `pnpm type-check`, and `pnpm test`.
- No AI-specific test file currently covers schema validation, permission scoping, history redaction, knowledge organization isolation, disabled knowledge exclusion, or provider failure behavior. - No AI-specific test file currently covers schema validation, permission scoping, history redaction, knowledge organization isolation, disabled knowledge exclusion, or provider failure behavior.
- `normalizeCitations` currently appends allowed citations that the model did not necessarily cite, weakening the evidence chain. - `normalizeCitations` currently appends allowed citations that the model did not necessarily cite, weakening the evidence chain.

View File

@@ -1,4 +1,3 @@
CREATE EXTENSION IF NOT EXISTS vector;--> statement-breakpoint
CREATE TYPE "public"."ai_knowledge_scope" AS ENUM('platform', 'organization');--> statement-breakpoint CREATE TYPE "public"."ai_knowledge_scope" AS ENUM('platform', 'organization');--> statement-breakpoint
CREATE TYPE "public"."ai_knowledge_status" AS ENUM('enabled', 'disabled');--> statement-breakpoint CREATE TYPE "public"."ai_knowledge_status" AS ENUM('enabled', 'disabled');--> statement-breakpoint
CREATE TYPE "public"."elder_ai_analysis_status" AS ENUM('completed', 'failed');--> statement-breakpoint CREATE TYPE "public"."elder_ai_analysis_status" AS ENUM('completed', 'failed');--> statement-breakpoint
@@ -9,7 +8,7 @@ CREATE TABLE "ai_knowledge_chunks" (
"scope" "ai_knowledge_scope" NOT NULL, "scope" "ai_knowledge_scope" NOT NULL,
"chunk_index" integer NOT NULL, "chunk_index" integer NOT NULL,
"content" text NOT NULL, "content" text NOT NULL,
"embedding" vector(1536) NOT NULL, "embedding" jsonb DEFAULT '[]'::jsonb NOT NULL,
"source_title" text NOT NULL, "source_title" text NOT NULL,
"source_category" text DEFAULT '' NOT NULL, "source_category" text DEFAULT '' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL "created_at" timestamp with time zone DEFAULT now() NOT NULL
@@ -55,7 +54,6 @@ ALTER TABLE "elder_ai_analyses" ADD CONSTRAINT "elder_ai_analyses_elder_id_elder
ALTER TABLE "elder_ai_analyses" ADD CONSTRAINT "elder_ai_analyses_actor_account_id_accounts_id_fk" FOREIGN KEY ("actor_account_id") REFERENCES "public"."accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "elder_ai_analyses" ADD CONSTRAINT "elder_ai_analyses_actor_account_id_accounts_id_fk" FOREIGN KEY ("actor_account_id") REFERENCES "public"."accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "ai_knowledge_chunks_entry_lookup" ON "ai_knowledge_chunks" USING btree ("entry_id");--> statement-breakpoint CREATE INDEX "ai_knowledge_chunks_entry_lookup" ON "ai_knowledge_chunks" USING btree ("entry_id");--> statement-breakpoint
CREATE INDEX "ai_knowledge_chunks_scope_lookup" ON "ai_knowledge_chunks" USING btree ("scope","organization_id");--> statement-breakpoint CREATE INDEX "ai_knowledge_chunks_scope_lookup" ON "ai_knowledge_chunks" USING btree ("scope","organization_id");--> statement-breakpoint
CREATE INDEX "ai_knowledge_chunks_embedding_cosine_idx" ON "ai_knowledge_chunks" USING hnsw ("embedding" vector_cosine_ops);--> statement-breakpoint
CREATE INDEX "ai_knowledge_entries_scope_lookup" ON "ai_knowledge_entries" USING btree ("scope","status");--> statement-breakpoint CREATE INDEX "ai_knowledge_entries_scope_lookup" ON "ai_knowledge_entries" USING btree ("scope","status");--> statement-breakpoint
CREATE INDEX "ai_knowledge_entries_organization_lookup" ON "ai_knowledge_entries" USING btree ("organization_id","status");--> statement-breakpoint CREATE INDEX "ai_knowledge_entries_organization_lookup" ON "ai_knowledge_entries" USING btree ("organization_id","status");--> statement-breakpoint
CREATE INDEX "elder_ai_analyses_elder_lookup" ON "elder_ai_analyses" USING btree ("organization_id","elder_id","created_at");--> statement-breakpoint CREATE INDEX "elder_ai_analyses_elder_lookup" ON "elder_ai_analyses" USING btree ("organization_id","elder_id","created_at");--> statement-breakpoint

View File

@@ -277,9 +277,10 @@
}, },
"embedding": { "embedding": {
"name": "embedding", "name": "embedding",
"type": "vector(1536)", "type": "jsonb",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true,
"default": "'[]'::jsonb"
}, },
"source_title": { "source_title": {
"name": "source_title", "name": "source_title",

View File

@@ -130,7 +130,7 @@ type RetrievedChunkRow = {
title: string; title: string;
category: string; category: string;
content: string; content: string;
distance: number; embedding: number[];
}; };
type KnowledgeDatabaseFake = { type KnowledgeDatabaseFake = {
@@ -325,7 +325,7 @@ class SelectBuilder implements PromiseLike<SelectRow[]> {
title: chunk.sourceTitle, title: chunk.sourceTitle,
category: chunk.sourceCategory, category: chunk.sourceCategory,
content: chunk.content, 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"]); 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 () => { it("requires platform management permission before mutating platform knowledge", async () => {
const existingPlatformEntry = createEntry({ id: "entry-platform", scope: "platform", organizationId: null }); const existingPlatformEntry = createEntry({ id: "entry-platform", scope: "platform", organizationId: null });
useDatabase(createDatabaseFake({ entries: [existingPlatformEntry] })); useDatabase(createDatabaseFake({ entries: [existingPlatformEntry] }));

View File

@@ -1,7 +1,7 @@
import "server-only"; import "server-only";
import { OpenAIEmbeddings } from "@langchain/openai"; 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 { getAiRuntimeConfig } from "@/modules/ai/server/config";
import type { import type {
@@ -316,6 +316,29 @@ export async function deleteKnowledgeEntry(context: AuthContext, id: string): Pr
return { success: true, data: { entry: toKnowledgeEntry(row) } }; 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( export async function retrieveKnowledge(
organizationId: string, organizationId: string,
query: string, query: string,
@@ -333,7 +356,6 @@ export async function retrieveKnowledge(
} }
const database = getDatabase(); const database = getDatabase();
const distance = sql<number>`${aiKnowledgeChunks.embedding} <=> ${JSON.stringify(embedding)}::vector`;
const rows = await database const rows = await database
.select({ .select({
entryId: aiKnowledgeChunks.entryId, entryId: aiKnowledgeChunks.entryId,
@@ -341,25 +363,26 @@ export async function retrieveKnowledge(
title: aiKnowledgeChunks.sourceTitle, title: aiKnowledgeChunks.sourceTitle,
category: aiKnowledgeChunks.sourceCategory, category: aiKnowledgeChunks.sourceCategory,
content: aiKnowledgeChunks.content, content: aiKnowledgeChunks.content,
distance, embedding: aiKnowledgeChunks.embedding,
}) })
.from(aiKnowledgeChunks) .from(aiKnowledgeChunks)
.innerJoin(aiKnowledgeEntries, eq(aiKnowledgeEntries.id, aiKnowledgeChunks.entryId)) .innerJoin(aiKnowledgeEntries, eq(aiKnowledgeEntries.id, aiKnowledgeChunks.entryId))
.where(getEnabledChunkWhere(organizationId)) .where(getEnabledChunkWhere(organizationId));
.orderBy(distance)
.limit(options?.limit ?? DEFAULT_RETRIEVAL_LIMIT);
return { return {
success: true, success: true,
data: { data: {
results: rows.map((row) => ({ results: rows
.map((row) => ({
entryId: row.entryId, entryId: row.entryId,
chunkId: row.chunkId, chunkId: row.chunkId,
title: row.title, title: row.title,
category: row.category, category: row.category,
content: row.content, content: row.content,
score: 1 - row.distance, 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, timestamp,
uniqueIndex, uniqueIndex,
uuid, uuid,
vector,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
export const accountStatusEnum = pgEnum("account_status", ["active", "disabled", "pending"]); export const accountStatusEnum = pgEnum("account_status", ["active", "disabled", "pending"]);
@@ -555,7 +554,7 @@ export const aiKnowledgeChunks = pgTable("ai_knowledge_chunks", {
scope: aiKnowledgeScopeEnum("scope").notNull(), scope: aiKnowledgeScopeEnum("scope").notNull(),
chunkIndex: integer("chunk_index").notNull(), chunkIndex: integer("chunk_index").notNull(),
content: text("content").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(), sourceTitle: text("source_title").notNull(),
sourceCategory: text("source_category").notNull().default(""), sourceCategory: text("source_category").notNull().default(""),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),