mirror of
https://gitcode.com/JianFeeeee/TrulyMEM-TrueHumanMEM.git
synced 2026-09-22 18:08:12 +00:00
feat: add semantic search tests
This commit is contained in:
@ -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 (
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './types.js';
|
||||
export * from './semantic_search.js';
|
||||
export * from './graph_database.js';
|
||||
export { MemoryService } from './memory_service.js';
|
||||
|
||||
@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
86
ts/src/runtime/core/graph_memory/semantic_search.ts
Normal file
86
ts/src/runtime/core/graph_memory/semantic_search.ts
Normal 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));
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user