diff --git a/apps/backend/config.default.yaml b/apps/backend/config.default.yaml index 0eafede5..1c824044 100644 --- a/apps/backend/config.default.yaml +++ b/apps/backend/config.default.yaml @@ -73,7 +73,7 @@ search: keywordWeight: 0.4 rrfK: 60 # RRF constant maxResults: 20 - minScore: 0.3 + minScore: 0.80 embedding: model: "mixedbread-ai/mxbai-embed-large-v1" dimensions: 1024 diff --git a/apps/backend/src/__tests__/backend.test.ts b/apps/backend/src/__tests__/backend.test.ts index a09bbef9..d51bfb1e 100644 --- a/apps/backend/src/__tests__/backend.test.ts +++ b/apps/backend/src/__tests__/backend.test.ts @@ -243,11 +243,16 @@ describe('computeRRF', () => { // 4. Search: applySearchThreshold Tests // ========================================== describe('applySearchThreshold', () => { + // Boundary-value tests below pass an explicit `minVectorScore` so they + // exercise the filtering logic itself rather than whatever value happens + // to be set in config.default.yaml (`search.hybrid.minScore`). + const t = { minVectorScore: 0.80 }; + it('should return all results when no scores are set (both scores falsy)', () => { const results = [ { _id: { toString: () => 'a' } as any, source: 'faq' as const, score: 1 }, ] as any[]; - const filtered = applySearchThreshold(results); + const filtered = applySearchThreshold(results, t); expect(filtered).toHaveLength(0); }); @@ -255,7 +260,7 @@ describe('applySearchThreshold', () => { const results = [ { _id: { toString: () => 'a' } as any, source: 'faq' as const, score: 1, textScore: 0.5 } as any, ]; - const filtered = applySearchThreshold(results); + const filtered = applySearchThreshold(results, t); expect(filtered).toHaveLength(1); }); @@ -263,7 +268,7 @@ describe('applySearchThreshold', () => { const results = [ { _id: { toString: () => 'a' } as any, source: 'faq' as const, score: 1, textScore: 0, vectorScore: 0.85 } as any, ]; - const filtered = applySearchThreshold(results); + const filtered = applySearchThreshold(results, t); expect(filtered).toHaveLength(1); }); @@ -271,7 +276,7 @@ describe('applySearchThreshold', () => { const results = [ { _id: { toString: () => 'a' } as any, source: 'faq' as const, score: 1, textScore: 0.4, vectorScore: 0.9 } as any, ]; - const filtered = applySearchThreshold(results); + const filtered = applySearchThreshold(results, t); expect(filtered).toHaveLength(1); }); @@ -279,7 +284,7 @@ describe('applySearchThreshold', () => { const results = [ { _id: { toString: () => 'a' } as any, source: 'faq' as const, score: 1, textScore: 0, vectorScore: 0.75 } as any, ]; - const filtered = applySearchThreshold(results); + const filtered = applySearchThreshold(results, t); expect(filtered).toHaveLength(0); }); @@ -287,7 +292,7 @@ describe('applySearchThreshold', () => { const results = [ { _id: { toString: () => 'a' } as any, source: 'faq' as const, score: 1, textScore: 0.001, vectorScore: 0 } as any, ]; - const filtered = applySearchThreshold(results); + const filtered = applySearchThreshold(results, t); expect(filtered).toHaveLength(1); }); @@ -296,12 +301,12 @@ describe('applySearchThreshold', () => { { _id: { toString: () => 'a' } as any, source: 'faq' as const, score: 1, textScore: 0.3, vectorScore: 0.5 } as any, { _id: { toString: () => 'b' } as any, source: 'faq' as const, score: 1, textScore: 0, vectorScore: 0.6 } as any, ]; - const filtered = applySearchThreshold(results); + const filtered = applySearchThreshold(results, t); expect(filtered).toHaveLength(1); }); it('should handle empty results array', () => { - const filtered = applySearchThreshold([]); + const filtered = applySearchThreshold([], t); expect(filtered).toHaveLength(0); }); @@ -311,9 +316,33 @@ describe('applySearchThreshold', () => { { _id: { toString: () => 'a' } as any, source: 'faq' as const, score: 1, textScore: 0.8 } as any, { _id: { toString: () => 'b' } as any, source: 'faq' as const, score: 1, textScore: 0.5 } as any, ]; - const filtered = applySearchThreshold(results); + const filtered = applySearchThreshold(results, t); expect(filtered.map((r: any) => r._id.toString())).toEqual(['c', 'a', 'b']); }); + + // ── Configurable threshold (issue #168) ────────────────────────────────── + + it('should respect a custom minVectorScore override instead of the hardcoded 0.80', () => { + const results = [ + { _id: { toString: () => 'a' } as any, source: 'faq' as const, score: 1, textScore: 0, vectorScore: 0.5 } as any, + ]; + + expect(applySearchThreshold(results, { minVectorScore: 0.4 })).toHaveLength(1); + expect(applySearchThreshold(results, { minVectorScore: 0.6 })).toHaveLength(0); + }); + + it('should fall back to search.hybrid.minScore from config when no override is passed', () => { + const passing = [ + { _id: { toString: () => 'a' } as any, source: 'faq' as const, score: 1, textScore: 0, vectorScore: 0.85 } as any, + ]; + + const failing = [ + { _id: { toString: () => 'b' } as any, source: 'faq' as const, score: 1, textScore: 0, vectorScore: 0.75 } as any, + ]; + + expect(applySearchThreshold(passing)).toHaveLength(1); + expect(applySearchThreshold(failing)).toHaveLength(0); + }); }); // ========================================== diff --git a/apps/backend/src/config/schema.ts b/apps/backend/src/config/schema.ts index 6920f0b2..713dd54d 100644 --- a/apps/backend/src/config/schema.ts +++ b/apps/backend/src/config/schema.ts @@ -66,7 +66,7 @@ export const ConfigSchema = z.object({ keywordWeight: z.number().default(0.4), rrfK: z.number().default(60), maxResults: z.number().default(20), - minScore: z.number().default(0.3), + minScore: z.number().default(0.80), }), embedding: z.object({ model: z.string().default('mixedbread-ai/mxbai-embed-large-v1'), diff --git a/apps/backend/src/utils/http/search.ts b/apps/backend/src/utils/http/search.ts index 9863bedb..9d3a2d74 100644 --- a/apps/backend/src/utils/http/search.ts +++ b/apps/backend/src/utils/http/search.ts @@ -1,5 +1,5 @@ import { Types } from 'mongoose'; - +import { loadConfig } from '../../config/loader.js'; export type ResultSource = 'faq' | 'community' | 'knowledge'; export interface SearchResultItem { @@ -78,10 +78,21 @@ export function computeRRF( /** * Applies the platform's threshold filter to remove irrelevant results. * A document is kept if it has any keyword match (textScore > 0) OR - * a strong semantic match (vectorScore > 0.80). + * a strong semantic match (vectorScore > the configured minimum). + * + * The vector threshold comes from `search.hybrid.minScore` unless the caller + * overrides it via `thresholds.minVectorScore`. */ -export function applySearchThreshold(results: SearchResultItem[]): SearchResultItem[] { +export function applySearchThreshold( + results: SearchResultItem[], + thresholds?: { minVectorScore?: number } +): SearchResultItem[] { + const minVectorScore = + thresholds?.minVectorScore ?? loadConfig().search.hybrid.minScore; + return results.filter( - (doc) => (doc.textScore && doc.textScore > 0) || (doc.vectorScore && doc.vectorScore > 0.80) + (doc) => + (doc.textScore && doc.textScore > 0) || + (doc.vectorScore && doc.vectorScore > minVectorScore) ); } \ No newline at end of file