fix(ai-chat): live streaming, open-page context, any-dimension embeddings" -m "- streaming: give useChat a STABLE store id (chatId ?? per-mount generated)
so the v6 hook stops re-creating its store every render on a new chat
(which wiped the optimistic user message + streamed deltas, so nothing
showed until the turn finished). Also send X-Accel-Buffering:no + flushHeaders.
- context: client sends the currently-open page {id,title}; the system prompt
tells the agent which page 'this page' refers to (it reads it via its
CASL-scoped getPage tool; id is prompt-context only, no server-side fetch).
- embeddings: make page_embeddings.embedding dimension-agnostic (drop the
HNSW index + ALTER to vector), remove the hard 1536 guard, filter search by
model_dimensions — so 3072-dim (and any) models index instead of being
skipped. Seq-scan <=> search (wiki scale); existing pages reindex on next edit.
This commit is contained in:
@@ -12,13 +12,12 @@ import { AiService } from '../../../integrations/ai/ai.service';
|
||||
import { AiEmbeddingNotConfiguredException } from '../../../integrations/ai/ai-embedding-not-configured.exception';
|
||||
import { jsonToText } from '../../../collaboration/collaboration.util';
|
||||
|
||||
/**
|
||||
* Embedding dimension the `page_embeddings.embedding` column is fixed at
|
||||
* (`vector(1536)`). A model whose vectors have a different dimension cannot fit
|
||||
* this column — v1 limitation (§14[M7]); see the dimension guard in
|
||||
* `reindexPage`.
|
||||
*/
|
||||
const EMBEDDING_DIMENSIONS = 1536;
|
||||
// NOTE: the `page_embeddings.embedding` column is now dimension-agnostic
|
||||
// (bare pgvector `vector`, see migration 20260617T140000), so the indexer
|
||||
// stores WHATEVER dimension the configured model returns and records it per row
|
||||
// in `model_dimensions`. There is no fixed-dimension guard any more; search
|
||||
// compares only same-dimension rows. Trade-off: a dimension-agnostic column has
|
||||
// no ANN index, so retrieval is a seq scan with `<=>` (fine at wiki scale).
|
||||
|
||||
// RecursiveCharacterTextSplitter settings. ~1000 chars per chunk with 200 char
|
||||
// overlap is a reasonable default for prose retrieval (§6.7 stage D).
|
||||
@@ -31,7 +30,7 @@ const CHUNK_OVERLAP = 200;
|
||||
* cosine ANN retrieval.
|
||||
*
|
||||
* Everything is workspace-scoped. Reindex HARD-replaces a page's rows (delete +
|
||||
* insert in one transaction) so the HNSW index never serves stale vectors.
|
||||
* insert in one transaction) so search never serves stale vectors.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EmbeddingIndexerService {
|
||||
@@ -48,9 +47,9 @@ export class EmbeddingIndexerService {
|
||||
* (Re)build the embeddings for a single page.
|
||||
*
|
||||
* No-ops quietly when embeddings are unconfigured (so the queue never dies on
|
||||
* an unconfigured workspace) and when a non-matching embedding dimension is
|
||||
* returned (skip + single warning — §14[M7]). Deleted/empty pages have their
|
||||
* rows purged and return.
|
||||
* an unconfigured workspace). Any embedding dimension is accepted; the only
|
||||
* defensive skip is a page whose chunks somehow yield mixed vector lengths.
|
||||
* Deleted/empty pages have their rows purged and return.
|
||||
*/
|
||||
async reindexPage(pageId: string): Promise<void> {
|
||||
const page = await this.pageRepo.findById(pageId, {
|
||||
@@ -115,17 +114,21 @@ export class EmbeddingIndexerService {
|
||||
// Embed all chunks in one batch.
|
||||
const vectors = await this.aiService.embedTexts(workspaceId, chunks);
|
||||
|
||||
// Dimension guard (§14[M7]): the column is a fixed vector(1536). A model
|
||||
// with a different output dimension cannot be stored — skip the page and
|
||||
// warn once rather than failing every row insert.
|
||||
const wrongDim = vectors.find((v) => v.length !== EMBEDDING_DIMENSIONS);
|
||||
if (wrongDim) {
|
||||
this.logger.warn(
|
||||
`reindexPage: embedding dimension ${wrongDim.length} != ${EMBEDDING_DIMENSIONS} ` +
|
||||
`for workspace ${workspaceId}; skipping page ${pageId}. ` +
|
||||
`The embedding column is fixed at ${EMBEDDING_DIMENSIONS} dims (v1 limitation §14[M7]).`,
|
||||
);
|
||||
return;
|
||||
// The column is dimension-agnostic, so ANY model dimension is stored as-is.
|
||||
// Defensive sanity check only: all chunks of ONE page come from the SAME
|
||||
// model and must share a dimension. A page that yields mixed lengths would
|
||||
// poison the per-dimension search filter, so skip it with a warning rather
|
||||
// than insert inconsistent rows.
|
||||
const expectedDim = vectors[0]?.length;
|
||||
if (expectedDim != null) {
|
||||
const mixed = vectors.find((v) => v.length !== expectedDim);
|
||||
if (mixed) {
|
||||
this.logger.warn(
|
||||
`reindexPage: mixed embedding dimensions (${expectedDim} vs ${mixed.length}) ` +
|
||||
`for workspace ${workspaceId}; skipping page ${pageId}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const rows = this.buildChunkRows(
|
||||
@@ -136,8 +139,8 @@ export class EmbeddingIndexerService {
|
||||
modelName,
|
||||
);
|
||||
|
||||
// HARD replace in one transaction: delete then insert so the ANN index
|
||||
// never holds stale vectors for this page.
|
||||
// HARD replace in one transaction: delete then insert so search never
|
||||
// returns stale vectors for this page.
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await this.pageEmbeddingRepo.deleteByPage(pageId, workspaceId, trx);
|
||||
await this.pageEmbeddingRepo.insertChunks(rows, trx);
|
||||
|
||||
Reference in New Issue
Block a user