perf(gmw-ai): cache-hit optimization — bare-key Qdrant upsert + hit_count bump
This commit is contained in:
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user