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
|
* @param {string} text
|
||||||
* @returns {number[]}
|
* @returns {number[]}
|
||||||
@@ -9,14 +21,112 @@ function fallbackEmbedding(text) {
|
|||||||
return [Number(mean.toFixed(4)), chars.length];
|
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 {string} text
|
||||||
* @param {{ embedText?: (value: string) => Promise<number[]> | number[] }} [options]
|
* @param {{ embedText?: (value: string) => Promise<number[]> | number[], adapter?: EmbedderAdapter }} [options]
|
||||||
* @returns {Promise<number[]>}
|
* @returns {Promise<number[]>}
|
||||||
*/
|
*/
|
||||||
export async function embedText(text, options = {}) {
|
export async function embedText(text, options = {}) {
|
||||||
if (typeof options.embedText === 'function') {
|
if (typeof options.embedText === 'function') {
|
||||||
return options.embedText(text);
|
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 { createHash, randomUUID } from 'node:crypto';
|
||||||
import { createMemoryRecord, normalizeEvent, validateMemoryItem } from './schema.js';
|
import { createMemoryRecord, normalizeEvent, validateMemoryItem } from './schema.js';
|
||||||
import { embedText } from './embedder.js';
|
import { embedText, getDefaultEmbedderAdapter } from './embedder.js';
|
||||||
import { summarizeEvents } from './summarizer.js';
|
import { summarizeEvents } from './summarizer.js';
|
||||||
|
|
||||||
const WINDOW_BUCKET_MS = 60_000;
|
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 = {}) {
|
function buildTelemetryEmitter(options = {}) {
|
||||||
if (typeof options.emitTelemetry === 'function') return options.emitTelemetry;
|
if (typeof options.emitTelemetry === 'function') return options.emitTelemetry;
|
||||||
if (typeof options.telemetry?.emit === 'function') {
|
if (typeof options.telemetry?.emit === 'function') {
|
||||||
@@ -120,6 +128,9 @@ export function normalizeEvents(events) {
|
|||||||
* maxChunkChars?: number,
|
* maxChunkChars?: number,
|
||||||
* maxChunkTokens?: number,
|
* maxChunkTokens?: number,
|
||||||
* embedText?: (value: string) => Promise<number[]> | 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,
|
* emitTelemetry?: (event: string, payload: Record<string, unknown>) => void,
|
||||||
* telemetry?: { emit: (event: string, payload: Record<string, unknown>) => void },
|
* telemetry?: { emit: (event: string, payload: Record<string, unknown>) => void },
|
||||||
* now?: number,
|
* now?: number,
|
||||||
@@ -184,7 +195,8 @@ export async function ingestMemoryWindow(input = {}, options = {}) {
|
|||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
const summarization = await summarizeEvents(chunk, options);
|
const summarization = await summarizeEvents(chunk, options);
|
||||||
const contextText = chunk.map((event) => event.content).join('\n');
|
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 chunkStart = Math.min(...chunk.map((event) => event.timestamp));
|
||||||
const chunkEnd = Math.max(...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)
|
importance: Number.isFinite(chunk[0]?.metadata?.importance)
|
||||||
? Number(chunk[0].metadata.importance)
|
? Number(chunk[0].metadata.importance)
|
||||||
: summarization.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,
|
created_at: chunkStart,
|
||||||
last_seen: chunkEnd,
|
last_seen: chunkEnd,
|
||||||
source: typeof chunk[0]?.metadata?.source === 'string' ? chunk[0].metadata.source : 'ingest',
|
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);
|
await insertMemory(repository, record);
|
||||||
insertedRecords.push(record);
|
insertedRecords.push(record);
|
||||||
|
|
||||||
if (record.embedding_id && typeof repository.linkEmbedding === 'function') {
|
if (record.embedding_id) {
|
||||||
await repository.linkEmbedding(record.id, {
|
const vectorMetadata = {
|
||||||
id: record.embedding_id,
|
memory_id: record.id,
|
||||||
vector: embedding,
|
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', {
|
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 inserted = [];
|
||||||
const linked = [];
|
const linked = [];
|
||||||
const telemetry = [];
|
const telemetry = [];
|
||||||
|
const upserted = [];
|
||||||
|
|
||||||
const repository = {
|
const repository = {
|
||||||
async insertMemory(memory) {
|
async insertMemory(memory) {
|
||||||
@@ -49,6 +50,20 @@ test('ingestMemoryWindow gathers sources, redacts pii, chunks, embeds, links, an
|
|||||||
maxChunkChars: 40,
|
maxChunkChars: 40,
|
||||||
embedText: (text) => [text.length],
|
embedText: (text) => [text.length],
|
||||||
emitTelemetry: (event, payload) => telemetry.push({ event, payload }),
|
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,
|
now: 2000,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -57,9 +72,12 @@ test('ingestMemoryWindow gathers sources, redacts pii, chunks, embeds, links, an
|
|||||||
assert.equal(inserted.length, 2);
|
assert.equal(inserted.length, 2);
|
||||||
assert.equal(linked.length, 2);
|
assert.equal(linked.length, 2);
|
||||||
assert.equal(telemetry.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:email]')));
|
||||||
assert.ok(inserted.some((item) => item.content.includes('[redacted:phone]')));
|
assert.ok(inserted.some((item) => item.content.includes('[redacted:phone]')));
|
||||||
assert.ok(telemetry.every((entry) => entry.event === 'memory:ingested'));
|
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 () => {
|
test('ingestMemoryWindow dedupes overlapping windows by hashed content + agent + timestamp bucket', async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user