Skip to content

Commit 49cf13e

Browse files
Zuliang ZhaoZuliang Zhao
authored andcommitted
feat(memos-local-openclaw): add sqlite-vec acceleration and embedding cache
- Add sqlite-vec extension for fast vector search (vec_chunks virtual table) - Double-write embeddings to both embeddings table and vec_chunks - Switch vectorSearch to use sqlite-vec index when available, with brute-force fallback - Add embedding cache (EmbeddingCache) to avoid redundant API calls - Use embedQueryWithCache in recall engine for hybrid search - Add Ollama embedding provider support - Add ownerFilter config option for recall search
1 parent 96a1dd6 commit 49cf13e

File tree

8 files changed

+569
-81
lines changed

8 files changed

+569
-81
lines changed

apps/memos-local-openclaw/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"posthog-node": "^5.28.0",
5555
"puppeteer": "^24.38.0",
5656
"semver": "^7.7.4",
57+
"sqlite-vec": "^0.1.9",
5758
"uuid": "^10.0.0"
5859
},
5960
"devDependencies": {
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import type { Logger } from "../types";
2+
3+
interface CacheEntry {
4+
vector: number[];
5+
timestamp: number;
6+
}
7+
8+
interface CacheOptions {
9+
maxSize: number;
10+
ttlMs: number;
11+
}
12+
13+
/**
14+
* LRU Cache for embedding vectors
15+
*
16+
* - maxSize: maximum number of cached entries
17+
* - ttlMs: time-to-live in milliseconds
18+
*
19+
* Uses SHA-256 hash of query text as key for fast lookup
20+
*/
21+
export class EmbeddingCache {
22+
private cache: Map<string, CacheEntry>;
23+
private readonly maxSize: number;
24+
private readonly ttlMs: number;
25+
private accessOrder: string[];
26+
27+
constructor(options: CacheOptions, private log?: Logger) {
28+
this.maxSize = options.maxSize;
29+
this.ttlMs = options.ttlMs;
30+
this.cache = new Map();
31+
this.accessOrder = [];
32+
}
33+
34+
/**
35+
* Generate SHA-256 hash of text
36+
*/
37+
private async hashText(text: string): Promise<string> {
38+
const encoder = new TextEncoder();
39+
const data = encoder.encode(text.trim().toLowerCase());
40+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
41+
const hashArray = Array.from(new Uint8Array(hashBuffer));
42+
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
43+
}
44+
45+
/**
46+
* Get cached embedding if available and not expired
47+
*/
48+
async get(text: string): Promise<number[] | null> {
49+
const key = await this.hashText(text);
50+
const entry = this.cache.get(key);
51+
52+
if (!entry) {
53+
return null;
54+
}
55+
56+
// Check TTL
57+
const now = Date.now();
58+
if (now - entry.timestamp > this.ttlMs) {
59+
this.cache.delete(key);
60+
this.removeFromAccessOrder(key);
61+
this.log?.debug(`[EmbeddingCache] Entry expired for key: ${key.slice(0, 16)}...`);
62+
return null;
63+
}
64+
65+
// Update access order for LRU
66+
this.updateAccessOrder(key);
67+
this.log?.debug(`[EmbeddingCache] Cache hit for key: ${key.slice(0, 16)}...`);
68+
return entry.vector;
69+
}
70+
71+
/**
72+
* Store embedding in cache
73+
*/
74+
async set(text: string, vector: number[]): Promise<void> {
75+
const key = await this.hashText(text);
76+
77+
// If at capacity and adding new entry, evict oldest
78+
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
79+
this.evictLRU();
80+
}
81+
82+
this.cache.set(key, {
83+
vector,
84+
timestamp: Date.now(),
85+
});
86+
this.updateAccessOrder(key);
87+
this.log?.debug(`[EmbeddingCache] Cached embedding for key: ${key.slice(0, 16)}...`);
88+
}
89+
90+
/**
91+
* Check if text is cached and valid
92+
*/
93+
async has(text: string): Promise<boolean> {
94+
const key = await this.hashText(text);
95+
const entry = this.cache.get(key);
96+
97+
if (!entry) return false;
98+
99+
// Check TTL
100+
if (Date.now() - entry.timestamp > this.ttlMs) {
101+
this.cache.delete(key);
102+
this.removeFromAccessOrder(key);
103+
return false;
104+
}
105+
106+
return true;
107+
}
108+
109+
/**
110+
* Get cache statistics
111+
*/
112+
getStats(): { size: number; maxSize: number; ttlMs: number } {
113+
return {
114+
size: this.cache.size,
115+
maxSize: this.maxSize,
116+
ttlMs: this.ttlMs,
117+
};
118+
}
119+
120+
/**
121+
* Clear all cached entries
122+
*/
123+
clear(): void {
124+
this.cache.clear();
125+
this.accessOrder = [];
126+
this.log?.debug("[EmbeddingCache] Cache cleared");
127+
}
128+
129+
private updateAccessOrder(key: string): void {
130+
this.removeFromAccessOrder(key);
131+
this.accessOrder.push(key);
132+
}
133+
134+
private removeFromAccessOrder(key: string): void {
135+
const index = this.accessOrder.indexOf(key);
136+
if (index > -1) {
137+
this.accessOrder.splice(index, 1);
138+
}
139+
}
140+
141+
private evictLRU(): void {
142+
if (this.accessOrder.length === 0) return;
143+
const oldestKey = this.accessOrder.shift();
144+
if (oldestKey) {
145+
this.cache.delete(oldestKey);
146+
this.log?.debug(`[EmbeddingCache] Evicted LRU entry: ${oldestKey.slice(0, 16)}...`);
147+
}
148+
}
149+
}
150+
151+
// Default cache configuration
152+
export const DEFAULT_CACHE_OPTIONS: CacheOptions = {
153+
maxSize: 1000,
154+
ttlMs: 60 * 60 * 1000, // 1 hour
155+
};
156+
157+
// Global cache instance (singleton pattern)
158+
let globalCache: EmbeddingCache | null = null;
159+
160+
export function getGlobalCache(log?: Logger): EmbeddingCache {
161+
if (!globalCache) {
162+
globalCache = new EmbeddingCache(DEFAULT_CACHE_OPTIONS, log);
163+
}
164+
return globalCache;
165+
}
166+
167+
export function resetGlobalCache(): void {
168+
globalCache = null;
169+
}

apps/memos-local-openclaw/src/embedding/index.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,59 @@ import { embedGemini } from "./providers/gemini";
44
import { embedCohere, embedCohereQuery } from "./providers/cohere";
55
import { embedVoyage } from "./providers/voyage";
66
import { embedMistral } from "./providers/mistral";
7+
import { embedOllama } from "./providers/ollama";
78
import { embedLocal } from "./local";
89
import { modelHealth } from "../ingest/providers";
10+
import { EmbeddingCache, DEFAULT_CACHE_OPTIONS, getGlobalCache } from "./cache";
911

1012
export class Embedder {
13+
private cache: EmbeddingCache;
14+
1115
constructor(
1216
private cfg: EmbeddingConfig | undefined,
1317
private log: Logger,
1418
private openclawAPI?: OpenClawAPI,
15-
) {}
19+
) {
20+
// Use global cache singleton to share cache across instances
21+
this.cache = getGlobalCache(log);
22+
}
23+
24+
/**
25+
* Get embedding for query with caching support
26+
*/
27+
async embedQueryWithCache(text: string): Promise<number[]> {
28+
// Try cache first
29+
const cached = await this.cache.get(text);
30+
if (cached) {
31+
this.log.debug(`[Embedder] Cache hit for query: "${text.slice(0, 50)}..."`);
32+
return cached;
33+
}
34+
35+
// Generate embedding
36+
const startTime = Date.now();
37+
const vector = await this.embedQuery(text);
38+
const duration = Date.now() - startTime;
39+
40+
// Store in cache
41+
await this.cache.set(text, vector);
42+
this.log.debug(`[Embedder] Cached embedding (${duration}ms) for query: "${text.slice(0, 50)}..."`);
43+
44+
return vector;
45+
}
46+
47+
/**
48+
* Clear embedding cache
49+
*/
50+
clearCache(): void {
51+
this.cache.clear();
52+
}
53+
54+
/**
55+
* Get cache statistics
56+
*/
57+
getCacheStats(): { size: number; maxSize: number; ttlMs: number } {
58+
return this.cache.getStats();
59+
}
1660

1761
get provider(): string {
1862
if (this.cfg?.provider === "openclaw" && this.cfg.capabilities?.hostEmbedding !== true) {
@@ -70,6 +114,8 @@ export class Embedder {
70114
result = await embedMistral(texts, cfg!, this.log); break;
71115
case "voyage":
72116
result = await embedVoyage(texts, cfg!, this.log); break;
117+
case "ollama":
118+
result = await embedOllama(texts, cfg!, this.log); break;
73119
case "local":
74120
default:
75121
result = await embedLocal(texts, this.log); break;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { EmbeddingConfig, Logger } from "../../types";
2+
3+
export async function embedOllama(
4+
texts: string[],
5+
cfg: EmbeddingConfig,
6+
log: Logger,
7+
): Promise<number[][]> {
8+
const endpoint = cfg.endpoint ?? "http://localhost:11434";
9+
const model = cfg.model ?? "qwen";
10+
11+
// Ollama embedding API endpoint
12+
const url = `${endpoint.replace(/\/+$/, "")}/api/embed`;
13+
14+
const results: number[][] = [];
15+
16+
// Ollama 支持批量 embedding,但某些模型可能有限制
17+
// 这里使用单个处理以确保兼容性
18+
for (const text of texts) {
19+
const resp = await fetch(url, {
20+
method: "POST",
21+
headers: {
22+
"Content-Type": "application/json",
23+
...cfg.headers,
24+
},
25+
body: JSON.stringify({
26+
model,
27+
input: text,
28+
}),
29+
signal: AbortSignal.timeout(cfg.timeoutMs ?? 60_000),
30+
});
31+
32+
if (!resp.ok) {
33+
const body = await resp.text();
34+
throw new Error(`Ollama embedding failed (${resp.status}): ${body}`);
35+
}
36+
37+
const json = (await resp.json()) as {
38+
embeddings: number[][] | number[];
39+
};
40+
41+
// Ollama 返回的 embeddings 可能是二维数组或一维数组
42+
const embedding = Array.isArray(json.embeddings[0])
43+
? (json.embeddings as number[][])[0]
44+
: (json.embeddings as number[]);
45+
46+
results.push(embedding);
47+
}
48+
49+
return results;
50+
}

0 commit comments

Comments
 (0)