mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 06:42:19 +03:00
* feat(cache): implement dual-layer semantic caching with in-memory and redis vector stores Implements production-grade, configurable semantic caching for OmniRoute. - Dual-layer architecture: Layer 1 exact hash match (0 embedding latency) + Layer 2 vector cosine similarity search. - Backends: in-memory vector store with L2 normalization, LRU and TTL + Redis vector store adapter with fail-open fallback. - Embedding generation: conversation history normalization, system prompt exclusion, timeout protection. - Streaming support: serializable SSE stream synthesis ending in data: [DONE]\n\n. - Request overrides and telemetry headers: X-OmniRoute-Cache (HIT (exact) | HIT (semantic) | MISS), X-OmniRoute-Cache-Similarity, X-OmniRoute-Savings-Tokens, Cache-Control: no-cache, x-omniroute-no-cache, x-omniroute-cache-threshold, x-omniroute-cache-type, x-omniroute-cache-no-store, x-omniroute-cache-key. - Unit test coverage across dual-layer search, eviction, redis resilience, and streaming replay. * feat(cache): add default embedding client and live integration test for Lemonade server and Redis - Add embeddingBaseUrl and embeddingApiKey configuration options to SemanticCacheConfig. - Implement createDefaultEmbeddingGenerator for automatic OpenAI-compatible embedding integration. - Add live verification script scripts/ad-hoc/test-semantic-cache-lemonade.ts. - Add network-aware integration test tests/integration/semantic-cache-lemonade.test.ts for Lemonade harrier-oss-v1-0.6b and Redis vector store. * fix(cache): address CodeRabbit review recommendations on PR #1 - Multi-tenant partition isolation: support null sentinel in StoreFilter so anonymous requests cannot match authenticated entries. - Provider propagation: pass routed provider to non-streaming and streaming cache writes. - Embedding resilience: race generator with timeout promise in generateEmbeddingWithTimeout to guard against uncooperative generators. - Memory store consistency: replace older entries with identical hash on insert, and safe-guard hashToId deletion in removeEntry. - Redis store consistency: prune expired/missing entries from candidate sets during search/stats, and delete hash mapping conditionally. - Token telemetry: use managerResult.tokensSaved when cached response lacks usage data. - Clova batch timeout: add AbortSignal.timeout(FETCH_TIMEOUT_MS) to fetchClovaEmbeddingBatch. --------- Co-authored-by: John Smith <you@example.com>
202 lines
5.6 KiB
TypeScript
202 lines
5.6 KiB
TypeScript
/**
|
|
* In-Memory Vector Store
|
|
*
|
|
* High-performance, zero-dependency in-memory vector store with cosine similarity,
|
|
* L2 normalization, O(1) direct-hash index, and LRU/TTL eviction.
|
|
*
|
|
* @module services/cache/memoryVectorStore
|
|
*/
|
|
|
|
import {
|
|
type CacheEntry,
|
|
type IVectorStore,
|
|
type SimilaritySearchResult,
|
|
type StoreFilter,
|
|
dotProduct,
|
|
l2Normalize,
|
|
} from "./vectorStore.ts";
|
|
|
|
interface InternalMemoryEntry {
|
|
entry: CacheEntry;
|
|
normalizedEmbedding?: number[];
|
|
}
|
|
|
|
export class MemoryVectorStore implements IVectorStore {
|
|
private readonly maxEntries: number;
|
|
private readonly entries = new Map<string, InternalMemoryEntry>();
|
|
private readonly hashToId = new Map<string, string>();
|
|
|
|
constructor(options?: { maxEntries?: number }) {
|
|
this.maxEntries = options?.maxEntries ?? 1000;
|
|
}
|
|
|
|
private isExpired(expiresAt: number): boolean {
|
|
return expiresAt > 0 && expiresAt <= Date.now();
|
|
}
|
|
|
|
private removeEntry(id: string): boolean {
|
|
const existing = this.entries.get(id);
|
|
if (!existing) return false;
|
|
// Only delete hashToId mapping if it still points to this id
|
|
if (this.hashToId.get(existing.entry.hash) === id) {
|
|
this.hashToId.delete(existing.entry.hash);
|
|
}
|
|
this.entries.delete(id);
|
|
return true;
|
|
}
|
|
|
|
private evictOldestIfNeeded(): void {
|
|
while (this.entries.size >= this.maxEntries) {
|
|
// Map keys iterator yields oldest inserted key first
|
|
const oldestKey = this.entries.keys().next().value;
|
|
if (!oldestKey) break;
|
|
this.removeEntry(oldestKey);
|
|
}
|
|
}
|
|
|
|
public async get(id: string): Promise<CacheEntry | null> {
|
|
const item = this.entries.get(id);
|
|
if (!item) return null;
|
|
|
|
if (this.isExpired(item.entry.expiresAt)) {
|
|
this.removeEntry(id);
|
|
return null;
|
|
}
|
|
|
|
// Refresh LRU order on hit: delete and re-insert
|
|
this.entries.delete(id);
|
|
this.entries.set(id, item);
|
|
|
|
return item.entry;
|
|
}
|
|
|
|
public async getByHash(hash: string): Promise<CacheEntry | null> {
|
|
const id = this.hashToId.get(hash);
|
|
if (!id) return null;
|
|
return this.get(id);
|
|
}
|
|
|
|
public async set(entry: CacheEntry, ttlMs: number): Promise<void> {
|
|
// If ID already exists, remove it first
|
|
if (this.entries.has(entry.id)) {
|
|
this.removeEntry(entry.id);
|
|
}
|
|
|
|
// If an existing entry shares the same direct hash, remove the older entry
|
|
const existingIdWithHash = this.hashToId.get(entry.hash);
|
|
if (existingIdWithHash && existingIdWithHash !== entry.id) {
|
|
this.removeEntry(existingIdWithHash);
|
|
}
|
|
|
|
this.evictOldestIfNeeded();
|
|
|
|
const expiresAt = ttlMs > 0 ? Date.now() + ttlMs : entry.expiresAt;
|
|
const finalEntry: CacheEntry = {
|
|
...entry,
|
|
expiresAt,
|
|
};
|
|
|
|
let normalizedEmbedding: number[] | undefined;
|
|
if (Array.isArray(entry.embedding) && entry.embedding.length > 0) {
|
|
normalizedEmbedding = l2Normalize(entry.embedding);
|
|
}
|
|
|
|
const internal: InternalMemoryEntry = {
|
|
entry: finalEntry,
|
|
normalizedEmbedding,
|
|
};
|
|
|
|
this.entries.set(finalEntry.id, internal);
|
|
this.hashToId.set(finalEntry.hash, finalEntry.id);
|
|
}
|
|
|
|
public async searchNearest(
|
|
embedding: number[],
|
|
filter: StoreFilter,
|
|
threshold: number,
|
|
limit = 1
|
|
): Promise<SimilaritySearchResult[]> {
|
|
if (!embedding || embedding.length === 0 || this.entries.size === 0) {
|
|
return [];
|
|
}
|
|
|
|
const queryNorm = l2Normalize(embedding);
|
|
const now = Date.now();
|
|
const expiredIds: string[] = [];
|
|
const candidates: SimilaritySearchResult[] = [];
|
|
|
|
for (const [id, item] of this.entries.entries()) {
|
|
if (item.entry.expiresAt > 0 && item.entry.expiresAt <= now) {
|
|
expiredIds.push(id);
|
|
continue;
|
|
}
|
|
|
|
// Metadata filter checks
|
|
if (filter.model && item.entry.model !== filter.model) continue;
|
|
if (filter.provider && item.entry.provider !== filter.provider) continue;
|
|
|
|
// Partition key isolation: null means must have NO key, string means exact match
|
|
if (filter.apiKeyId !== undefined) {
|
|
const expected = filter.apiKeyId === null ? undefined : filter.apiKeyId;
|
|
if (item.entry.apiKeyId !== expected) continue;
|
|
}
|
|
if (filter.cacheKey !== undefined) {
|
|
const expected = filter.cacheKey === null ? undefined : filter.cacheKey;
|
|
if (item.entry.cacheKey !== expected) continue;
|
|
}
|
|
|
|
if (!item.normalizedEmbedding || item.normalizedEmbedding.length !== queryNorm.length) {
|
|
continue;
|
|
}
|
|
|
|
// Since both query and candidate are L2-normalized, cosine similarity is the dot product
|
|
const sim = dotProduct(queryNorm, item.normalizedEmbedding);
|
|
if (sim >= threshold) {
|
|
candidates.push({
|
|
entry: item.entry,
|
|
similarity: sim,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Clean expired entries found during traversal
|
|
for (const id of expiredIds) {
|
|
this.removeEntry(id);
|
|
}
|
|
|
|
// Sort descending by similarity
|
|
candidates.sort((a, b) => b.similarity - a.similarity);
|
|
|
|
return candidates.slice(0, limit);
|
|
}
|
|
|
|
public async delete(id: string): Promise<boolean> {
|
|
return this.removeEntry(id);
|
|
}
|
|
|
|
public async deleteByModel(model: string): Promise<number> {
|
|
let count = 0;
|
|
const toRemove: string[] = [];
|
|
for (const [id, item] of this.entries.entries()) {
|
|
if (item.entry.model === model) {
|
|
toRemove.push(id);
|
|
}
|
|
}
|
|
for (const id of toRemove) {
|
|
if (this.removeEntry(id)) count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
public async clear(): Promise<number> {
|
|
const count = this.entries.size;
|
|
this.entries.clear();
|
|
this.hashToId.clear();
|
|
return count;
|
|
}
|
|
|
|
public async getStats(): Promise<{ entries: number }> {
|
|
return { entries: this.entries.size };
|
|
}
|
|
}
|