fix(search): hydrate Chroma matches in relevance order, not by date (#3881)

A search returns the newest of up to 100 semantic candidates rather than
the most relevant ones, so an older observation that matches precisely
loses to a newer, vaguely related one — including when the query is the
older observation's own verbatim text. Raising the limit surfaces it
again, which shows it was a candidate all along and was dropped by the
date cut rather than by relevance.

hybridSemanticHydrate asks Chroma for 100 matches, which arrive in
distance order, then hydrates them with orderBy 'date_desc'. That
re-sorts the ranking away before the limit is applied. The same file
already does this correctly in performChromaSemanticSearch, which
hydrates with orderBy 'relevance' — the mode that preserves the
caller-provided id order.

Use 'relevance' at the three call sites that pass ids straight from
Chroma: searchObservations, getTimelineByQuery, and
searchChromaForTimeline. The last one selects the single timeline
anchor, where 'date_desc' picked the most recent candidate rather than
the top-ranked one, disagreeing with the FTS fallback directly beneath
it that returns the best match.

Closes #3876
This commit is contained in:
kavish-19
2026-09-11 06:31:40 +05:30
committed by GitHub
parent 9fac73788b
commit a485a5254e
2 changed files with 64 additions and 3 deletions
+3 -3
View File
@@ -129,7 +129,7 @@ export class SearchManager {
private async searchChromaForTimeline(query: string, project?: string, platformSource?: string): Promise<ObservationSearchResult[]> {
return this.hybridSemanticHydrate(query, 'observation', project, platformSource, (ids) =>
this.sessionStore.getObservationsByIds(ids, { orderBy: 'date_desc', limit: 1, project, platformSource })
this.sessionStore.getObservationsByIds(ids, { orderBy: 'relevance', limit: 1, project, platformSource })
);
}
@@ -924,7 +924,7 @@ export class SearchManager {
try {
const limit = options.limit || 20;
results = await this.hybridSemanticHydrate(query, 'observation', options.project, options.platformSource, (ids) =>
this.sessionStore.getObservationsByIds(ids, { orderBy: 'date_desc', limit, project: options.project, platformSource: options.platformSource })
this.sessionStore.getObservationsByIds(ids, { orderBy: 'relevance', limit, project: options.project, platformSource: options.platformSource })
);
} catch (chromaError) {
const errorObject = chromaError instanceof Error ? chromaError : new Error(String(chromaError));
@@ -1103,7 +1103,7 @@ export class SearchManager {
logger.debug('SEARCH', 'Using hybrid semantic search for timeline query', {});
try {
results = await this.hybridSemanticHydrate(query, 'observation', project, platformSource, (ids) =>
this.sessionStore.getObservationsByIds(ids, { orderBy: 'date_desc', limit, project, platformSource })
this.sessionStore.getObservationsByIds(ids, { orderBy: 'relevance', limit, project, platformSource })
);
} catch (chromaError) {
const errorObject = chromaError instanceof Error ? chromaError : new Error(String(chromaError));
+61
View File
@@ -102,6 +102,67 @@ describe('SearchManager platform-scoped Chroma hydration', () => {
expect(result.observations).toEqual([observation]);
});
it('hydrates Chroma observation matches in relevance order, not by date', async () => {
// Chroma returns up to 100 candidates already ranked by distance. Hydrating
// them with orderBy 'date_desc' discards that ranking and yields the N
// newest candidates instead of the N most relevant, so an older exact match
// loses to a newer vague one. 'relevance' preserves the caller-provided id
// order (tests/services/sqlite/get-observations-by-ids-relevance.test.ts).
// performChromaSemanticSearch already does this; these two paths did not.
const olderExactMatch = 11;
const newerVagueMatch = 22;
const now = Date.now();
const makeManager = (getObservationsByIds: any) => new SearchManager(
{
searchObservations: mock(() => []),
searchSessions: mock(() => []),
searchUserPrompts: mock(() => []),
} as any,
{
getObservationsByIds,
getSessionSummariesByIds: mock(() => []),
getUserPromptsByIds: mock(() => []),
} as any,
{
queryChroma: mock(() => Promise.resolve({
// Chroma's own order: the exact match ranks first despite being older.
ids: [olderExactMatch, newerVagueMatch],
distances: [0.05, 0.4],
metadatas: [
{ sqlite_id: olderExactMatch, doc_type: 'observation', created_at_epoch: now - 86_400_000 },
{ sqlite_id: newerVagueMatch, doc_type: 'observation', created_at_epoch: now },
],
})),
} as any,
{} as any,
{} as any,
);
const searchHydrate = mock(() => []);
await makeManager(searchHydrate).searchObservations({ query: 'exact phrase', limit: 1 });
expect(searchHydrate).toHaveBeenCalledWith(
[olderExactMatch, newerVagueMatch],
expect.objectContaining({ orderBy: 'relevance' })
);
const timelineHydrate = mock(() => []);
await makeManager(timelineHydrate).getTimelineByQuery({ query: 'exact phrase', limit: 1 });
expect(timelineHydrate).toHaveBeenCalledWith(
[olderExactMatch, newerVagueMatch],
expect.objectContaining({ orderBy: 'relevance' })
);
// timeline() picks a single anchor via searchChromaForTimeline; the anchor
// should be the top-ranked match, not merely the most recent one.
const anchorHydrate = mock(() => []);
await makeManager(anchorHydrate).timeline({ query: 'exact phrase' });
expect(anchorHydrate).toHaveBeenCalledWith(
[olderExactMatch, newerVagueMatch],
expect.objectContaining({ orderBy: 'relevance' })
);
});
it('passes platformSource into Chroma session where filter and SQLite hydration', async () => {
const session = {
id: 6,