Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* nothing about what a workspace search returns. A signed-in reader's own grants widen what they
* read, and a source whose reader must be proven live admits a candidate only once the proof
* holds, with the readable rows below it filling the page when it does not. A Search-marked knowledge base
* named explicitly by id is searched the same way.
* named explicitly by id is searched the same way, except that it keeps no vector candidates.
*/
import { createHash } from 'node:crypto'
import type { Principal } from '@sim/auth/principal'
Expand Down Expand Up @@ -48,10 +48,14 @@ afterAll(async () => {
await db.$client.end()
})

const ALL_MODES: readonly KnowledgeSearchMode[] = ['vector', 'hybrid']
/** A Search knowledge base's chunk writes delete its candidate rows, so only keyword ranking finds it. */
const KEYWORD_RANKED_MODES: readonly KnowledgeSearchMode[] = ['hybrid']

describe.each([
['a workspace knowledge base', false],
['a dormant search index', true],
] as const)('search of %s decides access on the document', (_label, isSearchIndex) => {
['a workspace knowledge base', false, ALL_MODES],
['a dormant search index', true, KEYWORD_RANKED_MODES],
] as const)('search of %s decides access on the document', (_label, isSearchIndex, modes) => {
const ids = createKnowledgeAclFixtureIds()
const baseId = generateId()
const readable = [generateId(), generateId(), generateId()]
Expand Down Expand Up @@ -161,41 +165,43 @@ describe.each([
return result.rows.map((row) => row.documentId).sort()
}

it.each(['vector', 'hybrid'] as const)(
'returns a signed-in reader the rows an actorless run gets (%s)',
async (mode) => {
const previousDebug = db.$client.options.debug
const statements: string[] = []
db.$client.options.debug = (_connection, query) => {
statements.push(query)
}
try {
const signedIn = await search(reader, mode)
const workspaceKey = await search(actorless, mode)
expect(signedIn).toEqual([...readable].sort())
expect(workspaceKey).toEqual(signedIn)
/** Nothing read the per-row machinery the search-index path keeps for itself. */
for (const fragment of [
'embedding_keyword_search',
'knowledge_projection_dirty',
'"embedding_search"."acl"',
'"embedding_search"."connector_id"',
]) {
expect(statements.filter((statement) => statement.includes(fragment))).toEqual([])
}
} finally {
db.$client.options.debug = previousDebug
it.each(modes)('returns a signed-in reader the rows an actorless run gets (%s)', async (mode) => {
const previousDebug = db.$client.options.debug
const statements: string[] = []
db.$client.options.debug = (_connection, query) => {
statements.push(query)
}
try {
const signedIn = await search(reader, mode)
const workspaceKey = await search(actorless, mode)
expect(signedIn).toEqual([...readable].sort())
expect(workspaceKey).toEqual(signedIn)
/** Nothing read the per-row machinery the search-index path keeps for itself. */
for (const fragment of [
'embedding_keyword_search',
'knowledge_projection_dirty',
'"embedding_search"."acl"',
'"embedding_search"."connector_id"',
]) {
expect(statements.filter((statement) => statement.includes(fragment))).toEqual([])
}
} finally {
db.$client.options.debug = previousDebug
}
)
})

it.each(['vector', 'hybrid'] as const)(
it.each(modes)(
'returns a signed-in reader the document shared with them alone as well (%s)',
async (mode) => {
const owner: Principal = { kind: 'session', userId: ids.aliceId, sessionId: 'fixture-owner' }
expect(await search(owner, mode)).toEqual([...readable, personal].sort())
}
)

it.runIf(isSearchIndex)('finds nothing by vector, since no candidate row is kept', async () => {
const owner: Principal = { kind: 'session', userId: ids.aliceId, sessionId: 'fixture-owner' }
expect(await search(owner, 'vector')).toEqual([])
})
})

describe('a workspace source whose reader must be proven live', () => {
Expand Down
56 changes: 53 additions & 3 deletions packages/db/knowledge-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,16 @@ const SEARCH_VECTOR_COLUMNS = SEARCH_VECTOR_WIDTHS.map((width) => widthColumn('v
/** The bit columns of `embedding_search`, whose width check requires exactly one. */
const SEARCH_BINARY_COLUMNS = ['"binary"', 'binary_384', 'binary_768', 'binary_1024', 'binary_3072']

/** The `ON CONFLICT DO UPDATE` assignments that take each column from the proposed row. */
function excludedAssignments(columns: readonly string[]): string {
return columns.map((column) => `${column} = EXCLUDED.${column}`).join(', ')
}

/**
* The halfvec projections of an embedding row, in {@link SEARCH_VECTOR_COLUMNS} order. Shortening
* is valid only for the two OpenAI models trained for prefix retrieval. These and the bit
* projections match what `sync_embedding_search()` from `0016_backfill_search_vectors` writes, so
* a pass over rows a synchronous writer wrote finds nothing to rewrite.
* projections are what {@link SYNC_EMBEDDING_SEARCH_FUNCTION} writes, so a pass over rows a
* synchronous writer wrote finds nothing to rewrite.
*/
function searchVectorProjections(prefix: string, shortened: string): string {
return SEARCH_VECTOR_WIDTHS.map((width) =>
Expand All @@ -94,6 +99,51 @@ function searchVectorShortened(model: string, prefix: string): string {
return `${model} IN ${SHORTENED_EMBEDDING_MODELS} AND ${prefix}.embedding_384 IS NULL`
}

/**
* The `embedding_search_sync` trigger function: projects an ordinary-KB chunk and deletes the row
* of a Search-KB chunk. Migration 0449 installs this exact body; fresh databases and pushes install
* it through `0016_backfill_search_vectors`.
*
* contract-pending(after Search KB candidate rows are purged and Search KBs are unwritable): drop
* the `search_index` delete branch — no Search-KB chunk write can reach the trigger then.
*/
export const SYNC_EMBEDDING_SEARCH_FUNCTION = `CREATE OR REPLACE FUNCTION sync_embedding_search()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
search_index boolean;
shortened boolean;
BEGIN
SELECT is_search_index, ${searchVectorShortened('embedding_model', 'NEW')}
INTO STRICT search_index, shortened
FROM knowledge_base WHERE id = NEW.knowledge_base_id;
IF search_index THEN
DELETE FROM embedding_search WHERE id = NEW.id;
RETURN NEW;
END IF;
INSERT INTO embedding_search
(id, knowledge_base_id, document_id, enabled, ${SEARCH_BINARY_COLUMNS.join(', ')},
${SEARCH_VECTOR_COLUMNS.join(', ')})
VALUES (NEW.id, NEW.knowledge_base_id, NEW.document_id, NEW.enabled,
${searchBinaryProjections('NEW')},
${searchVectorProjections('NEW', 'shortened')})
ON CONFLICT (id) DO UPDATE SET
${excludedAssignments(['knowledge_base_id', 'document_id', 'enabled', ...SEARCH_BINARY_COLUMNS, ...SEARCH_VECTOR_COLUMNS])};
RETURN NEW;
END;
$$`

const comparableSource = (source: string) => source.replace(/\s+/g, ' ').trim()

/**
* Whether a live `pg_proc.prosrc` is {@link SYNC_EMBEDDING_SEARCH_FUNCTION}'s body, ignoring
* whitespace: PostgreSQL stores the body between the `$$` quotes verbatim.
*/
export function isCurrentSyncEmbeddingSearchSource(prosrc: string): boolean {
return (
comparableSource(prosrc) === comparableSource(SYNC_EMBEDDING_SEARCH_FUNCTION.split('$$')[1])
)
}

export type KnowledgeProjection = 'embedding_search'

/** Active vector projections that mirror document access for legacy synchronous writers. */
Expand Down Expand Up @@ -138,7 +188,7 @@ function contentPageStatement(): string {
${searchVectorProjections('source', 'shortened')}
FROM source
ON CONFLICT (id) DO UPDATE SET
${[...compared, ...SEARCH_BINARY_COLUMNS].map((column) => `${column} = EXCLUDED.${column}`).join(', ')}
${excludedAssignments([...compared, ...SEARCH_BINARY_COLUMNS])}
WHERE (${compared.map((column) => `s.${column}`).join(', ')})
IS DISTINCT FROM (${compared.map((column) => `EXCLUDED.${column}`).join(', ')})
RETURNING s.id
Expand Down
18 changes: 0 additions & 18 deletions packages/db/maintenance/index.ts

This file was deleted.

Loading
Loading