mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-27 07:51:52 +00:00
Add pluggable memory embedder adapter and vector metadata
This commit is contained in:
1 parent
a1b6331cea
commit
b8d4d2ac82
4 files changed
+211
-10
No files matched your search
@@ -1,3 +1,15 @@
|
||||
const DEFAULT_VECTOR_BACKEND = 'inmemory';
|
||||
const VECTOR_BACKEND_ENV = 'MEMORY_VECTOR_BACKEND';
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* embedText: (text: string) => Promise<number[]> | number[],
|
||||
* upsertVector: (input: { id: string, vector: number[], metadata?: Record<string, unknown> }) => Promise<void> | void,
|
||||
* queryVector: (input: { vector: number[], k?: number, filter?: Record<string, unknown> }) => Promise<Array<{ id: string, score: number, vector: number[], metadata: Record<string, unknown> }>> | Array<{ id: string, score: number, vector: number[], metadata: Record<string, unknown> }>,
|
||||
* deleteVector: (id: string) => Promise<boolean> | boolean,
|
||||
* }} EmbedderAdapter
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {number[]}
|
||||
@@ -9,14 +21,112 @@ function fallbackEmbedding(text) {
|
||||
return [Number(mean.toFixed(4)), chars.length];
|
||||
}
|
||||
|
||||
function cosineSimilarity(left, right) {
|
||||
const size = Math.max(left.length, right.length);
|
||||
let dot = 0;
|
||||
let leftNorm = 0;
|
||||
let rightNorm = 0;
|
||||
|
||||
for (let index = 0; index < size; index += 1) {
|
||||
const leftValue = Number(left[index] ?? 0);
|
||||
const rightValue = Number(right[index] ?? 0);
|
||||
dot += leftValue * rightValue;
|
||||
leftNorm += leftValue ** 2;
|
||||
rightNorm += rightValue ** 2;
|
||||
}
|
||||
|
||||
if (leftNorm === 0 || rightNorm === 0) return 0;
|
||||
return dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm));
|
||||
}
|
||||
|
||||
/** @implements {EmbedderAdapter} */
|
||||
class InMemoryEmbedderAdapter {
|
||||
constructor() {
|
||||
this.vectors = new Map();
|
||||
}
|
||||
|
||||
/** @param {string} text */
|
||||
async embedText(text) {
|
||||
return fallbackEmbedding(text);
|
||||
}
|
||||
|
||||
/** @param {{ id: string, vector: number[], metadata?: Record<string, unknown> }} input */
|
||||
async upsertVector({ id, vector, metadata = {} }) {
|
||||
if (typeof id !== 'string' || id.length === 0) {
|
||||
throw new TypeError('upsertVector requires a non-empty id');
|
||||
}
|
||||
if (!Array.isArray(vector) || vector.length === 0) {
|
||||
throw new TypeError('upsertVector requires a non-empty vector');
|
||||
}
|
||||
this.vectors.set(id, { vector: [...vector], metadata: { ...metadata } });
|
||||
}
|
||||
|
||||
/** @param {{ vector: number[], k?: number, filter?: Record<string, unknown> }} input */
|
||||
async queryVector({ vector, k = 5, filter = {} }) {
|
||||
if (!Array.isArray(vector) || vector.length === 0) return [];
|
||||
|
||||
const matchesFilter = (metadata) => Object.entries(filter).every(([key, value]) => metadata?.[key] === value);
|
||||
|
||||
return [...this.vectors.entries()]
|
||||
.filter(([, value]) => matchesFilter(value.metadata))
|
||||
.map(([id, value]) => ({
|
||||
id,
|
||||
score: cosineSimilarity(vector, value.vector),
|
||||
vector: [...value.vector],
|
||||
metadata: { ...value.metadata },
|
||||
}))
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.slice(0, Number.isFinite(k) && k > 0 ? Math.floor(k) : 5);
|
||||
}
|
||||
|
||||
/** @param {string} id */
|
||||
async deleteVector(id) {
|
||||
if (typeof id !== 'string' || id.length === 0) return false;
|
||||
return this.vectors.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const ADAPTER_FACTORIES = {
|
||||
inmemory: () => new InMemoryEmbedderAdapter(),
|
||||
local: () => new InMemoryEmbedderAdapter(),
|
||||
mock: () => new InMemoryEmbedderAdapter(),
|
||||
};
|
||||
|
||||
let defaultAdapter;
|
||||
|
||||
/**
|
||||
* @param {{ backend?: string }} [options]
|
||||
* @returns {EmbedderAdapter}
|
||||
*/
|
||||
export function createEmbedderAdapter(options = {}) {
|
||||
const configuredBackend = String(options.backend ?? process.env[VECTOR_BACKEND_ENV] ?? DEFAULT_VECTOR_BACKEND).trim().toLowerCase();
|
||||
const factory = ADAPTER_FACTORIES[configuredBackend];
|
||||
if (!factory) {
|
||||
throw new Error(`Unsupported vector backend "${configuredBackend}". Configure ${VECTOR_BACKEND_ENV}=inmemory (default) until provider adapters are added.`);
|
||||
}
|
||||
return factory();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {EmbedderAdapter}
|
||||
*/
|
||||
export function getDefaultEmbedderAdapter() {
|
||||
if (!defaultAdapter) {
|
||||
defaultAdapter = createEmbedderAdapter();
|
||||
}
|
||||
return defaultAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {{ embedText?: (value: string) => Promise<number[]> | number[] }} [options]
|
||||
* @param {{ embedText?: (value: string) => Promise<number[]> | number[], adapter?: EmbedderAdapter }} [options]
|
||||
* @returns {Promise<number[]>}
|
||||
*/
|
||||
export async function embedText(text, options = {}) {
|
||||
if (typeof options.embedText === 'function') {
|
||||
return options.embedText(text);
|
||||
}
|
||||
return fallbackEmbedding(text);
|
||||
|
||||
const adapter = options.adapter ?? getDefaultEmbedderAdapter();
|
||||
return adapter.embedText(text);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createMemoryRecord, normalizeEvent, validateMemoryItem } from './schema.js';
|
||||
import { embedText } from './embedder.js';
|
||||
import { embedText, getDefaultEmbedderAdapter } from './embedder.js';
|
||||
import { summarizeEvents } from './summarizer.js';
|
||||
|
||||
const WINDOW_BUCKET_MS = 60_000;
|
||||
@@ -88,6 +88,14 @@ async function rememberWindowFingerprint(repository, dedupeKey, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function compactContextExcerpt(text, maxChars = 280) {
|
||||
if (typeof text !== 'string' || text.length === 0) return '';
|
||||
const compact = text.replace(/\s+/g, ' ').trim();
|
||||
if (compact.length <= maxChars) return compact;
|
||||
return `${compact.slice(0, maxChars - 1)}…`;
|
||||
}
|
||||
|
||||
function buildTelemetryEmitter(options = {}) {
|
||||
if (typeof options.emitTelemetry === 'function') return options.emitTelemetry;
|
||||
if (typeof options.telemetry?.emit === 'function') {
|
||||
@@ -120,6 +128,9 @@ export function normalizeEvents(events) {
|
||||
* maxChunkChars?: number,
|
||||
* maxChunkTokens?: number,
|
||||
* embedText?: (value: string) => Promise<number[]> | number[],
|
||||
* adapter?: import('./embedder.js').EmbedderAdapter,
|
||||
* embedderAdapter?: import('./embedder.js').EmbedderAdapter,
|
||||
* contextExcerptChars?: number,
|
||||
* emitTelemetry?: (event: string, payload: Record<string, unknown>) => void,
|
||||
* telemetry?: { emit: (event: string, payload: Record<string, unknown>) => void },
|
||||
* now?: number,
|
||||
@@ -184,7 +195,8 @@ export async function ingestMemoryWindow(input = {}, options = {}) {
|
||||
for (const chunk of chunks) {
|
||||
const summarization = await summarizeEvents(chunk, options);
|
||||
const contextText = chunk.map((event) => event.content).join('\n');
|
||||
const embedding = await embedText(`${summarization.summary}\n${contextText}`, options);
|
||||
const summaryEmbedding = await embedText(summarization.summary, options);
|
||||
const contextExcerpt = compactContextExcerpt(contextText, options.contextExcerptChars);
|
||||
const chunkStart = Math.min(...chunk.map((event) => event.timestamp));
|
||||
const chunkEnd = Math.max(...chunk.map((event) => event.timestamp));
|
||||
|
||||
@@ -198,7 +210,7 @@ export async function ingestMemoryWindow(input = {}, options = {}) {
|
||||
importance: Number.isFinite(chunk[0]?.metadata?.importance)
|
||||
? Number(chunk[0].metadata.importance)
|
||||
: summarization.importance,
|
||||
embedding_id: Array.isArray(embedding) && embedding.length > 0 ? randomUUID() : null,
|
||||
embedding_id: Array.isArray(summaryEmbedding) && summaryEmbedding.length > 0 ? randomUUID() : null,
|
||||
created_at: chunkStart,
|
||||
last_seen: chunkEnd,
|
||||
source: typeof chunk[0]?.metadata?.source === 'string' ? chunk[0].metadata.source : 'ingest',
|
||||
@@ -221,11 +233,33 @@ export async function ingestMemoryWindow(input = {}, options = {}) {
|
||||
await insertMemory(repository, record);
|
||||
insertedRecords.push(record);
|
||||
|
||||
if (record.embedding_id && typeof repository.linkEmbedding === 'function') {
|
||||
await repository.linkEmbedding(record.id, {
|
||||
id: record.embedding_id,
|
||||
vector: embedding,
|
||||
});
|
||||
if (record.embedding_id) {
|
||||
const vectorMetadata = {
|
||||
memory_id: record.id,
|
||||
agent_id: record.agent_id,
|
||||
summary: record.summary,
|
||||
context_excerpt: contextExcerpt,
|
||||
source: record.source,
|
||||
created_at: record.created_at,
|
||||
last_seen: record.last_seen,
|
||||
};
|
||||
|
||||
if (typeof repository.linkEmbedding === 'function') {
|
||||
await repository.linkEmbedding(record.id, {
|
||||
id: record.embedding_id,
|
||||
vector: summaryEmbedding,
|
||||
metadata: vectorMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
const adapter = options.embedderAdapter ?? options.adapter ?? getDefaultEmbedderAdapter();
|
||||
if (typeof adapter?.upsertVector === 'function') {
|
||||
await adapter.upsertVector({
|
||||
id: record.embedding_id,
|
||||
vector: summaryEmbedding,
|
||||
metadata: vectorMetadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
emitTelemetry('memory:ingested', {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createEmbedderAdapter, embedText } from '../src/services/memory/embedder.js';
|
||||
|
||||
test('createEmbedderAdapter selects in-memory backend and supports vector CRUD', async () => {
|
||||
const adapter = createEmbedderAdapter({ backend: 'inmemory' });
|
||||
const vector = await adapter.embedText('hello world');
|
||||
|
||||
await adapter.upsertVector({
|
||||
id: 'vec-1',
|
||||
vector,
|
||||
metadata: { agent_id: 'a1', context_excerpt: 'hello world' },
|
||||
});
|
||||
|
||||
const results = await adapter.queryVector({
|
||||
vector,
|
||||
k: 1,
|
||||
filter: { agent_id: 'a1' },
|
||||
});
|
||||
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].id, 'vec-1');
|
||||
assert.equal(results[0].metadata.context_excerpt, 'hello world');
|
||||
|
||||
const deleted = await adapter.deleteVector('vec-1');
|
||||
assert.equal(deleted, true);
|
||||
});
|
||||
|
||||
test('createEmbedderAdapter reads backend from environment', async () => {
|
||||
process.env.MEMORY_VECTOR_BACKEND = 'local';
|
||||
const adapter = createEmbedderAdapter();
|
||||
const vector = await embedText('env selected', { adapter });
|
||||
|
||||
assert.ok(Array.isArray(vector));
|
||||
assert.ok(vector.length > 0);
|
||||
|
||||
delete process.env.MEMORY_VECTOR_BACKEND;
|
||||
});
|
||||
@@ -7,6 +7,7 @@ test('ingestMemoryWindow gathers sources, redacts pii, chunks, embeds, links, an
|
||||
const inserted = [];
|
||||
const linked = [];
|
||||
const telemetry = [];
|
||||
const upserted = [];
|
||||
|
||||
const repository = {
|
||||
async insertMemory(memory) {
|
||||
@@ -49,6 +50,20 @@ test('ingestMemoryWindow gathers sources, redacts pii, chunks, embeds, links, an
|
||||
maxChunkChars: 40,
|
||||
embedText: (text) => [text.length],
|
||||
emitTelemetry: (event, payload) => telemetry.push({ event, payload }),
|
||||
embedderAdapter: {
|
||||
async embedText(text) {
|
||||
return [text.length];
|
||||
},
|
||||
async upsertVector(entry) {
|
||||
upserted.push(entry);
|
||||
},
|
||||
async queryVector() {
|
||||
return [];
|
||||
},
|
||||
async deleteVector() {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
now: 2000,
|
||||
}
|
||||
);
|
||||
@@ -57,9 +72,12 @@ test('ingestMemoryWindow gathers sources, redacts pii, chunks, embeds, links, an
|
||||
assert.equal(inserted.length, 2);
|
||||
assert.equal(linked.length, 2);
|
||||
assert.equal(telemetry.length, 2);
|
||||
assert.equal(upserted.length, 2);
|
||||
assert.ok(inserted.some((item) => item.content.includes('[redacted:email]')));
|
||||
assert.ok(inserted.some((item) => item.content.includes('[redacted:phone]')));
|
||||
assert.ok(telemetry.every((entry) => entry.event === 'memory:ingested'));
|
||||
assert.ok(linked.every((entry) => typeof entry.embedding.metadata?.context_excerpt === 'string'));
|
||||
assert.ok(linked.every((entry) => entry.embedding.metadata.context_excerpt.length > 0));
|
||||
});
|
||||
|
||||
test('ingestMemoryWindow dedupes overlapping windows by hashed content + agent + timestamp bucket', async () => {
|
||||
|
||||
Reference in new issue
Block a user