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:
+15
-4
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 (0–1) 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
@@ -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
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user