search: cache results

This commit is contained in:
2026-05-30 17:57:56 -07:00
parent 5b7ed25522
commit 600bc3befc
8 changed files with 148 additions and 11 deletions

View File

@@ -0,0 +1,8 @@
-- Add normalized search query lookup key for cache/reuse behavior.
ALTER TABLE "Search" ADD COLUMN "queryNormalized" TEXT;
UPDATE "Search"
SET "queryNormalized" = lower(trim("query"))
WHERE "query" IS NOT NULL AND trim("query") != '';
CREATE INDEX "Search_queryNormalized_updatedAt_idx" ON "Search"("queryNormalized", "updatedAt");

View File

@@ -118,6 +118,7 @@ model Search {
title String?
query String?
queryNormalized String?
source SearchSource @default(exa)
@@ -139,6 +140,7 @@ model Search {
projectItems ProjectItem[]
@@index([updatedAt])
@@index([queryNormalized, updatedAt])
@@index([userId])
}

View File

@@ -12,6 +12,7 @@ import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
import { openaiClient } from "./llm/providers.js";
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
import { exaClient } from "./search/exa.js";
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
import type { ChatAttachment } from "./llm/types.js";
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
@@ -375,7 +376,7 @@ function serializeChatLike<T extends Record<string, any>>(chat: T) {
}
function serializeSearchLike<T extends Record<string, any>>(search: T) {
const { projectItems: _projectItems, ...rest } = search;
const { projectItems: _projectItems, queryNormalized: _queryNormalized, ...rest } = search;
return {
...rest,
...serializeStarFields(search),
@@ -649,6 +650,7 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
where: { id: searchId },
data: {
query,
queryNormalized: normalizeSearchQuery(query),
title: normalizedTitle,
requestId: searchResponse?.requestId ?? null,
rawResponse: searchResponse as any,
@@ -686,6 +688,7 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
where: { id: searchId },
data: {
query,
queryNormalized: normalizeSearchQuery(query),
title: normalizedTitle,
latencyMs: Math.round(performance.now() - startedAt),
error: message,
@@ -877,18 +880,51 @@ export async function registerRoutes(app: FastifyInstance) {
app.post("/v1/searches", async (req) => {
requireAdmin(req);
const Body = z.object({ title: z.string().optional(), query: z.string().optional() });
const Body = z.object({
title: z.string().optional(),
query: z.string().optional(),
reuseByQuery: z.boolean().optional(),
});
const body = Body.parse(req.body ?? {});
const title = body.title?.trim() || body.query?.trim()?.slice(0, 80);
const query = body.query?.trim() || null;
const queryNormalized = normalizeSearchQuery(query);
if (body.reuseByQuery && queryNormalized) {
const existing = await prisma.search.findFirst({
where: { queryNormalized },
orderBy: { updatedAt: "desc" },
select: {
...searchSummarySelect,
answerText: true,
_count: { select: { results: true } },
},
});
if (existing) {
const { _count, answerText: _answerText, ...search } = existing;
return {
search: serializeSearchLike(search),
reused: true,
cacheHit: isFreshSearchCacheHit({
updatedAt: existing.updatedAt,
resultCount: _count.results,
answerText: existing.answerText,
isActive: activeSearchStreams.has(existing.id),
}),
};
}
}
const search = await prisma.search.create({
data: {
title: title || null,
query,
queryNormalized,
},
select: searchSummarySelect,
});
return { search: serializeSearchLike(search) };
return { search: serializeSearchLike(search), reused: false, cacheHit: false };
});
app.patch("/v1/searches/:searchId/star", async (req) => {
@@ -1032,6 +1068,7 @@ export async function registerRoutes(app: FastifyInstance) {
where: { id: searchId },
data: {
query,
queryNormalized: normalizeSearchQuery(query),
title: normalizedTitle,
requestId: searchResponse?.requestId ?? null,
rawResponse: searchResponse as any,

View File

@@ -0,0 +1,29 @@
export const SEARCH_QUERY_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
export function normalizeSearchQuery(value: string | null | undefined) {
const normalized = value?.trim().toLowerCase() ?? "";
return normalized || null;
}
export function hasReusableSearchPayload(candidate: { resultCount: number; answerText?: string | null }) {
return candidate.resultCount > 0 || Boolean(candidate.answerText?.trim());
}
export function isFreshSearchCacheHit(
candidate: {
updatedAt: Date | string;
resultCount: number;
answerText?: string | null;
isActive?: boolean;
},
now = new Date(),
ttlMs = SEARCH_QUERY_CACHE_TTL_MS
) {
if (candidate.isActive) return false;
if (!hasReusableSearchPayload(candidate)) return false;
const updatedAtMs = new Date(candidate.updatedAt).getTime();
if (!Number.isFinite(updatedAtMs)) return false;
return now.getTime() - updatedAtMs <= ttlMs;
}

View File

@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import test from "node:test";
import { SEARCH_QUERY_CACHE_TTL_MS, isFreshSearchCacheHit, normalizeSearchQuery } from "../src/search-cache.js";
test("normalizeSearchQuery trims and lowercases query text", () => {
assert.equal(normalizeSearchQuery(" Bitcoin PRICE "), "bitcoin price");
assert.equal(normalizeSearchQuery(" "), null);
assert.equal(normalizeSearchQuery(null), null);
});
test("isFreshSearchCacheHit requires fresh persisted payload and no active stream", () => {
const now = new Date("2026-05-31T12:00:00.000Z");
assert.equal(
isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS + 1), resultCount: 1 }, now),
true
);
assert.equal(
isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS - 1), resultCount: 1 }, now),
false
);
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "" }, now), false);
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "answer" }, now), true);
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 1, isActive: true }, now), false);
});