Add semantic caching support with ChromaDB integration

Implement semantic caching for Ollama chat responses using ChromaDB to store and retrieve embeddings. The cache can be
enabled/disabled in settings and includes configurable similarity threshold, embedding model, and ChromaDB URL. Added
cache clear functionality and error handling for cache operations.
This commit is contained in:
2026-05-07 23:03:58 +02:00
parent 951c3bbc92
commit 1985f849f4
7 changed files with 338 additions and 27 deletions
+15 -4
View File
@@ -34,13 +34,19 @@ class ChatView extends obsidian_1.ItemView {
this.newChatButtonClickWrapper = null;
this.listenersAttached = false;
this.settings = settings;
this.ollamaClient = new ollama_client_1.OllamaClient(settings.ollamaUrl, settings.model);
this.ollamaClient = new ollama_client_1.OllamaClient(settings.ollamaUrl, settings.model, undefined, settings.cacheConfig);
this.vaultIndexer = new vault_indexer_1.VaultIndexer(this.app.vault);
this.toolExecutor = new tool_executor_1.ToolExecutor(this.app.vault, this.app);
}
updateSettings(newSettings) {
this.settings = newSettings;
this.ollamaClient = new ollama_client_1.OllamaClient(newSettings.ollamaUrl, newSettings.model);
this.ollamaClient = new ollama_client_1.OllamaClient(newSettings.ollamaUrl, newSettings.model, undefined, newSettings.cacheConfig);
void this.ollamaClient.initializeCache().catch(() => {
new obsidian_1.Notice('Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.');
});
}
async clearCache() {
await this.ollamaClient.clearCache();
}
getViewType() {
return 'ollama-chat-view';
@@ -48,11 +54,16 @@ class ChatView extends obsidian_1.ItemView {
getDisplayText() {
return 'Ollama Chat';
}
onOpen() {
async onOpen() {
try {
await this.ollamaClient.initializeCache();
}
catch {
new obsidian_1.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();
return Promise.resolve();
}
onSettingsChange(newSettings) {
this.updateSettings(newSettings);
+7
View File
@@ -8,4 +8,11 @@ exports.DEFAULT_SETTINGS = {
vaultSearchLimit: 3,
maxMessageHistory: 50,
lastIndexTime: 0,
cacheConfig: {
enabled: false,
similarityThreshold: 0.85,
collectionName: 'ollama_semantic_cache',
embeddingModel: 'nomic-embed-text',
chromaURL: 'http://localhost:8000',
},
};
+67 -1
View File
@@ -45,7 +45,11 @@ class OllamaPlugin extends obsidian_1.Plugin {
const data = (await this.loadData());
if (data) {
utils_1.Logger.debug('Loading saved settings', 'settings');
Object.assign(this.settings, data);
this.settings = {
...constants_1.DEFAULT_SETTINGS,
...data,
cacheConfig: { ...constants_1.DEFAULT_SETTINGS.cacheConfig, ...data.cacheConfig },
};
}
}
catch (error) {
@@ -81,6 +85,16 @@ class OllamaPlugin extends obsidian_1.Plugin {
}
});
}
async clearSemanticCache() {
const leaves = this.app.workspace.getLeavesOfType('ollama-chat-view');
for (const leaf of leaves) {
const view = leaf.view;
if (view instanceof chat_view_1.ChatView) {
await view.clearCache();
return;
}
}
}
}
exports.default = OllamaPlugin;
class OllamaSettingTab extends obsidian_1.PluginSettingTab {
@@ -125,6 +139,58 @@ class OllamaSettingTab extends obsidian_1.PluginSettingTab {
new obsidian_1.Notice(modelValidation.error || 'Invalid model name format.');
}
}));
new obsidian_1.Setting(container)
.setName('Enable Semantic Cache')
.setDesc('Cache responses semantically to speed up repeated queries')
.addToggle((toggle) => toggle.setValue(this.plugin.settings.cacheConfig.enabled).onChange(async (value) => {
this.plugin.settings.cacheConfig.enabled = value;
await this.plugin.saveSettings();
this.plugin.notifyChatViews();
}));
new obsidian_1.Setting(container)
.setName('ChromaDB URL')
.setDesc('URL for your ChromaDB instance (default: http://localhost:8000)')
.addText((text) => text
.setValue(this.plugin.settings.cacheConfig.chromaURL || 'http://localhost:8000')
.onChange(async (value) => {
this.plugin.settings.cacheConfig.chromaURL = value;
await this.plugin.saveSettings();
}));
new obsidian_1.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 obsidian_1.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 obsidian_1.Notice('Similarity threshold must be a number between 0 and 1.');
}
}));
new obsidian_1.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 obsidian_1.Notice('Semantic cache cleared.');
}
catch {
new obsidian_1.Notice('Failed to clear semantic cache. Is ChromaDB running?');
}
}));
}
hide() {
// Clear the container to prevent duplicate elements
+85 -11
View File
@@ -4,13 +4,27 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.OllamaClient = void 0;
const types_1 = require("./types");
const utils_1 = require("./utils");
const semantic_cache_1 = require("./semantic-cache");
class OllamaClient {
constructor(baseURL, model, fetchFn) {
constructor(baseURL, model, fetchFn, cacheConfig) {
this.maxRetries = 3;
this.currentStreamController = null;
this.baseURL = baseURL;
this.model = model;
this.fetchFn = fetchFn ?? fetch;
if (cacheConfig?.enabled) {
this.cacheService = new semantic_cache_1.SemanticCacheService(baseURL, cacheConfig);
}
}
async initializeCache() {
if (this.cacheService) {
await this.cacheService.initialize();
}
}
async clearCache() {
if (this.cacheService) {
await this.cacheService.clearCache();
}
}
cancelStream() {
if (this.currentStreamController) {
@@ -19,8 +33,40 @@ class OllamaClient {
}
}
async *streamChat(messages, tools = []) {
for await (const message of this.streamChatWithRetry(messages, tools)) {
yield message;
// Bypass cache if tools are involved to prevent state corruption
if (tools.length > 0) {
yield* this.streamChatWithRetry(messages, tools, 0);
return;
}
// Find the last user message
let lastUserMsg = messages[messages.length - 1];
if (lastUserMsg && lastUserMsg.role !== 'user') {
// Find the last user message if not the last one
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
lastUserMsg = messages[i];
break;
}
}
}
if (lastUserMsg && this.cacheService) {
const cached = await this.cacheService.getCache(lastUserMsg.content);
if (cached) {
yield { role: 'assistant', content: cached, tool_calls: [] };
return;
}
}
if (this.cacheService && lastUserMsg) {
const chunks = [];
for await (const chunk of this.streamChatWithRetry(messages, tools, 0)) {
chunks.push(chunk);
yield chunk;
}
const fullContent = chunks.map((c) => c.content).join('');
void this.cacheService.setCache(lastUserMsg.content, fullContent);
}
else {
yield* this.streamChatWithRetry(messages, tools, 0);
}
}
async streamChatAsPromise(messages, tools = []) {
@@ -30,6 +76,34 @@ class OllamaClient {
}
return chunks;
}
async chat(messages, tools = []) {
// Bypass cache if tools are involved
if (tools.length > 0) {
return this.chatWithRetry(messages, tools, 0);
}
// Find the last user message
let lastUserMsg = messages[messages.length - 1];
if (lastUserMsg && lastUserMsg.role !== 'user') {
// Find the last user message if not the last one
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
lastUserMsg = messages[i];
break;
}
}
}
if (lastUserMsg && this.cacheService) {
const cached = await this.cacheService.getCache(lastUserMsg.content);
if (cached) {
return { role: 'assistant', content: cached, tool_calls: [] };
}
}
const response = await this.chatWithRetry(messages, tools, 0);
if (this.cacheService && lastUserMsg) {
void this.cacheService.setCache(lastUserMsg.content, response.content);
}
return response;
}
async *streamChatWithRetry(messages, tools = [], attempt = 0) {
const controller = new AbortController();
this.currentStreamController = controller;
@@ -60,14 +134,16 @@ class OllamaClient {
// If the controller was aborted during the delay, cancel the retry
if (controller.signal.aborted) {
clearTimeout(timer);
reject(controller.signal.reason ??
new DOMException('The operation was aborted.', 'AbortError'));
const abortError = controller.signal.reason ??
new DOMException('The operation was aborted.', 'AbortError');
reject(abortError);
return;
}
controller.signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(controller.signal.reason ??
new DOMException('The operation was aborted.', 'AbortError'));
const abortError = controller.signal.reason ??
new DOMException('The operation was aborted.', 'AbortError');
reject(abortError);
}, { once: true });
});
// Only proceed with retry if this controller is still the active one.
@@ -166,9 +242,6 @@ class OllamaClient {
}
}
}
async chat(messages, tools = []) {
return this.chatWithRetry(messages, tools, 0);
}
async chatWithRetry(messages, tools = [], attempt = 0) {
const controller = new AbortController();
try {
@@ -206,7 +279,8 @@ class OllamaClient {
}
throwIfOllamaError(parsed) {
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}`);
}
}
toOllamaMessage(value) {
+33 -9
View File
@@ -56,7 +56,18 @@ export class OllamaClient {
return;
}
const lastUserMsg = messages.findLast((m) => m.role === 'user');
// Find the last user message
let lastUserMsg: OllamaMessage | undefined = messages[messages.length - 1];
if (lastUserMsg && lastUserMsg.role !== 'user') {
// Find the last user message if not the last one
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
lastUserMsg = messages[i];
break;
}
}
}
if (lastUserMsg && this.cacheService) {
const cached = await this.cacheService.getCache(lastUserMsg.content);
if (cached) {
@@ -77,7 +88,6 @@ export class OllamaClient {
yield* this.streamChatWithRetry(messages, tools, 0);
}
}
}
async streamChatAsPromise(
messages: OllamaMessage[],
@@ -96,7 +106,18 @@ export class OllamaClient {
return this.chatWithRetry(messages, tools, 0);
}
const lastUserMsg = messages.findLast((m) => m.role === 'user');
// Find the last user message
let lastUserMsg: OllamaMessage | undefined = messages[messages.length - 1];
if (lastUserMsg && lastUserMsg.role !== 'user') {
// Find the last user message if not the last one
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
lastUserMsg = messages[i];
break;
}
}
}
if (lastUserMsg && this.cacheService) {
const cached = await this.cacheService.getCache(lastUserMsg.content);
if (cached) {
@@ -155,10 +176,10 @@ export class OllamaClient {
// If the controller was aborted during the delay, cancel the retry
if (controller.signal.aborted) {
clearTimeout(timer);
reject(
const abortError: Error =
controller.signal.reason ??
new DOMException('The operation was aborted.', 'AbortError')
);
(new DOMException('The operation was aborted.', 'AbortError') as unknown as Error);
reject(abortError);
return;
}
@@ -166,10 +187,13 @@ export class OllamaClient {
'abort',
() => {
clearTimeout(timer);
reject(
const abortError: Error =
controller.signal.reason ??
new DOMException('The operation was aborted.', 'AbortError')
);
(new DOMException(
'The operation was aborted.',
'AbortError'
) as unknown as Error);
reject(abortError);
},
{ once: true }
);
+118
View File
@@ -0,0 +1,118 @@
"use strict";
// src/semantic-cache.ts
Object.defineProperty(exports, "__esModule", { value: true });
exports.SemanticCacheService = void 0;
const chromadb_1 = require("chromadb");
const utils_1 = require("./utils");
class SemanticCacheService {
constructor(ollamaURL, config) {
this.collection = null;
this.ollamaURL = ollamaURL.replace(/\/+$/, '');
this.config = config;
// Use configurable ChromaDB URL or default to localhost
this.chromaURL = config.chromaURL || 'http://localhost:8000';
this.client = new chromadb_1.ChromaClient({ path: this.chromaURL });
}
async initialize() {
if (!this.config.enabled)
return;
try {
this.collection = await this.client.getOrCreateCollection({
name: this.config.collectionName,
metadata: { 'hnsw:space': 'cosine' },
});
utils_1.Logger.info(`Semantic cache initialized: ${this.config.collectionName}`, 'semantic-cache');
}
catch (error) {
utils_1.Logger.error(`Failed to initialize semantic cache: ${String(error)}`, 'semantic-cache');
}
}
async getEmbedding(text) {
try {
const response = await fetch(`${this.ollamaURL}/api/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.config.embeddingModel,
prompt: text,
}),
});
if (!response.ok) {
throw new Error(`Embedding failed with status ${response.status}`);
}
const data = await response.json();
return data.embedding;
}
catch (error) {
utils_1.Logger.warn(`Failed to generate embedding: ${String(error)}`, 'semantic-cache');
return [];
}
}
async getCache(prompt) {
if (!this.collection || !this.config.enabled || !prompt.trim()) {
return null;
}
try {
const embedding = await this.getEmbedding(prompt);
if (!embedding.length)
return null;
const results = await this.collection.query({
queryEmbeddings: [embedding],
nResults: 1,
include: ['metadatas', 'distances'],
});
// Cosine distance = 1 - cosine_similarity
// We want distance < (1 - threshold)
if (results.distances &&
results.distances[0] &&
results.distances[0][0] < 1 - this.config.similarityThreshold) {
utils_1.Logger.debug('Semantic cache hit', 'semantic-cache');
return results.metadatas?.[0]?.[0]?.fullResponse ?? null;
}
}
catch (error) {
utils_1.Logger.warn(`Cache lookup failed: ${String(error)}`, 'semantic-cache');
}
return null;
}
async setCache(prompt, response) {
if (!this.collection || !this.config.enabled || !prompt.trim() || !response.trim()) {
return;
}
try {
const embedding = await this.getEmbedding(prompt);
if (!embedding.length)
return;
// Fallback for crypto.randomUUID() if not available
let id;
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
id = crypto.randomUUID();
}
else {
// Fallback to a simple ID generator if crypto is not available
id = 'cache_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
const collectionAddResult = await this.collection.add({
ids: [id],
embeddings: [embedding],
metadatas: [{ fullResponse: response }],
});
utils_1.Logger.debug('Cached new response', 'semantic-cache');
}
catch (error) {
utils_1.Logger.warn(`Cache write failed: ${String(error)}`, 'semantic-cache');
}
}
async clearCache() {
if (this.collection && this.config.enabled) {
try {
await this.collection.reset();
utils_1.Logger.info('Semantic cache cleared', 'semantic-cache');
}
catch (error) {
utils_1.Logger.error(`Failed to clear semantic cache: ${String(error)}`, 'semantic-cache');
}
}
}
}
exports.SemanticCacheService = SemanticCacheService;
+13 -2
View File
@@ -65,7 +65,7 @@ export class SemanticCacheService {
const embedding = await this.getEmbedding(prompt);
if (!embedding.length) return null;
const results = await this.collection.query({
const results: any = await this.collection.query({
queryEmbeddings: [embedding],
nResults: 1,
include: ['metadatas', 'distances'],
@@ -106,7 +106,7 @@ export class SemanticCacheService {
id = 'cache_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
await this.collection.add({
const collectionAddResult: any = await this.collection.add({
ids: [id],
embeddings: [embedding],
metadatas: [{ fullResponse: response }],
@@ -116,4 +116,15 @@ export class SemanticCacheService {
Logger.warn(`Cache write failed: ${String(error)}`, 'semantic-cache');
}
}
async clearCache(): Promise<void> {
if (this.collection && this.config.enabled) {
try {
await this.collection.reset();
Logger.info('Semantic cache cleared', 'semantic-cache');
} catch (error) {
Logger.error(`Failed to clear semantic cache: ${String(error)}`, 'semantic-cache');
}
}
}
}