Add semantic cache support using ChromaDB #3

Open
fegger wants to merge 4 commits from feature/semantic_caching into main
9 changed files with 223 additions and 63 deletions
Showing only changes of commit 96d7323377 - Show all commits
+19 -7
View File
@@ -72,6 +72,15 @@ export class ChatView extends ItemView {
undefined,
newSettings.cacheConfig
);
void this.ollamaClient.initializeCache().catch(() => {
new Notice(
'Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.'
);
});
}
public async clearCache(): Promise<void> {
await this.ollamaClient.clearCache();
}
getViewType(): string {
@@ -83,7 +92,13 @@ export class ChatView extends ItemView {
}
async onOpen(): Promise<void> {
try {
await this.ollamaClient.initializeCache();
} catch {
new Notice(
'Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.'
);
}
this.render();
this.removeEventListeners(); // Clean up any existing listeners before reattaching
this.setupEventListeners();
@@ -93,7 +108,7 @@ export class ChatView extends ItemView {
this.updateSettings(newSettings);
}
async onClose(): Promise<void> {
onClose(): Promise<void> {
this.ollamaClient.cancelStream();
this.removeEventListeners();
this.cleanupStreamingResources();
@@ -268,7 +283,7 @@ export class ChatView extends ItemView {
if (streamingMessage && !this.lastMessageEl) {
this.lastMessageEl = (this.chatContainer ?? this.contentEl).createEl('div', {
cls: `ollama-message assistant`,
}) as HTMLElement;
});
this.lastMessageEl.setAttribute('data-msg-id', streamingMessage.id);
}
if (this.lastMessageEl) {
@@ -314,14 +329,11 @@ export class ChatView extends ItemView {
return [
systemMessage,
...this.messages.map(
(m) =>
({
...this.messages.map((m) => ({
role: m.role,
content: m.content,
tool_calls: m.tool_calls,
}) as OllamaMessage
),
})),
userMessageWithContext,
];
}
+1
View File
@@ -11,5 +11,6 @@ export const DEFAULT_SETTINGS = {
similarityThreshold: 0.85,
collectionName: 'ollama_semantic_cache',
embeddingModel: 'nomic-embed-text',
chromaUrl: 'http://localhost:8000',
},
};
+71 -1
View File
@@ -49,7 +49,11 @@ export default class OllamaPlugin extends Plugin {
const data = (await this.loadData()) as Partial<PluginSettings> | null;
if (data) {
Logger.debug('Loading saved settings', 'settings');
this.settings = Object.assign({}, this.settings, data);
this.settings = {
...DEFAULT_SETTINGS,
...data,
cacheConfig: { ...DEFAULT_SETTINGS.cacheConfig, ...data.cacheConfig },
};
}
} catch (error) {
ErrorHandler.handleError(error, 'settings load');
@@ -89,6 +93,17 @@ export default class OllamaPlugin extends Plugin {
}
});
}
public async clearSemanticCache(): Promise<void> {
const leaves = this.app.workspace.getLeavesOfType('ollama-chat-view');
for (const leaf of leaves) {
const view = leaf.view;
if (view instanceof ChatView) {
await view.clearCache();
return;
}
}
}
}
class OllamaSettingTab extends PluginSettingTab {
@@ -152,6 +167,61 @@ class OllamaSettingTab extends PluginSettingTab {
this.plugin.notifyChatViews();
})
);
new Setting(container)
.setName('ChromaDB URL')
.setDesc('URL of your ChromaDB instance (used for semantic cache)')
.addText((text) =>
text.setValue(this.plugin.settings.cacheConfig.chromaUrl).onChange(async (value) => {
this.plugin.settings.cacheConfig.chromaUrl = value;
await this.plugin.saveSettings();
this.plugin.notifyChatViews();
})
);
new Setting(container)
.setName('Cache Embedding Model')
.setDesc('Ollama model used to generate embeddings for the semantic cache')
.addText((text) =>
text.setValue(this.plugin.settings.cacheConfig.embeddingModel).onChange(async (value) => {
this.plugin.settings.cacheConfig.embeddingModel = value;
await this.plugin.saveSettings();
this.plugin.notifyChatViews();
})
);
new Setting(container)
.setName('Cache Similarity Threshold')
.setDesc(
'Minimum cosine similarity (01) for a cache hit. Higher values require closer matches.'
)
.addText((text) =>
text
.setValue(String(this.plugin.settings.cacheConfig.similarityThreshold))
.onChange(async (value) => {
const parsed = parseFloat(value);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
this.plugin.settings.cacheConfig.similarityThreshold = parsed;
await this.plugin.saveSettings();
} else {
new Notice('Similarity threshold must be a number between 0 and 1.');
}
})
);
new Setting(container)
.setName('Clear Semantic Cache')
.setDesc('Delete all cached responses from ChromaDB')
.addButton((button) =>
button.setButtonText('Clear Cache').onClick(async () => {
try {
await this.plugin.clearSemanticCache();
new Notice('Semantic cache cleared.');
} catch {
new Notice('Failed to clear semantic cache. Is ChromaDB running?');
}
})
);
}
hide(): void {
+16 -8
View File
@@ -1,9 +1,9 @@
// src/ollama-client.ts
import type { OllamaMessage, OllamaTool } from './types';
import { ApiError } from './types';
import { ApiError, CacheConfig } from './types';
import { Logger } from './utils';
import { SemanticCacheService, CacheConfig } from './semantic-cache';
import { SemanticCacheService } from './semantic-cache';
interface OllamaChatResponse {
message?: Partial<OllamaMessage>;
@@ -33,6 +33,12 @@ export class OllamaClient {
}
}
async clearCache(): Promise<void> {
if (this.cacheService) {
await this.cacheService.clearCache();
}
}
cancelStream(): void {
if (this.currentStreamController) {
this.currentStreamController.abort();
@@ -50,7 +56,7 @@ export class OllamaClient {
return;
}
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
const lastUserMsg = messages.findLast((m) => m.role === 'user');
if (lastUserMsg && this.cacheService) {
const cached = await this.cacheService.getCache(lastUserMsg.content);
if (cached) {
@@ -59,16 +65,16 @@ export class OllamaClient {
}
}
if (this.cacheService && lastUserMsg) {
const chunks: OllamaMessage[] = [];
for await (const chunk of this.streamChatWithRetry(messages, tools, 0)) {
chunks.push(chunk);
yield chunk;
}
// Populate cache in background after successful stream
if (this.cacheService && lastUserMsg) {
const fullContent = chunks.map((c) => c.content).join('');
void this.cacheService.setCache(lastUserMsg.content, fullContent);
} else {
yield* this.streamChatWithRetry(messages, tools, 0);
}
}
@@ -89,7 +95,7 @@ export class OllamaClient {
return this.chatWithRetry(messages, tools, 0);
}
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
const lastUserMsg = messages.findLast((m) => m.role === 'user');
if (lastUserMsg && this.cacheService) {
const cached = await this.cacheService.getCache(lastUserMsg.content);
if (cached) {
@@ -334,7 +340,9 @@ export class OllamaClient {
private throwIfOllamaError(parsed: Record<string, unknown>): void {
if (parsed.error) {
throw new Error(`Ollama error: ${String(parsed.error)}`);
const errorMsg =
typeof parsed.error === 'string' ? parsed.error : JSON.stringify(parsed.error);
throw new Error(`Ollama error: ${errorMsg}`);
}
}
+36 -9
View File
@@ -1,22 +1,24 @@
// src/semantic-cache.ts
import { ChromaClient } from 'chromadb';
import { ChromaClient, Collection, IncludeEnum } from 'chromadb';
import { Logger } from './utils';
import { CacheConfig } from './types';
export { CacheConfig } from './types';
export class SemanticCacheService {
private client: ChromaClient;
private collection: ReturnType<ChromaClient['getOrCreateCollection']> | null = null;
private collection: Collection | null = null;
private config: CacheConfig;
private ollamaURL: string;
constructor(ollamaURL: string, config: CacheConfig) {
this.ollamaURL = ollamaURL.replace(/\/+$/, '');
this.config = config;
this.client = new ChromaClient({ path: 'http://localhost:8000' });
this.client = new ChromaClient({ path: config.chromaUrl });
}
async initialize() {
async initialize(): Promise<void> {
if (!this.config.enabled) return;
try {
@@ -27,9 +29,32 @@ export class SemanticCacheService {
Logger.info(`Semantic cache initialized: ${this.config.collectionName}`, 'semantic-cache');
} catch (error) {
Logger.error(`Failed to initialize semantic cache: ${String(error)}`, 'semantic-cache');
throw error;
}
}
async clearCache(): Promise<void> {
await this.client.deleteCollection({ name: this.config.collectionName });
this.collection = null;
Logger.info('Semantic cache cleared', 'semantic-cache');
try {
await this.initialize();
} catch {
// best-effort re-init — swallow errors
}
}
// FNV-1a 32-bit hash — deterministic and collision-resistant enough for cache keys
private computeId(text: string): string {
let hash = 0x811c9dc5;
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, '0');
}
private async getEmbedding(text: string): Promise<number[]> {
try {
const response = await fetch(`${this.ollamaURL}/api/embeddings`, {
@@ -45,7 +70,7 @@ export class SemanticCacheService {
throw new Error(`Embedding failed with status ${response.status}`);
}
const data = await response.json();
const data = (await response.json()) as { embedding: number[] };
return data.embedding;
} catch (error) {
Logger.warn(`Failed to generate embedding: ${String(error)}`, 'semantic-cache');
@@ -65,7 +90,7 @@ export class SemanticCacheService {
const results = await this.collection.query({
queryEmbeddings: [embedding],
nResults: 1,
include: ['metadatas', 'distances'],
include: [IncludeEnum.Metadatas, IncludeEnum.Distances],
});
// Cosine distance = 1 - cosine_similarity
@@ -76,7 +101,8 @@ export class SemanticCacheService {
results.distances[0][0] < 1 - this.config.similarityThreshold
) {
Logger.debug('Semantic cache hit', 'semantic-cache');
return results.metadatas?.[0]?.[0]?.fullResponse ?? null;
const fullResponse = results.metadatas?.[0]?.[0]?.fullResponse;
return typeof fullResponse === 'string' ? fullResponse : null;
}
} catch (error) {
Logger.warn(`Cache lookup failed: ${String(error)}`, 'semantic-cache');
@@ -94,8 +120,9 @@ export class SemanticCacheService {
const embedding = await this.getEmbedding(prompt);
if (!embedding.length) return;
await this.collection.add({
ids: [crypto.randomUUID()],
const id = this.computeId(prompt);
await this.collection.upsert({
ids: [id],
embeddings: [embedding],
metadatas: [{ fullResponse: response }],
});
+1
View File
@@ -95,6 +95,7 @@ export interface CacheConfig {
similarityThreshold: number;
collectionName: string;
embeddingModel: string;
chromaUrl: string;
}
export interface PluginSettings {
+7
View File
@@ -32,6 +32,13 @@ const mockSettings: PluginSettings = {
vaultSearchLimit: 3,
maxMessageHistory: 50,
lastIndexTime: 0,
cacheConfig: {
enabled: false,
similarityThreshold: 0.9,
collectionName: 'test-cache',
embeddingModel: 'nomic-embed-text',
chromaUrl: 'http://localhost:8000',
},
};
describe('ChatView', () => {
+18 -13
View File
@@ -6,6 +6,7 @@ import { OllamaMessage, OllamaTool, CacheConfig } from '../src/types';
const mockInitialize = jest.fn().mockResolvedValue(undefined);
const mockGetCache = jest.fn().mockResolvedValue(null);
const mockSetCache = jest.fn().mockResolvedValue(undefined);
const mockClearCache = jest.fn().mockResolvedValue(undefined);
// Mock the semantic cache service BEFORE importing OllamaClient
jest.mock('../src/semantic-cache', () => ({
@@ -13,6 +14,7 @@ jest.mock('../src/semantic-cache', () => ({
initialize: mockInitialize,
getCache: mockGetCache,
setCache: mockSetCache,
clearCache: mockClearCache,
})),
}));
@@ -68,6 +70,7 @@ describe('OllamaClient with Semantic Cache', () => {
similarityThreshold: 0.85,
collectionName: 'test_cache',
embeddingModel: 'nomic-embed-text',
chromaUrl: 'http://localhost:8000',
};
beforeEach(() => {
@@ -101,6 +104,7 @@ describe('OllamaClient with Semantic Cache', () => {
});
it('should not create cache service when no config provided', () => {
jest.clearAllMocks(); // Reset the call recorded by beforeEach before checking
new OllamaClient('http://localhost:11434', 'llama3', mockFetch);
expect(SemanticCacheService).not.toHaveBeenCalled();
@@ -350,19 +354,8 @@ describe('OllamaClient with Semantic Cache', () => {
// Mock cache service to throw an error
mockGetCache.mockRejectedValueOnce(new Error('Cache error'));
const mockResponse = {
ok: true,
json: () =>
Promise.resolve({
message: {
content: 'LLM response after cache failure',
},
}),
};
mockFetch.mockResolvedValueOnce(mockResponse);
// The chat method does not handle cache errors, so it should propagate
// However, the client should still be usable
// Note: fetch is never reached because the cache throws first.
// The chat method does not handle cache errors, so it should propagate.
await expect(client.chat(mockMessages)).rejects.toThrow('Cache error');
});
@@ -414,6 +407,18 @@ describe('OllamaClient with Semantic Cache', () => {
expect(mockGetCache).toHaveBeenCalledWith('Second question');
});
it('should call clearCache on the cache service', async () => {
await client.clearCache();
expect(mockClearCache).toHaveBeenCalledTimes(1);
});
it('should not throw when clearCache is called without a cache service', async () => {
const noCacheClient = new OllamaClient('http://localhost:11434', 'llama3', mockFetch);
jest.clearAllMocks();
await expect(noCacheClient.clearCache()).resolves.toBeUndefined();
expect(mockClearCache).not.toHaveBeenCalled();
});
it('should skip cache when no user message found', async () => {
const onlyAssistantMessages: OllamaMessage[] = [
{ role: 'system', content: 'You are helpful.' },
+44 -15
View File
@@ -7,12 +7,20 @@ import { CacheConfig } from '../src/types';
jest.mock('chromadb', () => ({
ChromaClient: jest.fn().mockImplementation(() => {
return {
getOrCreateCollection: jest.fn().mockResolvedValue({
getOrCreateCollection: jest.fn().mockReturnValue({
query: jest.fn(),
add: jest.fn(),
upsert: jest.fn(),
}),
deleteCollection: jest.fn(),
};
}),
IncludeEnum: {
Documents: 'documents',
Embeddings: 'embeddings',
Metadatas: 'metadatas',
Distances: 'distances',
},
}));
// Now import SemanticCacheService after mocking
@@ -21,10 +29,12 @@ import { SemanticCacheService } from '../src/semantic-cache';
jest.spyOn(global, 'fetch').mockImplementation(jest.fn());
const mockChromaClient = {
getOrCreateCollection: jest.fn().mockResolvedValue({
getOrCreateCollection: jest.fn().mockReturnValue({
query: jest.fn(),
add: jest.fn(),
upsert: jest.fn(),
}),
deleteCollection: jest.fn(),
};
// Set up mock instance
@@ -44,6 +54,7 @@ describe('SemanticCacheService', () => {
similarityThreshold: 0.85,
collectionName: 'test_cache',
embeddingModel: 'nomic-embed-text',
chromaUrl: 'http://localhost:8000',
};
service = new SemanticCacheService('http://localhost:11434', config);
@@ -211,32 +222,30 @@ describe('SemanticCacheService', () => {
json: () => Promise.resolve({ embedding: [0.1, 0.2, 0.3] }),
});
// crypto.randomUUID mock
const mockUuid = 'mock-uuid-123' as any;
jest.spyOn(crypto, 'randomUUID').mockReturnValue(mockUuid);
await service.setCache('test prompt', 'test response');
const mockCollection = mockChromaClient.getOrCreateCollection();
expect(mockCollection.add).toHaveBeenCalledWith({
ids: [mockUuid],
expect(mockCollection.upsert).toHaveBeenCalledWith(
expect.objectContaining({
ids: [expect.any(String)],
embeddings: [[0.1, 0.2, 0.3]],
metadatas: [{ fullResponse: 'test response' }],
});
})
);
});
it('should not add entry when prompt is empty', async () => {
await service.setCache(' ', 'test response');
expect(mockFetch).not.toHaveBeenCalled();
expect(mockChromaClient.getOrCreateCollection().add).not.toHaveBeenCalled();
expect(mockChromaClient.getOrCreateCollection().upsert).not.toHaveBeenCalled();
});
it('should not add entry when response is empty', async () => {
await service.setCache('test prompt', ' ');
expect(mockFetch).not.toHaveBeenCalled();
expect(mockChromaClient.getOrCreateCollection().add).not.toHaveBeenCalled();
expect(mockChromaClient.getOrCreateCollection().upsert).not.toHaveBeenCalled();
});
it('should not add entry when cache is disabled', async () => {
@@ -255,13 +264,33 @@ describe('SemanticCacheService', () => {
json: () => Promise.resolve({ embedding: [0.1, 0.2, 0.3] }),
});
const mockUuid = 'mock-uuid-456' as any;
jest.spyOn(crypto, 'randomUUID').mockReturnValue(mockUuid);
mockChromaClient.getOrCreateCollection().add.mockRejectedValueOnce(new Error('Add failed'));
mockChromaClient
.getOrCreateCollection()
.upsert.mockRejectedValueOnce(new Error('Add failed'));
// Should not throw
await expect(service.setCache('test prompt', 'test response')).resolves.toBeUndefined();
});
});
describe('clearCache', () => {
beforeEach(async () => {
await service.initialize();
mockChromaClient.deleteCollection.mockResolvedValue(undefined);
});
it('should delete the collection and re-initialize', async () => {
await service.clearCache();
expect(mockChromaClient.deleteCollection).toHaveBeenCalledWith({ name: 'test_cache' });
// getOrCreateCollection called once in beforeEach initialize, once in clearCache re-init
expect(mockChromaClient.getOrCreateCollection).toHaveBeenCalledTimes(2);
});
it('should propagate errors from deleteCollection', async () => {
mockChromaClient.deleteCollection.mockRejectedValueOnce(new Error('Delete failed'));
await expect(service.clearCache()).rejects.toThrow('Delete failed');
});
});
});