cozo-memory 1.0.9 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -7
- package/dist/cli-commands.js +32 -0
- package/dist/cli.js +125 -0
- package/dist/compare-embeddings.js +402 -0
- package/dist/download-pplx-embed.js +151 -0
- package/dist/embedding-service.js +79 -7
- package/dist/eval-suite.js +7 -1
- package/dist/hybrid-search.js +87 -8
- package/dist/index.js +259 -18
- package/dist/reranker-service.js +125 -0
- package/dist/test-multi-level-memory.js +80 -0
- package/package.json +5 -3
|
@@ -94,9 +94,16 @@ class EmbeddingService {
|
|
|
94
94
|
tokenizer = null;
|
|
95
95
|
modelId;
|
|
96
96
|
dimensions;
|
|
97
|
+
useOllama;
|
|
98
|
+
ollamaModel;
|
|
99
|
+
ollamaBaseUrl;
|
|
97
100
|
queue = Promise.resolve();
|
|
98
101
|
constructor() {
|
|
99
102
|
this.cache = new LRUCache(1000, 3600000); // 1000 entries, 1h TTL
|
|
103
|
+
// Check if Ollama should be used
|
|
104
|
+
this.useOllama = process.env.USE_OLLAMA === 'true';
|
|
105
|
+
this.ollamaModel = process.env.OLLAMA_EMBEDDING_MODEL || 'argus-ai/pplx-embed-v1-0.6b:q8_0';
|
|
106
|
+
this.ollamaBaseUrl = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
|
|
100
107
|
// Support multiple embedding models via environment variable
|
|
101
108
|
this.modelId = process.env.EMBEDDING_MODEL || "Xenova/bge-m3";
|
|
102
109
|
// Set dimensions based on model
|
|
@@ -106,9 +113,20 @@ class EmbeddingService {
|
|
|
106
113
|
"Xenova/bge-small-en-v1.5": 384,
|
|
107
114
|
"Xenova/nomic-embed-text-v1": 768,
|
|
108
115
|
"onnx-community/Qwen3-Embedding-0.6B-ONNX": 1024,
|
|
116
|
+
// Note: perplexity-ai models require manual ONNX file placement
|
|
117
|
+
// See PPLX_EMBED_INTEGRATION.md for instructions
|
|
118
|
+
"perplexity-ai/pplx-embed-v1-0.6b": 1024,
|
|
119
|
+
"perplexity-ai/pplx-embed-v1-4b": 2560,
|
|
120
|
+
// Ollama models
|
|
121
|
+
"argus-ai/pplx-embed-v1-0.6b:q8_0": 1024,
|
|
109
122
|
};
|
|
110
|
-
this.dimensions = dimensionMap[this.modelId] || 1024;
|
|
111
|
-
|
|
123
|
+
this.dimensions = dimensionMap[this.useOllama ? this.ollamaModel : this.modelId] || 1024;
|
|
124
|
+
if (this.useOllama) {
|
|
125
|
+
console.error(`[EmbeddingService] Using Ollama: ${this.ollamaModel} @ ${this.ollamaBaseUrl} (${this.dimensions} dimensions)`);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
console.error(`[EmbeddingService] Using ONNX model: ${this.modelId} (${this.dimensions} dimensions)`);
|
|
129
|
+
}
|
|
112
130
|
}
|
|
113
131
|
// Public getter for dimensions
|
|
114
132
|
getDimensions() {
|
|
@@ -125,6 +143,11 @@ class EmbeddingService {
|
|
|
125
143
|
async init() {
|
|
126
144
|
if (this.session && this.tokenizer)
|
|
127
145
|
return;
|
|
146
|
+
// Skip ONNX initialization if using Ollama
|
|
147
|
+
if (this.useOllama) {
|
|
148
|
+
console.error('[EmbeddingService] Using Ollama backend, skipping ONNX initialization');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
128
151
|
try {
|
|
129
152
|
// 1. Check if model needs to be downloaded
|
|
130
153
|
// Extract namespace and model name from modelId (e.g., "Xenova/bge-m3" or "onnx-community/Qwen3-Embedding-0.6B-ONNX")
|
|
@@ -139,10 +162,23 @@ class EmbeddingService {
|
|
|
139
162
|
if (!fs.existsSync(fp32Path) && !fs.existsSync(quantizedPath)) {
|
|
140
163
|
console.log(`[EmbeddingService] Model not found, downloading ${this.modelId}...`);
|
|
141
164
|
console.log(`[EmbeddingService] This may take a few minutes on first run.`);
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
165
|
+
// Check if this is a Xenova-compatible model
|
|
166
|
+
if (namespace === 'Xenova' || namespace === 'onnx-community') {
|
|
167
|
+
// Import AutoModel dynamically to trigger download
|
|
168
|
+
const { AutoModel } = await import("@xenova/transformers");
|
|
169
|
+
await AutoModel.from_pretrained(this.modelId, { quantized: false });
|
|
170
|
+
console.log(`[EmbeddingService] Model download completed.`);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
// For non-Xenova models (like perplexity-ai), provide manual download instructions
|
|
174
|
+
console.error(`[EmbeddingService] ERROR: Model ${this.modelId} is not available via @xenova/transformers`);
|
|
175
|
+
console.error(`[EmbeddingService] Please download the model manually:`);
|
|
176
|
+
console.error(`[EmbeddingService] 1. Visit: https://huggingface.co/${this.modelId}`);
|
|
177
|
+
console.error(`[EmbeddingService] 2. Download the 'onnx' folder contents`);
|
|
178
|
+
console.error(`[EmbeddingService] 3. Place files in: ${baseDir}`);
|
|
179
|
+
console.error(`[EmbeddingService] See PPLX_EMBED_INTEGRATION.md for detailed instructions`);
|
|
180
|
+
throw new Error(`Model ${this.modelId} requires manual download. See error messages above.`);
|
|
181
|
+
}
|
|
146
182
|
}
|
|
147
183
|
// 2. Load Tokenizer
|
|
148
184
|
if (!this.tokenizer) {
|
|
@@ -188,6 +224,10 @@ class EmbeddingService {
|
|
|
188
224
|
return cached;
|
|
189
225
|
}
|
|
190
226
|
try {
|
|
227
|
+
// Use Ollama if enabled
|
|
228
|
+
if (this.useOllama) {
|
|
229
|
+
return await this.embedWithOllama(textStr);
|
|
230
|
+
}
|
|
191
231
|
await this.init();
|
|
192
232
|
if (!this.session || !this.tokenizer)
|
|
193
233
|
throw new Error("Session/Tokenizer not initialized");
|
|
@@ -240,6 +280,37 @@ class EmbeddingService {
|
|
|
240
280
|
}
|
|
241
281
|
});
|
|
242
282
|
}
|
|
283
|
+
async embedWithOllama(text) {
|
|
284
|
+
try {
|
|
285
|
+
const response = await fetch(`${this.ollamaBaseUrl}/api/embeddings`, {
|
|
286
|
+
method: 'POST',
|
|
287
|
+
headers: {
|
|
288
|
+
'Content-Type': 'application/json',
|
|
289
|
+
},
|
|
290
|
+
body: JSON.stringify({
|
|
291
|
+
model: this.ollamaModel,
|
|
292
|
+
prompt: text,
|
|
293
|
+
}),
|
|
294
|
+
});
|
|
295
|
+
if (!response.ok) {
|
|
296
|
+
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
|
|
297
|
+
}
|
|
298
|
+
const data = await response.json();
|
|
299
|
+
if (!data.embedding || !Array.isArray(data.embedding)) {
|
|
300
|
+
throw new Error('Invalid response from Ollama API');
|
|
301
|
+
}
|
|
302
|
+
const embedding = data.embedding;
|
|
303
|
+
// Normalize the embedding
|
|
304
|
+
const normalized = this.normalize(embedding);
|
|
305
|
+
// Cache it
|
|
306
|
+
this.cache.set(text, normalized);
|
|
307
|
+
return normalized;
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
console.error(`[EmbeddingService] Ollama error for "${text.substring(0, 20)}...":`, error?.message || error);
|
|
311
|
+
return new Array(this.dimensions).fill(0);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
243
314
|
// Batch-Embeddings
|
|
244
315
|
async embedBatch(texts) {
|
|
245
316
|
// For now, process sequentially via serialized queue to avoid overloading
|
|
@@ -306,7 +377,8 @@ class EmbeddingService {
|
|
|
306
377
|
return {
|
|
307
378
|
size: this.cache.size(),
|
|
308
379
|
maxSize: 1000,
|
|
309
|
-
model: this.modelId,
|
|
380
|
+
model: this.useOllama ? this.ollamaModel : this.modelId,
|
|
381
|
+
backend: this.useOllama ? 'ollama' : 'onnx',
|
|
310
382
|
dimensions: this.dimensions
|
|
311
383
|
};
|
|
312
384
|
}
|
package/dist/eval-suite.js
CHANGED
|
@@ -88,10 +88,14 @@ async function runEvaluation() {
|
|
|
88
88
|
}
|
|
89
89
|
const server = new index_1.MemoryServer(EVAL_DB_PATH);
|
|
90
90
|
await server.embeddingService.embed("warmup");
|
|
91
|
+
// Warmup reranker
|
|
92
|
+
await server.hybridSearch.advancedSearch({ query: "warmup", limit: 1, rerank: true });
|
|
91
93
|
await setupEvalData(server);
|
|
92
94
|
const methods = [
|
|
93
95
|
{ name: "Hybrid Search", func: (q) => server.hybridSearch.search({ query: q, limit: 10 }) },
|
|
96
|
+
{ name: "Reranked Search", func: (q) => server.hybridSearch.search({ query: q, limit: 10, rerank: true }) },
|
|
94
97
|
{ name: "Graph-RAG", func: (q) => server.hybridSearch.graphRag({ query: q, limit: 10, graphConstraints: { maxDepth: 2 } }) },
|
|
98
|
+
{ name: "Graph-RAG (Reranked)", func: (q) => server.hybridSearch.graphRag({ query: q, limit: 10, graphConstraints: { maxDepth: 2 }, rerank: true }) },
|
|
95
99
|
{ name: "Graph-Walking", func: (q) => server.graph_walking({ query: q, limit: 10, max_depth: 3 }) }
|
|
96
100
|
];
|
|
97
101
|
const summary = [];
|
|
@@ -101,6 +105,9 @@ async function runEvaluation() {
|
|
|
101
105
|
let totalRecall10 = 0;
|
|
102
106
|
let totalMRR = 0;
|
|
103
107
|
let totalLatency = 0;
|
|
108
|
+
const n = EVAL_DATASET.length;
|
|
109
|
+
// Reset cache between methods to get accurate latency
|
|
110
|
+
await server.hybridSearch.clearCache();
|
|
104
111
|
for (const task of EVAL_DATASET) {
|
|
105
112
|
const t0 = perf_hooks_1.performance.now();
|
|
106
113
|
const results = await method.func(task.query);
|
|
@@ -113,7 +120,6 @@ async function runEvaluation() {
|
|
|
113
120
|
totalMRR += mrr;
|
|
114
121
|
totalLatency += (t1 - t0);
|
|
115
122
|
}
|
|
116
|
-
const n = EVAL_DATASET.length;
|
|
117
123
|
summary.push({
|
|
118
124
|
Method: method.name,
|
|
119
125
|
"Recall@3": (totalRecall3 / n).toFixed(3),
|
package/dist/hybrid-search.js
CHANGED
|
@@ -5,15 +5,18 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.HybridSearch = void 0;
|
|
7
7
|
const crypto_1 = __importDefault(require("crypto"));
|
|
8
|
+
const reranker_service_1 = require("./reranker-service");
|
|
8
9
|
const SEMANTIC_CACHE_THRESHOLD = 0.95;
|
|
9
10
|
class HybridSearch {
|
|
10
11
|
db;
|
|
11
12
|
embeddingService;
|
|
13
|
+
rerankerService;
|
|
12
14
|
searchCache = new Map();
|
|
13
15
|
CACHE_TTL = 300000; // 5 minutes cache
|
|
14
16
|
constructor(db, embeddingService) {
|
|
15
17
|
this.db = db;
|
|
16
18
|
this.embeddingService = embeddingService;
|
|
19
|
+
this.rerankerService = new reranker_service_1.RerankerService();
|
|
17
20
|
}
|
|
18
21
|
getCacheKey(options) {
|
|
19
22
|
const str = JSON.stringify({
|
|
@@ -75,6 +78,65 @@ class HybridSearch {
|
|
|
75
78
|
return { ...r, score };
|
|
76
79
|
});
|
|
77
80
|
}
|
|
81
|
+
applyContextBoost(results, options) {
|
|
82
|
+
const { session_id, task_id } = options;
|
|
83
|
+
if (!session_id && !task_id)
|
|
84
|
+
return results;
|
|
85
|
+
return results.map(result => {
|
|
86
|
+
let boost = 1.0;
|
|
87
|
+
let reasons = [];
|
|
88
|
+
const metadata = result.metadata || {};
|
|
89
|
+
if (task_id && metadata.task_id === task_id) {
|
|
90
|
+
boost += 0.5;
|
|
91
|
+
reasons.push("Task Match");
|
|
92
|
+
}
|
|
93
|
+
if (session_id && metadata.session_id === session_id) {
|
|
94
|
+
boost += 0.3;
|
|
95
|
+
reasons.push("Session Match");
|
|
96
|
+
}
|
|
97
|
+
if (boost > 1.0) {
|
|
98
|
+
// Cap the score at 1.0 to stay within standard search score range
|
|
99
|
+
const newScore = Math.min(1.0, result.score * boost);
|
|
100
|
+
return {
|
|
101
|
+
...result,
|
|
102
|
+
score: newScore,
|
|
103
|
+
explanation: (typeof result.explanation === 'string' ? result.explanation : JSON.stringify(result.explanation)) +
|
|
104
|
+
` | Context Boost (x${boost.toFixed(1)}): ${reasons.join(', ')}`
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
async applyReranking(query, results) {
|
|
111
|
+
if (results.length <= 1)
|
|
112
|
+
return results;
|
|
113
|
+
console.error(`[HybridSearch] Reranking ${results.length} candidates...`);
|
|
114
|
+
const documents = results.map(r => {
|
|
115
|
+
const parts = [
|
|
116
|
+
r.name ? `Name: ${r.name}` : '',
|
|
117
|
+
r.type ? `Type: ${r.type}` : '',
|
|
118
|
+
r.text ? `Description: ${r.text}` : '',
|
|
119
|
+
r.metadata ? `Details: ${JSON.stringify(r.metadata)}` : ''
|
|
120
|
+
].filter(p => p !== '');
|
|
121
|
+
return parts.join(' | ');
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
const rerankedOrder = await this.rerankerService.rerank(query, documents);
|
|
125
|
+
return rerankedOrder.map((item, i) => {
|
|
126
|
+
const original = results[item.index];
|
|
127
|
+
return {
|
|
128
|
+
...original,
|
|
129
|
+
score: (item.score + 1.0) / 2.0, // Normalize to 0-1 range if it's logits, or just use as is
|
|
130
|
+
explanation: (typeof original.explanation === 'string' ? original.explanation : JSON.stringify(original.explanation)) +
|
|
131
|
+
` | Reranked (Rank ${i + 1}, Cross-Encoder Score: ${item.score.toFixed(4)})`
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
console.error(`[HybridSearch] Reranking failed, returning original results:`, e);
|
|
137
|
+
return results;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
78
140
|
async advancedSearch(options) {
|
|
79
141
|
console.error("[HybridSearch] Starting advancedSearch with options:", JSON.stringify(options, null, 2));
|
|
80
142
|
const { query, limit = 10, filters, graphConstraints, vectorParams } = options;
|
|
@@ -181,10 +243,6 @@ class HybridSearch {
|
|
|
181
243
|
`:sort -score`,
|
|
182
244
|
`:limit $limit`
|
|
183
245
|
].join('\n').trim();
|
|
184
|
-
console.error('--- DEBUG: Cozo Datalog Query ---');
|
|
185
|
-
console.error(datalogQuery);
|
|
186
|
-
console.error('--- DEBUG: Params ---');
|
|
187
|
-
console.error(JSON.stringify(params, null, 2));
|
|
188
246
|
try {
|
|
189
247
|
const results = await this.db.run(datalogQuery, params);
|
|
190
248
|
let searchResults = results.rows.map((r) => ({
|
|
@@ -211,7 +269,14 @@ class HybridSearch {
|
|
|
211
269
|
return Object.entries(filters.metadata).every(([key, val]) => r.metadata[key] === val);
|
|
212
270
|
});
|
|
213
271
|
}
|
|
214
|
-
const
|
|
272
|
+
const timeDecayedResults = this.applyTimeDecay(searchResults);
|
|
273
|
+
const finalResults = this.applyContextBoost(timeDecayedResults, options);
|
|
274
|
+
// Phase 3: Reranking
|
|
275
|
+
if (options.rerank) {
|
|
276
|
+
const rerankedResults = await this.applyReranking(options.query, finalResults);
|
|
277
|
+
await this.updateCache(options, queryEmbedding, rerankedResults);
|
|
278
|
+
return rerankedResults;
|
|
279
|
+
}
|
|
215
280
|
await this.updateCache(options, queryEmbedding, finalResults);
|
|
216
281
|
return finalResults;
|
|
217
282
|
}
|
|
@@ -300,7 +365,6 @@ class HybridSearch {
|
|
|
300
365
|
:sort -score
|
|
301
366
|
:limit $limit
|
|
302
367
|
`.trim();
|
|
303
|
-
console.error("[HybridSearch] Graph-RAG Datalog Query:\n", datalogQuery);
|
|
304
368
|
try {
|
|
305
369
|
const results = await this.db.run(datalogQuery, params);
|
|
306
370
|
let searchResults = results.rows.map((r) => ({
|
|
@@ -330,7 +394,11 @@ class HybridSearch {
|
|
|
330
394
|
return Object.entries(filters.metadata).every(([key, val]) => r.metadata[key] === val);
|
|
331
395
|
});
|
|
332
396
|
}
|
|
333
|
-
|
|
397
|
+
const decayedResults = this.applyTimeDecay(searchResults);
|
|
398
|
+
if (options.rerank) {
|
|
399
|
+
return await this.applyReranking(options.query, decayedResults);
|
|
400
|
+
}
|
|
401
|
+
return decayedResults;
|
|
334
402
|
}
|
|
335
403
|
catch (e) {
|
|
336
404
|
console.error("[HybridSearch] Error in graphRag:", e.message);
|
|
@@ -414,7 +482,8 @@ No markdown, no explanation. Just the JSON.`;
|
|
|
414
482
|
filters: {
|
|
415
483
|
...options.filters,
|
|
416
484
|
entityTypes: ["CommunitySummary"]
|
|
417
|
-
}
|
|
485
|
+
},
|
|
486
|
+
rerank: options.rerank
|
|
418
487
|
});
|
|
419
488
|
// If no community summaries found, fallback to standard search
|
|
420
489
|
if (results.length === 0) {
|
|
@@ -437,5 +506,15 @@ No markdown, no explanation. Just the JSON.`;
|
|
|
437
506
|
}
|
|
438
507
|
}));
|
|
439
508
|
}
|
|
509
|
+
async clearCache() {
|
|
510
|
+
this.searchCache.clear();
|
|
511
|
+
try {
|
|
512
|
+
await this.db.run(`{ ?[query_hash] := *search_cache{query_hash} :rm search_cache {query_hash} }`);
|
|
513
|
+
console.error("[HybridSearch] Cache cleared successfully.");
|
|
514
|
+
}
|
|
515
|
+
catch (e) {
|
|
516
|
+
console.error("[HybridSearch] Error clearing cache:", e);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
440
519
|
}
|
|
441
520
|
exports.HybridSearch = HybridSearch;
|