Skip to content
Open
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
2 changes: 1 addition & 1 deletion apps/backend/config.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 38 additions & 9 deletions apps/backend/src/__tests__/backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,51 +243,56 @@ 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);
});

it('should include a result if textScore > 0', () => {
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);
});

it('should include a result if vectorScore > 0.80 even when textScore is 0', () => {
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);
});

it('should include a result when BOTH textScore > 0 AND vectorScore > 0.80', () => {
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);
});

it('should exclude a result when textScore is 0 AND vectorScore is below 0.80', () => {
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);
});

it('should exclude a result when only textScore > 0 but textScore is very small', () => {
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);
});

Expand All @@ -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);
});

Expand All @@ -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);
});
});

// ==========================================
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
19 changes: 15 additions & 4 deletions apps/backend/src/utils/http/search.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Types } from 'mongoose';

import { loadConfig } from '../../config/loader.js';
export type ResultSource = 'faq' | 'community' | 'knowledge';

export interface SearchResultItem {
Expand Down Expand Up @@ -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)
);
}