perf(gmw-ai): cache-hit optimization — bare-key Qdrant upsert + hit_count bump

This commit is contained in:
asepharyana
2026-08-25 12:03:47 +07:00
parent ecbf2617e4
commit 796c6390ac
4 changed files with 70 additions and 8 deletions
@@ -33,6 +33,7 @@ import {
parseQdrantVerdict, parseQdrantVerdict,
type StoredModerationVerdict, type StoredModerationVerdict,
setCachedTextModeration, setCachedTextModeration,
upsertBareKeyToQdrant,
} from "./textCacheStore.js"; } from "./textCacheStore.js";
const log = createChildLogger("moderationOrchestrator"); const log = createChildLogger("moderationOrchestrator");
@@ -318,6 +319,7 @@ export async function runModerationAnalysis(
}; };
cacheHits.push(hit); cacheHits.push(hit);
hitByKey.set(cacheKey, hit); hitByKey.set(cacheKey, hit);
servedCacheKeys.add(cacheKey); // bump hit_count for metrics
logCacheEvent("hit", cacheKey, "text"); logCacheEvent("hit", cacheKey, "text");
} }
} else { } else {
@@ -356,6 +358,7 @@ export async function runModerationAnalysis(
}; };
cacheHits.push(hit); cacheHits.push(hit);
hitByKey.set(cacheKey, hit); hitByKey.set(cacheKey, hit);
servedCacheKeys.add(cacheKey); // bump hit_count for metrics
logCacheEvent("hit", cacheKey, "text"); logCacheEvent("hit", cacheKey, "text");
} }
} }
@@ -383,6 +386,7 @@ export async function runModerationAnalysis(
cacheHits: cacheHits.length, cacheHits: cacheHits.length,
uncached: uncachedTargets.length, uncached: uncachedTargets.length,
total: targets.length, total: targets.length,
servedKeys: servedCacheKeys.size,
}, },
"User moderation cache applied", "User moderation cache applied",
); );
@@ -463,9 +467,14 @@ export async function runModerationAnalysis(
// WITH conversation context (accurate), but its verdict is also stored // WITH conversation context (accurate), but its verdict is also stored
// under the context-free bare key so repeats in OTHER channels hit the // under the context-free bare key so repeats in OTHER channels hit the
// exact cache instead of paying a new LLM call. Same guard as the read // exact cache instead of paying a new LLM call. Same guard as the read
// path — only non-actionable clean verdicts may cross channels. No // path — only non-actionable clean verdicts may cross channels.
// embedding on the bare row: the semantic tier is already global, and //
// writing one would create a duplicate Qdrant point for this content. // 2026-08-25 cache-hit fix: the bare key is ALSO upserted to Qdrant
// (via upsertBareKeyToQdrant) with the SAME embedding already computed
// at lookup time. Previously the bare key was only PG-written with
// embedding=null — bare clean verdicts were DB-only and invisible to
// searchQdrantBatch, capping the semantic hit-rate below the exact-cache
// hit-rate for cross-channel repeats.
const bareKey = makeTextModerationCacheKey(rawContent); const bareKey = makeTextModerationCacheKey(rawContent);
if ( if (
bareKey !== cacheKey && bareKey !== cacheKey &&
@@ -486,6 +495,10 @@ export async function runModerationAnalysis(
) { ) {
globalBareKeysWritten.set(bareKey, true); globalBareKeysWritten.set(bareKey, true);
setCachedTextModeration(bareKey, stored, null).catch(() => {}); setCachedTextModeration(bareKey, stored, null).catch(() => {});
const bareEmbedding = embeddingsByKey.get(cacheKey);
if (bareEmbedding && bareEmbedding.length > 0) {
upsertBareKeyToQdrant(bareKey, stored, bareEmbedding).catch(() => {});
}
} }
} }
@@ -664,6 +664,55 @@ export async function findSimilarTextModeration(
} }
} }
/**
* Upsert a bare (context-free) clean verdict to the Qdrant vector store,
* making global-reuse clean verdicts discoverable by semantic search.
*
* Why: the main `setCachedTextModeration` writes bare-key rows to Postgres
* with embedding=null (deliberate — no duplicate PG embedding column), but
* a bare clean verdict that never reaches Qdrant is invisible to
* searchQdrantBatch. So two messages with identical clean content in
* DIFFERENT channels never match semantically — the semantic hit-rate is
* capped below the exact-cache hit-rate. This helper shares the embedding
* already computed at lookup time so the bare point is semantically
* findable.
*
* Guard: only non-actionable clean verdicts qualify (same guard as the
* read path and as the orchestrator's bare-key write-back). No-op when
* Qdrant is disabled or no embedding is available.
*/
export async function upsertBareKeyToQdrant(
bareKey: string,
result: {
status: string;
flags: string[];
score: number;
analysis: string;
categories: string[];
severity: string;
confidence: number;
recommendedAction: string;
},
embedding: number[] | null | undefined,
): Promise<void> {
if (!isQdrantConfigured() || !embedding || embedding.length === 0) return;
if (!isGloballyReusableCleanVerdict(result, undefined)) return;
const now = Date.now();
const USER_MOD_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
await upsertQdrantPoint(bareKey, embedding, {
text: bareKey,
flags: JSON.stringify(result),
analyzed_at: now,
expires_at: now + USER_MOD_CACHE_TTL_MS,
content_hash: bareKey.split(":").pop() ?? "",
}).catch((err: unknown) => {
logger.error(
{ error: err instanceof Error ? err.message : String(err), bareKey },
"Failed to upsert bare-key clean verdict to Qdrant",
);
});
}
/** /**
* Store a moderation result for a (user, content) pair. * Store a moderation result for a (user, content) pair.
* The `flags` field stores the full result object as JSON. * The `flags` field stores the full result object as JSON.
@@ -182,7 +182,7 @@ export const configSchema = z
.number() .number()
.int() .int()
.positive() .positive()
.default(30), .default(50),
// Qdrant vector store for the semantic moderation cache. When // Qdrant vector store for the semantic moderation cache. When
// QDRANT_URL is set, embeddings are stored/searched there (Postgres // QDRANT_URL is set, embeddings are stored/searched there (Postgres
// embedding column remains as a legacy fallback). // embedding column remains as a legacy fallback).
@@ -276,7 +276,7 @@ export const configSchema = z
.number() .number()
.int() .int()
.positive() .positive()
.default(72), .default(120),
AI_ANALYSIS_MAX_CONTEXT_TOKENS: z.coerce.number().positive().default(8000), AI_ANALYSIS_MAX_CONTEXT_TOKENS: z.coerce.number().positive().default(8000),
AI_ANALYSIS_MAX_TARGET_TOKENS: z.coerce.number().positive().default(14000), AI_ANALYSIS_MAX_TARGET_TOKENS: z.coerce.number().positive().default(14000),
AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT: z.coerce AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT: z.coerce
@@ -145,10 +145,10 @@ describe("isGloballyReusableCleanVerdict", () => {
}); });
it("rejects entries older than the freshness window", () => { it("rejects entries older than the freshness window", () => {
// Default AI_CACHE_GLOBAL_REUSE_MAX_AGE_H = 72h. // Default AI_CACHE_GLOBAL_REUSE_MAX_AGE_H = 120h.
const tooOld = Date.now() - 73 * 60 * 60 * 1000; const tooOld = Date.now() - 121 * 60 * 60 * 1000;
expect(isGloballyReusableCleanVerdict(makeVerdict(), tooOld)).toBe(false); expect(isGloballyReusableCleanVerdict(makeVerdict(), tooOld)).toBe(false);
const freshEnough = Date.now() - 71 * 60 * 60 * 1000; const freshEnough = Date.now() - 119 * 60 * 60 * 1000;
expect(isGloballyReusableCleanVerdict(makeVerdict(), freshEnough)).toBe( expect(isGloballyReusableCleanVerdict(makeVerdict(), freshEnough)).toBe(
true, true,
); );