fix(search): cap vector hydration window

This commit is contained in:
Vincent Koc
2026-05-03 01:13:31 -07:00
parent f53b49041a
commit e76b72cdb1
2 changed files with 25 additions and 18 deletions
+11 -9
View File
@@ -1060,17 +1060,18 @@ describe("search helpers", () => {
expect(result[0].skill.slug).toBe("fallback-skill");
});
it("hydrates the stable max vector window for ordinary load-more searches", async () => {
it("hydrates a bounded vector window for ordinary load-more searches", async () => {
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
// Ordinary first-page and load-more searches use a stable recall floor, so
// candidateLimit starts at the Convex vector maximum.
const batch = Array.from({ length: 256 }, (_, i) => ({
const batch = Array.from({ length: 128 }, (_, i) => ({
_id: `skillEmbeddings:e${i}`,
_score: 0.5 - i * 0.001,
}));
const vectorSearchMock = vi.fn().mockResolvedValueOnce(batch);
const vectorSearchMock = vi.fn(
async (_table: unknown, _index: unknown, opts: { limit: number }) =>
batch.slice(0, opts.limit),
);
const hydrateCalls: string[][] = [];
const runQuery = vi.fn(
@@ -1101,9 +1102,10 @@ describe("search helpers", () => {
{ query: "test", limit: 50 },
);
expect(vectorSearchMock).toHaveBeenCalledTimes(1);
expect(hydrateCalls).toHaveLength(1);
expect(hydrateCalls[0]).toHaveLength(256);
expect(vectorSearchMock).toHaveBeenCalledTimes(2);
expect(hydrateCalls).toHaveLength(2);
expect(hydrateCalls[0]).toHaveLength(100);
expect(hydrateCalls[1]).toHaveLength(28);
});
it("merges fallback matches without duplicate skill ids", () => {
@@ -1239,7 +1241,7 @@ describe("soul search", () => {
it("hydrates only new soul embedding ids across vector iterations", async () => {
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
const firstBatch = Array.from({ length: 200 }, (_, i) => ({
const firstBatch = Array.from({ length: 100 }, (_, i) => ({
_id: i === 0 ? "soulEmbeddings:a" : `soulEmbeddings:filler${i}`,
_score: i === 0 ? 0.9 : 0.1,
}));
+14 -9
View File
@@ -60,6 +60,8 @@ const POPULARITY_WEIGHT = 0.08;
const FALLBACK_SCAN_LIMIT = 2000;
const MIN_STABLE_SEARCH_RECALL_LIMIT = 100;
const MAX_DIRECT_SKILL_SEARCH_CANDIDATES = 100;
const MIN_VECTOR_SEARCH_CANDIDATES = 50;
const MAX_VECTOR_SEARCH_CANDIDATES = 128;
const SKILL_CAPABILITY_TAG_SET = new Set<string>(SKILL_CAPABILITY_TAGS);
function getNextCandidateLimit(current: number, max: number) {
@@ -188,11 +190,13 @@ export const searchSkills: ReturnType<typeof action> = action({
// Keep ordinary first-page and load-more requests ranking the same recall pool
// before slicing, so expanding the display limit does not reshuffle the prefix.
const recallLimit = Math.max(limit, MIN_STABLE_SEARCH_RECALL_LIMIT);
// Convex vectorSearch max limit is 256; clamp candidate sizes accordingly.
// Keep the initial pool large enough to catch moderate-vector matches
// that win after lexical and popularity scoring, even for small limits.
const maxCandidate = Math.min(Math.max(recallLimit * 10, 200), 256);
let candidateLimit = Math.min(Math.max(recallLimit * 3, 200), 256);
// Keep the vector pool bounded; exact slug, prefix, and lexical fallback cover
// literal recall without hydrating hundreds of semantic candidates per search.
const maxCandidate = Math.min(
Math.max(limit * 4, MIN_VECTOR_SEARCH_CANDIDATES),
MAX_VECTOR_SEARCH_CANDIDATES,
);
let candidateLimit = Math.min(Math.max(limit * 2, MIN_VECTOR_SEARCH_CANDIDATES), maxCandidate);
let hydrated: SkillSearchEntry[] = [];
const seenEmbeddingIds = new Set<Id<"skillEmbeddings">>();
let scoreById = new Map<Id<"skillEmbeddings">, number>();
@@ -664,10 +668,11 @@ export const searchSouls: ReturnType<typeof action> = action({
vector = null;
}
const limit = args.limit ?? 10;
// Convex vectorSearch max limit is 256; clamp candidate sizes accordingly.
// Match searchSkills so soul search does not miss boosted exact matches.
const maxCandidate = Math.min(Math.max(limit * 10, 200), 256);
let candidateLimit = Math.min(Math.max(limit * 3, 200), 256);
const maxCandidate = Math.min(
Math.max(limit * 4, MIN_VECTOR_SEARCH_CANDIDATES),
MAX_VECTOR_SEARCH_CANDIDATES,
);
let candidateLimit = Math.min(Math.max(limit * 2, MIN_VECTOR_SEARCH_CANDIDATES), maxCandidate);
let hydrated: HydratedSoulEntry[] = [];
const seenEmbeddingIds = new Set<Id<"soulEmbeddings">>();
let scoreById = new Map<Id<"soulEmbeddings">, number>();