Skip to content
21 changes: 21 additions & 0 deletions frontend/app/api/queries/useGetSearchQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { server } from "@/test-utils/msw/server";
import { createQueryWrapper, renderHook, waitFor } from "@/test-utils/render";
import {
type ChunkResult,
resolveScoreThreshold,
type SearchPayload,
useGetSearchQuery,
} from "./useGetSearchQuery";
Expand Down Expand Up @@ -390,3 +391,23 @@ describe("useGetSearchQuery", () => {
});
});
});

describe("resolveScoreThreshold", () => {
it("lowers the threshold to 1.0 for a short single token", () => {
expect(resolveScoreThreshold("docl", 1.25)).toBe(1.0);
expect(resolveScoreThreshold(" ab ", 3)).toBe(1.0);
});

it("never raises a threshold that is already below 1.0", () => {
expect(resolveScoreThreshold("docl", 0.5)).toBe(0.5);
});

it.each([
["docling", "longer than four characters"],
["a b", "more than one token"],
["*", "the wildcard"],
["", "empty"],
])("keeps the base threshold for %j (%s)", (query) => {
expect(resolveScoreThreshold(query, 1.25)).toBe(1.25);
});
});
28 changes: 20 additions & 8 deletions frontend/app/api/queries/useGetSearchQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,22 @@ const getFileIdentity = (chunk: ChunkResult): string => {
return "Untitled source";
};

/** Short single-token queries get a lower threshold so partial words still match. */
export function resolveScoreThreshold(
query: string,
baseScoreThreshold: number,
): number {
const normalized = query.trim();
const isShortSingleToken =
normalized !== "*" &&
normalized.length > 0 &&
normalized.length <= 4 &&
!normalized.includes(" ");
return isShortSingleToken
? Math.min(baseScoreThreshold, 1.0)
: baseScoreThreshold;
}

export const useGetSearchQuery = (
query: string,
queryData?: ParsedQueryData | null,
Expand All @@ -143,14 +159,10 @@ export const useGetSearchQuery = (

const baseScoreThreshold =
queryData?.scoreThreshold ?? SEARCH_CONSTANTS.DEFAULT_SCORE_THRESHOLD;
const isShortSingleTokenQuery =
normalizedQuery !== "*" &&
normalizedQuery.length > 0 &&
normalizedQuery.length <= 4 &&
!normalizedQuery.includes(" ");
const dynamicScoreThreshold = isShortSingleTokenQuery
? Math.min(baseScoreThreshold, 1.0)
: baseScoreThreshold;
const dynamicScoreThreshold = resolveScoreThreshold(
normalizedQuery,
baseScoreThreshold,
);

const searchPayload: SearchPayload = {
query: effectiveQuery,
Expand Down
236 changes: 236 additions & 0 deletions frontend/app/api/queries/useGetSearchSuggestions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { HttpResponse, http } from "msw";
import { describe, expect, it } from "vitest";
import { server } from "@/test-utils/msw/server";
import {
act,
createQueryWrapper,
renderHook,
waitFor,
} from "@/test-utils/render";
import {
type SearchSuggestResult,
SUGGEST_KEYWORD_DEBOUNCE_MS,
SUGGEST_MAX_CHARS,
useSearchSuggestions,
} from "./useGetSearchSuggestions";

interface SuggestRequest {
query: string;
semantic: boolean;
filters: Record<string, string[]>;
scoreThreshold?: number;
}

function result(
phase: "keyword" | "semantic",
query: string,
): SearchSuggestResult {
return {
phase,
query,
suggestions: [{ text: `${query}-${phase}`, files: 1, chunks: 2 }],
files: [],
};
}

function serveSuggest() {
const requests: SuggestRequest[] = [];
server.use(
http.post("/api/search/suggest", async ({ request }) => {
const body = (await request.json()) as SuggestRequest;
requests.push(body);
return HttpResponse.json(
result(body.semantic ? "semantic" : "keyword", body.query),
);
}),
);
return requests;
}

describe("useSearchSuggestions", () => {
it("sends a keyword request first, then upgrades to the semantic result", async () => {
const requests = serveSuggest();
const { result: hook } = renderHook(
() => useSearchSuggestions("docling", undefined),
{ wrapper: createQueryWrapper() },
);

await waitFor(() => expect(hook.current.data?.phase).toBe("semantic"));

expect(requests.map((r) => r.semantic)).toEqual(
expect.arrayContaining([false, true]),
);
expect(hook.current.data?.suggestions[0].text).toBe("docling-semantic");
expect(hook.current.isRefining).toBe(false);
});

it("sends nothing below the minimum length", async () => {
const requests = serveSuggest();
const { result: hook } = renderHook(
() => useSearchSuggestions("d", undefined),
{ wrapper: createQueryWrapper() },
);

await new Promise((resolve) =>
setTimeout(resolve, SUGGEST_KEYWORD_DEBOUNCE_MS + 100),
);

expect(requests).toHaveLength(0);
expect(hook.current.data).toBeUndefined();
});

it("does not request anything while disabled", async () => {
const requests = serveSuggest();
renderHook(() => useSearchSuggestions("docling", undefined, false), {
wrapper: createQueryWrapper(),
});

await new Promise((resolve) =>
setTimeout(resolve, SUGGEST_KEYWORD_DEBOUNCE_MS + 100),
);

expect(requests).toHaveLength(0);
});

it("forwards active filters and drops the wildcard sentinel", async () => {
const requests = serveSuggest();
renderHook(
() =>
useSearchSuggestions("docling", {
data_sources: ["a.pdf"],
owners: ["*"],
}),
{ wrapper: createQueryWrapper() },
);

await waitFor(() => expect(requests.length).toBeGreaterThan(0));
expect(requests[0].filters).toEqual({ data_sources: ["a.pdf"] });
});

it("forwards the base score threshold on both phases", async () => {
const requests = serveSuggest();
renderHook(() => useSearchSuggestions("docling", undefined, true, 2.5), {
wrapper: createQueryWrapper(),
});

await waitFor(() => expect(requests.some((r) => r.semantic)).toBe(true));
expect(requests.map((r) => r.scoreThreshold)).toEqual(
expect.arrayContaining([2.5]),
);
expect(requests.every((r) => r.scoreThreshold === 2.5)).toBe(true);
});

it("lowers the threshold for a short single token like the main search", async () => {
const requests = serveSuggest();
renderHook(() => useSearchSuggestions("docl", undefined, true, 2.5), {
wrapper: createQueryWrapper(),
});

await waitFor(() => expect(requests.some((r) => r.semantic)).toBe(true));
expect(requests.every((r) => r.scoreThreshold === 1.0)).toBe(true);
});

it("reports an error when the keyword request fails and nothing is cached", async () => {
server.use(
http.post("/api/search/suggest", () =>
HttpResponse.json(
{ error: "Suggestions unavailable" },
{ status: 500 },
),
),
);
const { result: hook } = renderHook(
() => useSearchSuggestions("docling", undefined),
{ wrapper: createQueryWrapper() },
);

await waitFor(() => expect(hook.current.isError).toBe(true));
expect(hook.current.data).toBeUndefined();
expect(hook.current.isRefining).toBe(false);
});

it("keeps showing keyword results when only the semantic request fails", async () => {
server.use(
http.post("/api/search/suggest", async ({ request }) => {
const body = (await request.json()) as SuggestRequest;
if (body.semantic) {
return HttpResponse.json({ error: "x" }, { status: 502 });
}
return HttpResponse.json(result("keyword", body.query));
}),
);
const { result: hook } = renderHook(
() => useSearchSuggestions("docling", undefined),
{ wrapper: createQueryWrapper() },
);

await waitFor(() => expect(hook.current.data?.phase).toBe("keyword"));
await waitFor(() => expect(hook.current.isRefining).toBe(false));
expect(hook.current.isError).toBe(false);
});

it("only debounces after typing pauses, collapsing a burst into one keyword request", async () => {
const requests = serveSuggest();
const { rerender } = renderHook(
({ q }) => useSearchSuggestions(q, undefined),
{ wrapper: createQueryWrapper(), initialProps: { q: "" } },
);

for (const q of ["do", "doc", "docl", "docli"]) {
await act(async () => {
rerender({ q });
await new Promise((resolve) => setTimeout(resolve, 40));
});
}

await waitFor(() =>
expect(requests.some((r) => !r.semantic && r.query === "docli")).toBe(
true,
),
);
const keywordQueries = requests
.filter((r) => !r.semantic)
.map((r) => r.query);
expect(keywordQueries).toEqual(["docli"]);
});

it("truncates over-long input to the server's length cap", async () => {
const requests = serveSuggest();
const { result: hook } = renderHook(
() => useSearchSuggestions("a".repeat(150), undefined),
{ wrapper: createQueryWrapper() },
);

await waitFor(() =>
expect(hook.current.data?.query).toBe("a".repeat(SUGGEST_MAX_CHARS)),
);
expect(requests.every((r) => r.query.length === SUGGEST_MAX_CHARS)).toBe(
true,
);
});

it("collapses internal whitespace so equivalent input shares one request", async () => {
const requests = serveSuggest();
const { result: hook } = renderHook(
() => useSearchSuggestions(" open rag ", undefined),
{ wrapper: createQueryWrapper() },
);

await waitFor(() => expect(hook.current.data?.query).toBe("open rag"));
expect(requests.every((r) => r.query === "open rag")).toBe(true);
});

it("hides the previous query's suggestions while the new query debounces", async () => {
serveSuggest();
const { result: hook, rerender } = renderHook(
({ q }) => useSearchSuggestions(q, undefined),
{ wrapper: createQueryWrapper(), initialProps: { q: "docling" } },
);
await waitFor(() => expect(hook.current.data?.query).toBe("docling"));

rerender({ q: "docker" });
expect(hook.current.data).toBeUndefined();

await waitFor(() => expect(hook.current.data?.query).toBe("docker"));
});
});
Loading
Loading