perf(gmw-ai): cache-hit optimization — bare-key Qdrant upsert + hit_count bump
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
||||
parseQdrantVerdict,
|
||||
type StoredModerationVerdict,
|
||||
setCachedTextModeration,
|
||||
upsertBareKeyToQdrant,
|
||||
} from "./textCacheStore.js";
|
||||
|
||||
const log = createChildLogger("moderationOrchestrator");
|
||||
@@ -318,6 +319,7 @@ export async function runModerationAnalysis(
|
||||
};
|
||||
cacheHits.push(hit);
|
||||
hitByKey.set(cacheKey, hit);
|
||||
servedCacheKeys.add(cacheKey); // bump hit_count for metrics
|
||||
logCacheEvent("hit", cacheKey, "text");
|
||||
}
|
||||
} else {
|
||||
@@ -356,6 +358,7 @@ export async function runModerationAnalysis(
|
||||
};
|
||||
cacheHits.push(hit);
|
||||
hitByKey.set(cacheKey, hit);
|
||||
servedCacheKeys.add(cacheKey); // bump hit_count for metrics
|
||||
logCacheEvent("hit", cacheKey, "text");
|
||||
}
|
||||
}
|
||||
@@ -383,6 +386,7 @@ export async function runModerationAnalysis(
|
||||
cacheHits: cacheHits.length,
|
||||
uncached: uncachedTargets.length,
|
||||
total: targets.length,
|
||||
servedKeys: servedCacheKeys.size,
|
||||
},
|
||||
"User moderation cache applied",
|
||||
);
|
||||
@@ -463,9 +467,14 @@ export async function runModerationAnalysis(
|
||||
// WITH conversation context (accurate), but its verdict is also stored
|
||||
// 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
|
||||
// path — only non-actionable clean verdicts may cross channels. No
|
||||
// embedding on the bare row: the semantic tier is already global, and
|
||||
// writing one would create a duplicate Qdrant point for this content.
|
||||
// path — only non-actionable clean verdicts may cross channels.
|
||||
//
|
||||
// 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);
|
||||
if (
|
||||
bareKey !== cacheKey &&
|
||||
@@ -486,6 +495,10 @@ export async function runModerationAnalysis(
|
||||
) {
|
||||
globalBareKeysWritten.set(bareKey, true);
|
||||
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.
|
||||
* The `flags` field stores the full result object as JSON.
|
||||
|
||||
Reference in New Issue
Block a user