feat: add semantic search tests

This commit is contained in:
root
2026-04-24 19:31:18 +08:00
parent 33a2b8c749
commit 697a639cbe
13 changed files with 2316 additions and 9 deletions

BIN
ts/graph_memory.db-shm Normal file

Binary file not shown.

BIN
ts/graph_memory.db-wal Normal file

Binary file not shown.

979
ts/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,9 @@
"version": "1.0.0",
"type": "module",
"openclaw": {
"extensions": ["./dist/plugin-entry.js"],
"extensions": [
"./dist/plugin-entry.js"
],
"compat": {
"pluginApi": ">=2026.3.24-beta.2",
"minGatewayVersion": "2026.3.24-beta.2"
@ -19,7 +21,9 @@
},
"dependencies": {
"@sinclair/typebox": "^0.34.49",
"@xenova/transformers": "^2.17.2",
"better-sqlite3": "^12.9.0",
"sharp": "^0.34.5",
"yaml": "^2.8.3"
},
"devDependencies": {

View File

@ -1,4 +1,7 @@
import Database from 'better-sqlite3';
import {
SemanticSearchEngine
} from './semantic_search.js';
import type {
Entity, Relation, RecallParams, CommitParams, PurgeParams,
RecallResult, CommitResult, PurgeResult, MemoryStats
@ -7,14 +10,20 @@ import type {
export class GraphDatabase {
private db: Database.Database;
private sessionId: string;
private semanticSearch: SemanticSearchEngine;
constructor(dbPath?: string, sessionId?: string) {
this.db = new Database(dbPath || 'graph_memory.db');
this.sessionId = sessionId || `session-${Date.now()}`;
this.db.pragma('journal_mode = WAL');
this.semanticSearch = new SemanticSearchEngine(this.db);
this.initialize();
}
getSemanticSearch(): SemanticSearchEngine {
return this.semanticSearch;
}
private initialize(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS entities (

View File

@ -1,3 +1,4 @@
export * from './types.js';
export * from './semantic_search.js';
export * from './graph_database.js';
export { MemoryService } from './memory_service.js';

View File

@ -365,6 +365,35 @@ export class MemoryService {
};
}
async storeSemanticMemory(text: string, source?: string, sourceLine?: number): Promise<string> {
const id = this.generateId();
const semanticSearch = this.db.getSemanticSearch();
const embedding = await semanticSearch.generateEmbedding(text);
semanticSearch.storeEmbedding(id, text, embedding, source, sourceLine);
return id;
}
async semanticSearch(query: string, limit: number = 10): Promise<Array<{ id: string; text: string; source?: string; similarity: number }>> {
const semanticSearch = this.db.getSemanticSearch();
const queryEmbedding = await semanticSearch.generateEmbedding(query);
const results = semanticSearch.searchSimilar(queryEmbedding, limit);
return results.map(r => ({
id: r.id,
text: r.text,
source: r.source || undefined,
similarity: Math.round(r.similarity * 1000) / 1000
}));
}
async readMemoryFragment(path: string, fromLine?: number, lines?: number): Promise<string> {
const content = fs.readFileSync(path, 'utf-8');
if (fromLine !== undefined && lines !== undefined) {
const allLines = content.split('\n');
return allLines.slice(fromLine - 1, fromLine - 1 + lines).join('\n');
}
return content;
}
// ========== Utility ==========
setSessionId(sessionId: string): void {
@ -374,4 +403,8 @@ export class MemoryService {
getSessionId(): string {
return this.db.getSessionId();
}
private generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
}

View File

@ -0,0 +1,86 @@
import Database from 'better-sqlite3';
export interface SemanticMemory {
id: string;
text: string;
embedding: Float32Array;
source: string;
sourceLine?: number;
createdAt: Date;
}
export interface SearchResult {
id: string;
text: string;
source: string;
sourceLine?: number;
similarity: number;
}
export class SemanticSearchEngine {
private db: Database.Database;
private embeddingModel: any;
private modelReady: boolean = false;
constructor(db: Database.Database) {
this.db = db;
this.initialize();
}
private initialize(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS embeddings (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
embedding BLOB NOT NULL,
source TEXT,
source_line INTEGER,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_embeddings_source ON embeddings(source);
`);
}
async loadModel(): Promise<void> {
if (this.modelReady) return;
const { pipeline } = await import('@xenova/transformers') as any;
this.embeddingModel = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
this.modelReady = true;
}
async generateEmbedding(text: string): Promise<Float32Array> {
await this.loadModel();
const output = await this.embeddingModel(text, { pooling: 'mean', normalize: true });
return new Float32Array(output.data);
}
storeEmbedding(id: string, text: string, embedding: Float32Array, source?: string, sourceLine?: number): void {
const stmt = this.db.prepare(`
INSERT OR REPLACE INTO embeddings (id, text, embedding, source, source_line)
VALUES (?, ?, ?, ?, ?)
`);
stmt.run(id, text, Buffer.from(embedding.buffer), source || null, sourceLine || null);
}
searchSimilar(queryEmbedding: Float32Array, limit: number = 10): SearchResult[] {
const rows = this.db.prepare('SELECT id, text, embedding, source, source_line FROM embeddings').all();
const results = rows.map((row: any) => ({
id: row.id,
text: row.text,
source: row.source,
sourceLine: row.source_line,
similarity: cosineSimilarity(queryEmbedding, new Float32Array(row.embedding.buffer))
}));
return results.sort((a: SearchResult, b: SearchResult) => b.similarity - a.similarity).slice(0, limit);
}
}
export function cosineSimilarity(a: Float32Array, b: Float32Array): number {
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}

View File

@ -12,7 +12,8 @@ export const GraphMemoryToolSchema = Type.Object({
'persona_update', 'persona_clear',
'task_create', 'task_set_state', 'task_delete', 'task_link_info',
'context_rewrite', 'working_memory_chain',
'task_node_create', 'task_node_get_recent', 'task_node_get_chain'
'task_node_create', 'task_node_get_recent', 'task_node_get_chain',
'memory_search', 'memory_get'
]
}),
params: Type.Object({
@ -70,7 +71,15 @@ export const GraphMemoryToolSchema = Type.Object({
key_facts: Type.Optional(Type.Array(Type.String({ description: '关键事实' }), { description: '关键事实数组' })),
raw_context: Type.Optional(Type.String({ description: '原始上下文(存档用)' })),
limit: Type.Optional(Type.Number({ description: '限制数量' })),
from_node_id: Type.Optional(Type.Number({ description: '起始节点ID' }))
from_node_id: Type.Optional(Type.Number({ description: '起始节点ID' })),
query: Type.Optional(Type.String({ description: '语义搜索查询' })),
corpus: Type.Optional(Type.String({
enum: ['memory', 'wiki', 'all'],
description: '搜索语料范围'
})),
path: Type.Optional(Type.String({ description: '记忆文件路径' })),
fromLine: Type.Optional(Type.Number({ description: '起始行号' })),
lines: Type.Optional(Type.Number({ description: '读取行数' }))
}, { description: '操作参数' })
});
@ -93,9 +102,8 @@ const GRAPH_MEMORY_TOOL_DESCRIPTION = `图记忆工具 - 让 AI 拥有真正的
- task_link_info: 关联信息(必需参数: task_id, info_node)
- context_rewrite: 压缩上下文为关键记忆(必需参数: context; 可选: maxEntities, summary)
- working_memory_chain: 获取工作记忆链(可选参数: maxDepth, recentOnly)
- task_node_create: 创建任务节点并链接到链(必需参数: session_id, turn_id, summary, key_facts; 可选: raw_context)
- task_node_get_recent: 获取最近N个任务节点(必需参数: session_id; 可选: limit)
- task_node_get_chain: 获取完整任务链(必需参数: session_id; 可选: from_node_id)`;
- memory_search: 语义向量搜索(必需参数: query; 可选: limit, corpus)
- memory_get: 精确读取记忆文件片段(必需参数: path; 可选: fromLine, lines)`;
// ==================== 参数验证 ====================
@ -321,6 +329,35 @@ function validatePersonaClearParams(params: Record<string, unknown>): Validation
return errors;
}
function validateMemorySearchParams(params: Record<string, unknown>): ValidationError[] {
const errors: ValidationError[] = [];
if (!params.query || typeof params.query !== 'string' || params.query.trim() === '') {
errors.push({ field: 'query', message: 'memory_search 操作必需提供 query 字符串' });
}
if (params.limit !== undefined && (typeof params.limit !== 'number' || params.limit < 1 || params.limit > 50)) {
errors.push({ field: 'limit', message: 'limit 必须在 1-50 之间' });
}
return errors;
}
function validateMemoryGetParams(params: Record<string, unknown>): ValidationError[] {
const errors: ValidationError[] = [];
if (!params.path || typeof params.path !== 'string' || params.path.trim() === '') {
errors.push({ field: 'path', message: 'memory_get 操作必需提供 path 字符串' });
}
if (params.fromLine !== undefined && (typeof params.fromLine !== 'number' || params.fromLine < 1)) {
errors.push({ field: 'fromLine', message: 'fromLine 必须是正整数' });
}
if (params.lines !== undefined && (typeof params.lines !== 'number' || params.lines < 1 || params.lines > 500)) {
errors.push({ field: 'lines', message: 'lines 必须在 1-500 之间' });
}
return errors;
}
function validateParams(action: string, params: Record<string, unknown>): ValidationError[] {
switch (action) {
case 'recall': return validateRecallParams(params);
@ -337,6 +374,8 @@ function validateParams(action: string, params: Record<string, unknown>): Valida
case 'task_node_create': return validateTaskNodeCreateParams(params);
case 'task_node_get_recent': return validateTaskNodeGetRecentParams(params);
case 'task_node_get_chain': return validateTaskNodeGetChainParams(params);
case 'memory_search': return validateMemorySearchParams(params);
case 'memory_get': return validateMemoryGetParams(params);
default: return [];
}
}
@ -518,6 +557,23 @@ export function createGraphMemoryTool(dbPath?: string, sessionId?: string) {
params.from_node_id as number | undefined
);
case 'memory_search': {
const results = await service.semanticSearch(
params.query as string,
params.limit as number | undefined
);
return { results, count: results.length };
}
case 'memory_get': {
const content = await service.readMemoryFragment(
params.path as string,
params.fromLine as number | undefined,
params.lines as number | undefined
);
return { content, path: params.path };
}
case 'archive':
return service.archive(params.days as number | undefined);

View File

@ -0,0 +1,188 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Database from 'better-sqlite3';
vi.mock('@xenova/transformers', () => ({
pipeline: vi.fn().mockImplementation(() => {
const mockModel = (text: string, opts: any) => {
const arr = new Float32Array(384);
const seed = text.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
for (let i = 0; i < 384; i++) {
arr[i] = Math.sin((seed + i) * 0.1);
}
return { data: arr };
};
mockModel.to = function() {
return this;
};
return mockModel;
}),
}));
import { SemanticSearchEngine, cosineSimilarity } from '/home/program/TrulyMEM-TrueHumanMEM/ts/dist/runtime/core/graph_memory/semantic_search.js';
const TEST_DB_PATH = '/tmp/test_semantic_search.db';
describe('SemanticSearchEngine', () => {
let db: Database.Database;
let engine: SemanticSearchEngine;
beforeEach(async () => {
try {
const fs = await import('fs');
if (fs.existsSync(TEST_DB_PATH)) {
fs.unlinkSync(TEST_DB_PATH);
}
if (fs.existsSync(`${TEST_DB_PATH}-wal`)) {
fs.unlinkSync(`${TEST_DB_PATH}-wal`);
}
if (fs.existsSync(`${TEST_DB_PATH}-shm`)) {
fs.unlinkSync(`${TEST_DB_PATH}-shm`);
}
} catch {}
db = new Database(TEST_DB_PATH);
engine = new SemanticSearchEngine(db);
});
afterEach(() => {
if (db) {
db.close();
}
});
describe('initialization', () => {
it('creates embeddings table on initialization', () => {
const tableInfo = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='embeddings'").get();
expect(tableInfo).toBeDefined();
});
it('creates index on source column', () => {
const indexInfo = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_embeddings_source'").get();
expect(indexInfo).toBeDefined();
});
});
describe('generateEmbedding', () => {
it('generates 384 dimensional embedding', async () => {
const embedding = await engine.generateEmbedding('hello world');
expect(embedding.length).toBe(384);
});
it('generates consistent embeddings for same text', async () => {
const emb1 = await engine.generateEmbedding('test');
const emb2 = await engine.generateEmbedding('test');
expect(emb1.length).toBe(emb2.length);
const similarity = cosineSimilarity(emb1, emb2);
expect(similarity).toBeCloseTo(1, 3);
});
it('generates different embeddings for different text', async () => {
const emb1 = await engine.generateEmbedding('hello');
const emb2 = await engine.generateEmbedding('world');
const similarity = cosineSimilarity(emb1, emb2);
expect(similarity).not.toBe(1);
});
});
describe('storeEmbedding', () => {
it('stores embedding to SQLite', async () => {
const embedding = await engine.generateEmbedding('test');
engine.storeEmbedding('test-id', 'test text', embedding, 'test-source', 1);
const row = db.prepare('SELECT * FROM embeddings WHERE id = ?').get('test-id') as any;
expect(row).toBeDefined();
expect(row.text).toBe('test text');
expect(row.source).toBe('test-source');
expect(row.source_line).toBe(1);
});
it('replaces existing embedding with same id', async () => {
const emb1 = await engine.generateEmbedding('text1');
engine.storeEmbedding('dup-id', 'text 1', emb1);
const emb2 = await engine.generateEmbedding('text2');
engine.storeEmbedding('dup-id', 'text 2', emb2);
const row = db.prepare('SELECT text FROM embeddings WHERE id = ?').get('dup-id') as any;
expect(row.text).toBe('text 2');
});
});
describe('searchSimilar', () => {
beforeEach(async () => {
const emb1 = await engine.generateEmbedding('machine learning');
const emb2 = await engine.generateEmbedding('deep learning');
const emb3 = await engine.generateEmbedding('hello world');
engine.storeEmbedding('item-1', 'machine learning', emb1, 'source1', 1);
engine.storeEmbedding('item-2', 'deep learning neural network', emb2, 'source2', 2);
engine.storeEmbedding('item-3', 'hello world', emb3, 'source3', 3);
});
it('finds similar embeddings using cosine similarity', async () => {
const query = await engine.generateEmbedding('neural networks');
const results = engine.searchSimilar(query, 10);
expect(results.length).toBeGreaterThan(0);
expect(results[0].id).toBeDefined();
expect(typeof results[0].similarity).toBe('number');
});
it('returns results sorted by similarity descending', async () => {
const query = await engine.generateEmbedding('training');
const results = engine.searchSimilar(query, 10);
for (let i = 1; i < results.length; i++) {
expect(results[i - 1].similarity).toBeGreaterThanOrEqual(results[i].similarity);
}
});
it('respects limit parameter', async () => {
const query = await engine.generateEmbedding('test query');
const results = engine.searchSimilar(query, 2);
expect(results.length).toBeLessThanOrEqual(2);
});
it('includes source and sourceLine in results', async () => {
const query = await engine.generateEmbedding('test');
const results = engine.searchSimilar(query, 1);
expect(results[0].source).toBeDefined();
});
});
describe('cosineSimilarity', () => {
it('returns 1 for identical vectors', () => {
const a = new Float32Array([1, 0, 0]);
const b = new Float32Array([1, 0, 0]);
expect(cosineSimilarity(a, b)).toBeCloseTo(1);
});
it('returns -1 for opposite vectors', () => {
const a = new Float32Array([1, 0, 0]);
const b = new Float32Array([-1, 0, 0]);
expect(cosineSimilarity(a, b)).toBeCloseTo(-1);
});
it('returns 0 for orthogonal vectors', () => {
const a = new Float32Array([1, 0, 0]);
const b = new Float32Array([0, 1, 0]);
expect(cosineSimilarity(a, b)).toBeCloseTo(0);
});
it('returns value between -1 and 1 for random vectors', () => {
const a = new Float32Array([0.5, 0.3, 0.7]);
const b = new Float32Array([0.2, 0.8, 0.1]);
const similarity = cosineSimilarity(a, b);
expect(similarity).toBeGreaterThanOrEqual(-1);
expect(similarity).toBeLessThanOrEqual(1);
});
it('handles 384 dimensional vectors', () => {
const a = new Float32Array(384).fill(0.1);
const b = new Float32Array(384).fill(0.1);
const similarity = cosineSimilarity(a, b);
expect(similarity).toBeCloseTo(1);
});
});
});