Skip to content

Commit ba6f412

Browse files
committed
improvement(knowledge): read the source card with the result and hold Tin readiness longer
1 parent 27a8603 commit ba6f412

6 files changed

Lines changed: 80 additions & 80 deletions

File tree

‎apps/sim/lib/knowledge/application/search.test.ts‎

Lines changed: 22 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ const mocks = vi.hoisted(() => ({
2121
generateEmbedding: vi.fn(),
2222
executeSearch: vi.fn(),
2323
retrieval: vi.fn(),
24-
getDocumentMetadata: vi.fn(),
2524
getTagDefinitions: vi.fn(),
2625
getTagDefinitionsBatch: vi.fn(),
2726
recordEmbeddingUsage: vi.fn(),
@@ -100,7 +99,6 @@ vi.mock('@/lib/knowledge/search/queries', () => ({
10099
retrieval: mocks.retrieval(),
101100
readAccess: (args[0] as { access: unknown }).access,
102101
}),
103-
getDocumentMetadataByIds: mocks.getDocumentMetadata,
104102
}))
105103

106104
vi.mock('@/lib/knowledge/tags/service', () => ({
@@ -170,6 +168,9 @@ describe('knowledge search application use case', () => {
170168
content: 'answer',
171169
chunkIndex: 0,
172170
distance: 0.2,
171+
filename: 'guide.pdf',
172+
sourceUrl: null,
173+
connectorType: null,
173174
tag1: null,
174175
tag2: null,
175176
tag3: null,
@@ -189,29 +190,11 @@ describe('knowledge search application use case', () => {
189190
boolean3: null,
190191
},
191192
])
192-
mocks.getDocumentMetadata.mockResolvedValue({
193-
'document-1': { filename: 'guide.pdf', sourceUrl: null },
194-
})
195193
mocks.getTagDefinitions.mockResolvedValue([])
196194
mocks.recordEmbeddingUsage.mockResolvedValue(undefined)
197195
mocks.importProvenance.mockResolvedValue({ imported: true, documentMetadata: {} })
198196
})
199197

200-
it('drops passages whose access was revoked before the final document metadata read', async () => {
201-
mocks.getDocumentMetadata.mockResolvedValue({})
202-
const result = await searchKnowledge.execute({
203-
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
204-
input: {
205-
workspaceId: 'workspace-1',
206-
knowledgeBaseIds: ['knowledge-1'],
207-
query: 'orion',
208-
topK: 20,
209-
},
210-
})
211-
expect(result.results).toEqual([])
212-
expect(result.totalResults).toBe(0)
213-
})
214-
215198
it.each([false, true])(
216199
'requires explicit partial-result support for empty incomplete searches (allowPartialResults=%s)',
217200
async (allowPartialResults) => {
@@ -512,7 +495,6 @@ describe('knowledge search application use case', () => {
512495
})
513496
).rejects.toThrow('Search superseded during embedding')
514497
expect(mocks.executeSearch).not.toHaveBeenCalled()
515-
expect(mocks.getDocumentMetadata).not.toHaveBeenCalled()
516498
})
517499

518500
it('does not start reranking or metadata reads after retrieval is cancelled', async () => {
@@ -536,7 +518,6 @@ describe('knowledge search application use case', () => {
536518
})
537519
).rejects.toThrow('Search superseded during retrieval')
538520
expect(mocks.rerank).not.toHaveBeenCalled()
539-
expect(mocks.getDocumentMetadata).not.toHaveBeenCalled()
540521
expect(mocks.searched).not.toHaveBeenCalled()
541522
})
542523

@@ -550,6 +531,9 @@ describe('knowledge search application use case', () => {
550531
content: 'first',
551532
chunkIndex: 0,
552533
distance: 0.1,
534+
filename: 'thread',
535+
sourceUrl: null,
536+
connectorType: 'slack',
553537
},
554538
{
555539
id: 'chunk-2',
@@ -558,6 +542,9 @@ describe('knowledge search application use case', () => {
558542
content: 'second',
559543
chunkIndex: 1,
560544
distance: 0.2,
545+
filename: 'thread',
546+
sourceUrl: null,
547+
connectorType: 'slack',
561548
},
562549
{
563550
id: 'chunk-3',
@@ -566,12 +553,11 @@ describe('knowledge search application use case', () => {
566553
content: 'third',
567554
chunkIndex: 0,
568555
distance: 0.3,
556+
filename: 'page',
557+
sourceUrl: null,
558+
connectorType: 'gitlab',
569559
},
570560
])
571-
mocks.getDocumentMetadata.mockResolvedValue({
572-
'document-1': { filename: 'thread', connectorType: 'slack' },
573-
'document-2': { filename: 'page', connectorType: 'gitlab' },
574-
})
575561
await searchKnowledge.execute({
576562
principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' },
577563
input: {
@@ -808,14 +794,20 @@ describe('knowledge search application use case', () => {
808794
it('keeps the source card metadata when a provenance registry is present', async () => {
809795
const registry = { markIncomplete: vi.fn() }
810796
const sourceModifiedAt = new Date('2026-08-20T12:00:00Z')
811-
mocks.getDocumentMetadata.mockResolvedValueOnce({
812-
'document-1': {
797+
mocks.executeSearch.mockResolvedValueOnce([
798+
{
799+
id: 'embedding-1',
800+
documentId: 'document-1',
801+
knowledgeBaseId: 'knowledge-1',
802+
content: 'answer',
803+
chunkIndex: 0,
804+
distance: 0.2,
805+
sourceModifiedAt,
813806
filename: 'guide.pdf',
814807
sourceUrl: 'https://example.com/guide',
815-
sourceModifiedAt,
816808
connectorType: 'google_drive',
817809
},
818-
})
810+
])
819811

820812
const result = await searchKnowledge.execute({
821813
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
@@ -828,7 +820,6 @@ describe('knowledge search application use case', () => {
828820
},
829821
})
830822

831-
expect(mocks.getDocumentMetadata).toHaveBeenCalledWith(['document-1'], expect.anything())
832823
expect(result.results[0]).toMatchObject({
833824
documentName: 'guide.pdf',
834825
sourceUrl: 'https://example.com/guide',

‎apps/sim/lib/knowledge/application/search.ts‎

Lines changed: 33 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ import { resolveKnowledgeSearchDefaults } from '@/lib/knowledge/search/defaults'
4242
import { annotateSearchDiagnostics, measureSearchStage } from '@/lib/knowledge/search/diagnostics'
4343
import type { WorkspaceSearchFilters } from '@/lib/knowledge/search/filters'
4444
import {
45-
getDocumentMetadataByIds,
4645
type RetrievalStatus,
4746
retrieveKnowledgeSearch,
4847
type SearchResult,
@@ -634,49 +633,40 @@ const searchKnowledgeUseCase = defineAuthorizedKnowledgeUseCase({
634633
])
635634
)
636635
/**
637-
* Always read: the provenance snapshot vouches for the name, URL, and tags
638-
* a model may see, but the source card's modified time and connector type
639-
* are only carried here, under the same access predicate as the search.
636+
* The provenance snapshot vouches for the name, URL, and tags a model may see; the source
637+
* card's modified time and connector type ride on the hydrated row, read under the same
638+
* predicate as the content.
640639
*/
641-
const basicDocumentMetadata = await measureSearchStage('metadata', () =>
642-
getDocumentMetadataByIds(
643-
rows.map((row) => row.documentId),
644-
retrieved.readAccess
645-
)
646-
)
647-
const results = rows
648-
.filter((row) => basicDocumentMetadata[row.documentId])
649-
.map((row): KnowledgeSearchItem => {
650-
const metadata: Record<string, unknown> = {}
651-
const tagMap = tagMaps.get(row.knowledgeBaseId)
652-
const provenanceDocument = provenanceSnapshot?.documentMetadata[row.documentId]
653-
const basicDocument = basicDocumentMetadata[row.documentId]
654-
const document = provenanceDocument ?? basicDocument
655-
for (const slot of ALL_TAG_SLOTS) {
656-
const value =
657-
provenanceDocument && slot.startsWith('tag')
658-
? provenanceDocument[
659-
slot as 'tag1' | 'tag2' | 'tag3' | 'tag4' | 'tag5' | 'tag6' | 'tag7'
660-
]
661-
: row[slot]
662-
if (value !== null && value !== undefined) metadata[tagMap?.get(slot) ?? slot] = value
663-
}
664-
const rerankerScore = rerankerScores.get(row.id)
665-
return {
666-
embeddingId: row.id,
667-
knowledgeBaseId: row.knowledgeBaseId,
668-
documentId: row.documentId,
669-
documentName: document?.filename ?? null,
670-
sourceUrl: document?.sourceUrl ?? null,
671-
sourceModifiedAt: basicDocument?.sourceModifiedAt ?? null,
672-
connectorType: basicDocument?.connectorType ?? null,
673-
content: row.content,
674-
chunkIndex: row.chunkIndex,
675-
metadata,
676-
similarity: hasQuery ? 1 - row.distance : 1,
677-
...(rerankerScore !== undefined ? { rerankerScore } : {}),
678-
}
679-
})
640+
const results = rows.map((row): KnowledgeSearchItem => {
641+
const metadata: Record<string, unknown> = {}
642+
const tagMap = tagMaps.get(row.knowledgeBaseId)
643+
const provenanceDocument = provenanceSnapshot?.documentMetadata[row.documentId]
644+
const document = provenanceDocument ?? row
645+
for (const slot of ALL_TAG_SLOTS) {
646+
const value =
647+
provenanceDocument && slot.startsWith('tag')
648+
? provenanceDocument[
649+
slot as 'tag1' | 'tag2' | 'tag3' | 'tag4' | 'tag5' | 'tag6' | 'tag7'
650+
]
651+
: row[slot]
652+
if (value !== null && value !== undefined) metadata[tagMap?.get(slot) ?? slot] = value
653+
}
654+
const rerankerScore = rerankerScores.get(row.id)
655+
return {
656+
embeddingId: row.id,
657+
knowledgeBaseId: row.knowledgeBaseId,
658+
documentId: row.documentId,
659+
documentName: document?.filename ?? null,
660+
sourceUrl: document?.sourceUrl ?? null,
661+
sourceModifiedAt: row.sourceModifiedAt ?? null,
662+
connectorType: row.connectorType ?? null,
663+
content: row.content,
664+
chunkIndex: row.chunkIndex,
665+
metadata,
666+
similarity: hasQuery ? 1 - row.distance : 1,
667+
...(rerankerScore !== undefined ? { rerankerScore } : {}),
668+
}
669+
})
680670
if (registry && provenanceSnapshot) {
681671
for (const [documentId, document] of Object.entries(provenanceSnapshot.documentMetadata)) {
682672
const renderedMetadata = results

‎apps/sim/lib/knowledge/search/diagnostics.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ export type SearchStage =
3838
| 'usage_recording'
3939
| 'overage_billing'
4040
| 'tag_definitions'
41-
| 'metadata'
4241
| 'metadata.sql'
4342
| 'metadata_provenance'
4443
| 'activity_recording'

‎apps/sim/lib/knowledge/search/queries.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,10 @@ describe('workspace-scoped vector retrieval', () => {
557557
expect(fields).toContain(String(schemaMock.embeddingSearch.vector512))
558558
expect(fields).not.toContain(String(schemaMock.embedding.embedding))
559559
expect(JSON.stringify(dbChainMockFns.leftJoin.mock.calls)).toContain('embeddingSearch')
560+
/** The source card's name, URL and connector type ride on the same read; no second pass. */
561+
expect(fields).toContain(String(schemaMock.document.filename))
562+
expect(fields).toContain(String(schemaMock.knowledgeConnector.connectorType))
563+
expect(JSON.stringify(dbChainMockFns.leftJoin.mock.calls)).toContain('knowledgeConnector')
560564
})
561565

562566
it('passes over a slice whose documents went away instead of ending the pool there', async () => {

‎apps/sim/lib/knowledge/search/queries.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ import type { StructuredFilter } from '@/lib/knowledge/types'
6565
import {
6666
embeddingCandidateDimensions,
6767
embeddingCandidateDistance,
68-
embeddingDistance,
6968
} from '@/lib/knowledge/vector-columns'
7069

7170
const logger = createLogger('KnowledgeSearchQueries')
@@ -345,6 +344,10 @@ export interface SearchResult {
345344
knowledgeBaseId: string
346345
/** When the source last changed the document; NULL for uploads and sources that do not say. */
347346
sourceModifiedAt: Date | null
347+
filename: string
348+
sourceUrl: string | null
349+
/** The connector type behind the document; NULL for an upload. */
350+
connectorType: string | null
348351
}
349352

350353
/**
@@ -440,6 +443,9 @@ const getSearchResultFields = (distanceExpr: SQL<number> | SQL.Aliased<number>)
440443
distance: distanceExpr,
441444
knowledgeBaseId: embedding.knowledgeBaseId,
442445
sourceModifiedAt: document.sourceModifiedAt,
446+
filename: document.filename,
447+
sourceUrl: document.sourceUrl,
448+
connectorType: knowledgeConnector.connectorType,
443449
})
444450

445451
/**
@@ -921,6 +927,7 @@ function hydrateSearchCandidates(
921927
.from(embedding)
922928
.innerJoin(document, eq(embedding.documentId, document.id))
923929
.leftJoin(embeddingSearch, eq(embeddingSearch.id, embedding.id))
930+
.leftJoin(knowledgeConnector, eq(knowledgeConnector.id, document.connectorId))
924931
.where(
925932
and(
926933
inArray(embedding.id, ids),
@@ -1018,6 +1025,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
10181025
.select(getSearchResultFields(sql<number>`0`.as('distance')))
10191026
.from(embedding)
10201027
.innerJoin(document, eq(embedding.documentId, document.id))
1028+
.leftJoin(knowledgeConnector, eq(knowledgeConnector.id, document.connectorId))
10211029
.where(
10221030
and(
10231031
eq(embedding.knowledgeBaseId, kbId),
@@ -1036,6 +1044,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
10361044
.select(getSearchResultFields(sql<number>`0`.as('distance')))
10371045
.from(embedding)
10381046
.innerJoin(document, eq(embedding.documentId, document.id))
1047+
.leftJoin(knowledgeConnector, eq(knowledgeConnector.id, document.connectorId))
10391048
.where(
10401049
and(
10411050
inArray(embedding.knowledgeBaseId, knowledgeBaseIds),
@@ -2320,15 +2329,21 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise
23202329
return []
23212330
}
23222331

2323-
/** Hydration pass: full rows plus the cosine distance, bounded to the survivors. */
2332+
/** Hydration pass: full rows plus the projection's distance, bounded to the survivors. */
23242333
const hydrated = await db
23252334
.select(
23262335
getSearchResultFields(
2327-
embeddingDistance(queryVector.dimensions, queryVector.vector).as('distance')
2336+
embeddingCandidateDistance(
2337+
queryVector.dimensions,
2338+
queryVector.vector,
2339+
queryVector.model
2340+
).as('distance')
23282341
)
23292342
)
23302343
.from(embedding)
23312344
.innerJoin(document, eq(embedding.documentId, document.id))
2345+
.leftJoin(embeddingSearch, eq(embeddingSearch.id, embedding.id))
2346+
.leftJoin(knowledgeConnector, eq(knowledgeConnector.id, document.connectorId))
23322347
.where(and(inArray(embedding.id, topIds), ...getVisibilityConditions(access, params.filters)))
23332348

23342349
const rowById = new Map(hydrated.map((row) => [row.id, row]))

‎apps/sim/lib/knowledge/search/tin-keyword.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ const logger = createLogger('TinKeywordSearch')
1111

1212
/**
1313
* How long a readiness answer holds. The index only becomes valid once the projection is fully
14-
* backfilled, and flipping the flag off takes effect within this window.
14+
* backfilled, and never invalid again; the flag is checked on every search regardless, so a
15+
* long hold costs nothing but the one read it saves each search.
1516
*/
16-
const READINESS_TTL_MS = 60 * 1000
17+
const READINESS_TTL_MS = 10 * 60 * 1000
1718
const SEARCH_INDEX_TTL_MS = 10 * 60 * 1000
1819

1920
/**

0 commit comments

Comments
 (0)