feat(memory): add runtime API and exact vector index
This commit is contained in:
@@ -227,6 +227,10 @@
|
||||
|
||||
- Removed the special Anthropic `claude-opus-4-8` tool-call batch cap; sessions no longer abort an in-flight provider stream after a fixed number of completed tool calls.
|
||||
|
||||
### Added
|
||||
|
||||
- Added a structured memory runtime surface for extensions and UI integrations to query backend status, search memories, and save explicit memories across the configured memory backend.
|
||||
|
||||
## [15.10.4] - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -14,4 +14,5 @@ export type {
|
||||
export * from "./local-backend";
|
||||
export * from "./off-backend";
|
||||
export * from "./resolve";
|
||||
export * from "./runtime";
|
||||
export * from "./types";
|
||||
|
||||
@@ -27,4 +27,13 @@ export const localBackend: MemoryBackend = {
|
||||
async enqueue(agentDir, cwd) {
|
||||
enqueueMemoryConsolidation(agentDir, cwd);
|
||||
},
|
||||
async status() {
|
||||
return {
|
||||
backend: "local" as const,
|
||||
active: true,
|
||||
writable: false,
|
||||
searchable: false,
|
||||
message: "Local rollout-summary memory is active; structured search/save is not available.",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,4 +13,13 @@ export const offBackend: MemoryBackend = {
|
||||
},
|
||||
async clear() {},
|
||||
async enqueue() {},
|
||||
async status() {
|
||||
return {
|
||||
backend: "off" as const,
|
||||
active: false,
|
||||
writable: false,
|
||||
searchable: false,
|
||||
message: "Memory backend is off.",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { AgentSession } from "../session/agent-session";
|
||||
import { resolveMemoryBackend } from "./resolve";
|
||||
import type {
|
||||
MemoryBackendOperationContext,
|
||||
MemoryBackendSaveInput,
|
||||
MemoryBackendSearchOptions,
|
||||
MemoryRuntimeContext,
|
||||
} from "./types";
|
||||
|
||||
export function createMemoryRuntimeContext(context: MemoryBackendOperationContext): MemoryRuntimeContext {
|
||||
const settings = context.session?.settings;
|
||||
return {
|
||||
async status() {
|
||||
if (!settings) {
|
||||
return {
|
||||
backend: "off" as const,
|
||||
active: false,
|
||||
writable: false,
|
||||
searchable: false,
|
||||
message: "No active agent session.",
|
||||
};
|
||||
}
|
||||
const backend = await resolveMemoryBackend(settings);
|
||||
return backend.status
|
||||
? await backend.status(context)
|
||||
: {
|
||||
backend: backend.id,
|
||||
active: backend.id !== "off",
|
||||
writable: false,
|
||||
searchable: false,
|
||||
message: "This memory backend does not expose structured status.",
|
||||
};
|
||||
},
|
||||
async search(query: string, options?: MemoryBackendSearchOptions) {
|
||||
if (!settings) return unavailableSearch("off", query, "No active agent session.");
|
||||
const backend = await resolveMemoryBackend(settings);
|
||||
return backend.search
|
||||
? await backend.search(context, query, options)
|
||||
: unavailableSearch(backend.id, query, `Memory search is not available for the ${backend.id} backend.`);
|
||||
},
|
||||
async save(input: string | MemoryBackendSaveInput) {
|
||||
if (!settings) return unavailableSave("off", "No active agent session.");
|
||||
const backend = await resolveMemoryBackend(settings);
|
||||
const normalized = typeof input === "string" ? { content: input } : input;
|
||||
return backend.save
|
||||
? await backend.save(context, normalized)
|
||||
: unavailableSave(backend.id, `Memory save is not available for the ${backend.id} backend.`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createSessionMemoryRuntimeContext(
|
||||
session: AgentSession,
|
||||
agentDir: string,
|
||||
cwd: string,
|
||||
): MemoryRuntimeContext {
|
||||
return createMemoryRuntimeContext({ agentDir, cwd, session });
|
||||
}
|
||||
|
||||
function unavailableSearch(backend: string, query: string, message: string) {
|
||||
return { backend: backend as never, query, count: 0, items: [], message };
|
||||
}
|
||||
|
||||
function unavailableSave(backend: string, message: string) {
|
||||
return { backend: backend as never, stored: 0, message };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Memory backend abstraction.
|
||||
*
|
||||
* Backends are mutually exclusive — `await resolveMemoryBackend(settings)` resolves
|
||||
* Backends are mutually exclusive — `resolveMemoryBackend(settings)` returns
|
||||
* exactly one. Implementations MUST be self-contained: they own the per-session
|
||||
* state they create in `start()` and tear it down on `clear()`.
|
||||
*/
|
||||
@@ -15,6 +15,73 @@ import type { AgentSession } from "../session/agent-session";
|
||||
|
||||
export type MemoryBackendId = "off" | "local" | "hindsight" | "mnemopi";
|
||||
|
||||
export interface MemoryBackendStatus {
|
||||
backend: MemoryBackendId;
|
||||
active: boolean;
|
||||
writable: boolean;
|
||||
searchable: boolean;
|
||||
scope?: string;
|
||||
retainBank?: string;
|
||||
recallBanks?: string[];
|
||||
workingCount?: number;
|
||||
episodicCount?: number;
|
||||
tripleCount?: number;
|
||||
lastMemory?: string;
|
||||
lastRecall?: boolean;
|
||||
database?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface MemoryBackendSearchOptions {
|
||||
limit?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface MemoryBackendSearchItem {
|
||||
id?: string;
|
||||
content: string;
|
||||
bank?: string;
|
||||
source?: string;
|
||||
timestamp?: string;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export interface MemoryBackendSearchResult {
|
||||
backend: MemoryBackendId;
|
||||
query: string;
|
||||
count: number;
|
||||
items: MemoryBackendSearchItem[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface MemoryBackendSaveInput {
|
||||
content: string;
|
||||
context?: string;
|
||||
source?: string;
|
||||
importance?: number;
|
||||
}
|
||||
|
||||
export interface MemoryBackendSaveResult {
|
||||
backend: MemoryBackendId;
|
||||
stored: number;
|
||||
ids?: string[];
|
||||
queued?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface MemoryBackendOperationContext {
|
||||
agentDir: string;
|
||||
cwd: string;
|
||||
session?: AgentSession;
|
||||
}
|
||||
|
||||
export interface MemoryRuntimeContext {
|
||||
status(): Promise<MemoryBackendStatus>;
|
||||
search(query: string, options?: MemoryBackendSearchOptions): Promise<MemoryBackendSearchResult>;
|
||||
save(input: string | MemoryBackendSaveInput): Promise<MemoryBackendSaveResult>;
|
||||
}
|
||||
|
||||
export interface MemoryBackendStartOptions {
|
||||
session: AgentSession;
|
||||
settings: Settings;
|
||||
@@ -53,6 +120,19 @@ export interface MemoryBackend {
|
||||
/** Force consolidation/retain to happen now (slash `/memory enqueue`). */
|
||||
enqueue(agentDir: string, cwd: string, session?: AgentSession): Promise<void>;
|
||||
|
||||
/** Structured state for UI, slash commands, and extensions. */
|
||||
status?(context: MemoryBackendOperationContext): Promise<MemoryBackendStatus>;
|
||||
|
||||
/** Explicit user-facing semantic/lexical search. */
|
||||
search?(
|
||||
context: MemoryBackendOperationContext,
|
||||
query: string,
|
||||
options?: MemoryBackendSearchOptions,
|
||||
): Promise<MemoryBackendSearchResult>;
|
||||
|
||||
/** Explicit user-facing save operation. */
|
||||
save?(context: MemoryBackendOperationContext, input: MemoryBackendSaveInput): Promise<MemoryBackendSaveResult>;
|
||||
|
||||
/** Render backend-specific memory statistics as markdown (`/memory stats`). */
|
||||
stats?(agentDir: string, cwd: string, session?: AgentSession): Promise<string | undefined>;
|
||||
|
||||
|
||||
@@ -5,10 +5,15 @@ import type { Mnemopi } from "@oh-my-pi/pi-mnemopi";
|
||||
import type * as MnemopiDiagnoseNs from "@oh-my-pi/pi-mnemopi/diagnose";
|
||||
import type { DiagnosticSummary } from "@oh-my-pi/pi-mnemopi/diagnose";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
|
||||
import type { ModelRegistry } from "../config/model-registry";
|
||||
import { resolveRoleSelection } from "../config/model-resolver";
|
||||
import type { MemoryBackend, MemoryBackendStartOptions } from "../memory-backend/types";
|
||||
import type {
|
||||
MemoryBackend,
|
||||
MemoryBackendSaveInput,
|
||||
MemoryBackendSearchItem,
|
||||
MemoryBackendStartOptions,
|
||||
MemoryBackendStatus,
|
||||
} from "../memory-backend/types";
|
||||
import memoryConsolidationPrompt from "../prompts/system/memory-consolidation-system.md" with { type: "text" };
|
||||
import memoryExtractionPrompt from "../prompts/system/memory-extraction-system.md" with { type: "text" };
|
||||
import type { AgentSession } from "../session/agent-session";
|
||||
@@ -166,6 +171,86 @@ export const mnemopiBackend: MemoryBackend = {
|
||||
return renderMnemopiDiagnostics(summaries);
|
||||
},
|
||||
|
||||
async status({ agentDir, session }): Promise<MemoryBackendStatus> {
|
||||
const { targets, owned } = createStatsTargets(agentDir, session);
|
||||
try {
|
||||
if (targets.length === 0) {
|
||||
return {
|
||||
backend: "mnemopi",
|
||||
active: false,
|
||||
writable: false,
|
||||
searchable: false,
|
||||
message: "Mnemopi backend is configured but not initialised for this session.",
|
||||
};
|
||||
}
|
||||
return summarizeMnemopiStatus(targets, session);
|
||||
} finally {
|
||||
for (const memory of owned) memory.close();
|
||||
}
|
||||
},
|
||||
|
||||
async search({ session }, query, options) {
|
||||
const state = getMnemopiSessionState(session);
|
||||
const primary = state?.aliasOf ?? state;
|
||||
if (!primary) {
|
||||
return {
|
||||
backend: "mnemopi",
|
||||
query,
|
||||
count: 0,
|
||||
items: [],
|
||||
message: "Mnemopi backend is not initialised for this session.",
|
||||
};
|
||||
}
|
||||
if (options?.signal?.aborted) {
|
||||
return { backend: "mnemopi", query, count: 0, items: [], message: "Search aborted." };
|
||||
}
|
||||
const limit = clampLimit(options?.limit);
|
||||
const results = (await primary.recallResultsScoped(query)).slice(0, limit);
|
||||
const items: MemoryBackendSearchItem[] = results.map(result => ({
|
||||
id: result.id,
|
||||
content: result.content,
|
||||
source: result.source ?? undefined,
|
||||
timestamp: result.timestamp ?? undefined,
|
||||
score: result.score ?? result.importance,
|
||||
}));
|
||||
return { backend: "mnemopi", query, count: items.length, items };
|
||||
},
|
||||
|
||||
async save({ cwd, session }, input: MemoryBackendSaveInput) {
|
||||
const state = getMnemopiSessionState(session);
|
||||
const primary = state?.aliasOf ?? state;
|
||||
if (!primary) {
|
||||
return {
|
||||
backend: "mnemopi",
|
||||
stored: 0,
|
||||
message: "Mnemopi backend is not initialised for this session.",
|
||||
};
|
||||
}
|
||||
const content = input.content.trim();
|
||||
if (!content) return { backend: "mnemopi", stored: 0, message: "Memory content is empty." };
|
||||
const id = primary.rememberScoped(content, {
|
||||
source: input.source || "coding-agent-memory-command",
|
||||
importance: normalizeImportance(input.importance),
|
||||
metadata: {
|
||||
session_id: primary.sessionId,
|
||||
cwd,
|
||||
context: input.context ?? null,
|
||||
operation: "memory.save",
|
||||
},
|
||||
scope: "bank",
|
||||
extract: true,
|
||||
extractEntities: true,
|
||||
veracity: "user",
|
||||
memoryType: "fact",
|
||||
});
|
||||
return {
|
||||
backend: "mnemopi",
|
||||
stored: id ? 1 : 0,
|
||||
ids: id ? [id] : [],
|
||||
message: id ? undefined : "Mnemopi did not return a stored memory id.",
|
||||
};
|
||||
},
|
||||
|
||||
async preCompactionContext(messages, _settings, session): Promise<string | undefined> {
|
||||
const state = getMnemopiSessionState(session);
|
||||
return await state?.recallForCompaction(messages);
|
||||
@@ -247,6 +332,52 @@ function renderMnemopiStats(targets: readonly MnemopiStatsTarget[]): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function summarizeMnemopiStatus(
|
||||
targets: readonly MnemopiStatsTarget[],
|
||||
session: AgentSession | undefined,
|
||||
): MemoryBackendStatus {
|
||||
let workingCount = 0;
|
||||
let episodicCount = 0;
|
||||
let tripleCount = 0;
|
||||
let lastMemory: string | undefined;
|
||||
let database: string | undefined;
|
||||
for (const target of targets) {
|
||||
const stats = target.memory.getStats();
|
||||
workingCount += statCount(stats.beam.working_memory);
|
||||
episodicCount += statCount(stats.beam.episodic_memory);
|
||||
tripleCount += stats.beam.triples.total;
|
||||
lastMemory ??= stats.last_memory ?? undefined;
|
||||
database ??= stats.database ? shortenPath(stats.database) : undefined;
|
||||
}
|
||||
const state = getMnemopiSessionState(session);
|
||||
const primary = state?.aliasOf ?? state;
|
||||
return {
|
||||
backend: "mnemopi",
|
||||
active: true,
|
||||
writable: true,
|
||||
searchable: true,
|
||||
scope: primary?.config.scoping,
|
||||
retainBank: primary?.getScopedRetainTarget().bank ?? targets[0]?.bank,
|
||||
recallBanks: primary?.getScopedRecallTargets().map(target => target.bank) ?? targets.map(target => target.bank),
|
||||
workingCount,
|
||||
episodicCount,
|
||||
tripleCount,
|
||||
lastMemory,
|
||||
lastRecall: Boolean(primary?.lastRecallSnippet),
|
||||
database,
|
||||
};
|
||||
}
|
||||
|
||||
function clampLimit(limit: number | undefined): number {
|
||||
if (!Number.isFinite(limit)) return 10;
|
||||
return Math.max(1, Math.min(50, Math.trunc(limit ?? 10)));
|
||||
}
|
||||
|
||||
function normalizeImportance(value: number | undefined): number {
|
||||
if (!Number.isFinite(value)) return 0.75;
|
||||
return Math.max(0, Math.min(1, value ?? 0.75));
|
||||
}
|
||||
|
||||
function renderMnemopiDiagnostics(entries: readonly { bank: string; summary: DiagnosticSummary }[]): string {
|
||||
const lines = [
|
||||
"# Mnemopi Memory Diagnostics",
|
||||
@@ -357,10 +488,7 @@ async function resolveMnemopiProviderOptions(
|
||||
messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
|
||||
},
|
||||
{
|
||||
apiKey: modelRegistry.resolver(model.provider, {
|
||||
sessionId,
|
||||
baseUrl: model.baseUrl,
|
||||
}),
|
||||
apiKey,
|
||||
maxTokens: opts?.maxTokens,
|
||||
temperature: opts?.temperature,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { resolveMemoryBackend } from "@oh-my-pi/pi-coding-agent/memory-backend";
|
||||
import { createMemoryRuntimeContext, resolveMemoryBackend } from "@oh-my-pi/pi-coding-agent/memory-backend";
|
||||
|
||||
describe("resolveMemoryBackend", () => {
|
||||
beforeEach(() => {
|
||||
@@ -17,4 +17,35 @@ describe("resolveMemoryBackend", () => {
|
||||
expect((await resolveMemoryBackend(a)).id).toBe("hindsight");
|
||||
expect((await resolveMemoryBackend(b)).id).toBe("hindsight");
|
||||
});
|
||||
|
||||
it("exposes inactive status when no session is available", async () => {
|
||||
const memory = createMemoryRuntimeContext({ agentDir: "/tmp/agent", cwd: "/tmp/project" });
|
||||
|
||||
await expect(memory.status()).resolves.toMatchObject({
|
||||
backend: "off",
|
||||
active: false,
|
||||
writable: false,
|
||||
searchable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports local backend runtime status without structured search/save support", async () => {
|
||||
const settings = Settings.isolated({ "memory.backend": "local" });
|
||||
const memory = createMemoryRuntimeContext({
|
||||
agentDir: "/tmp/agent",
|
||||
cwd: "/tmp/project",
|
||||
session: { settings } as never,
|
||||
});
|
||||
|
||||
await expect(memory.status()).resolves.toMatchObject({
|
||||
backend: "local",
|
||||
active: true,
|
||||
writable: false,
|
||||
searchable: false,
|
||||
});
|
||||
await expect(memory.search("project preference")).resolves.toMatchObject({
|
||||
backend: "local",
|
||||
count: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
- Added an optional `fetch` option to `extractFacts` to control the transport used for remote extraction calls
|
||||
- Added support for passing a custom `fetch` implementation through `complete` and `summarizeMemories` via remote LLM options
|
||||
|
||||
### Changed
|
||||
|
||||
- Reworked the in-memory fallback vector search to build a normalized exact vector index per query, reducing repeated cosine-normalization work and matching the shape needed for future quantized index backends.
|
||||
|
||||
## [15.9.1] - 2026-06-04
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Database } from "bun:sqlite";
|
||||
import { generateId as generateTimedId, sha256Hex16, stableMemoryId } from "../../util/ids";
|
||||
import { currentEmbeddingModel, embed } from "../embeddings";
|
||||
import { getMnemopiRuntimeOptions, withMnemopiRuntimeOptions } from "../runtime-options";
|
||||
import { cosineSimilarity as vectorCosineSimilarity } from "../vector-math";
|
||||
import { buildExactVectorIndex, searchExactVectorIndex } from "../vector-index";
|
||||
import type { BeamMemoryState, JsonValue, Metadata } from "./types";
|
||||
|
||||
export type Vector = number[];
|
||||
@@ -551,16 +551,10 @@ export function inMemoryVecSearch(db: Database, queryEmbedding: readonly number[
|
||||
LIMIT 10000
|
||||
`)
|
||||
.all() as Record<string, unknown>[];
|
||||
const results: VectorDistanceResult[] = [];
|
||||
for (const row of rows) {
|
||||
const vec = decodeVector(String(row.embedding_json ?? ""));
|
||||
if (vec === null) continue;
|
||||
const sim = vectorCosineSimilarity(queryEmbedding, vec);
|
||||
if (sim === 0 && (queryEmbedding.every(n => n === 0) || vec.every(n => n === 0))) continue;
|
||||
results.push({ rowid: Number(row.rowid), distance: 1 - sim });
|
||||
}
|
||||
results.sort((a, b) => a.distance - b.distance || a.rowid - b.rowid);
|
||||
return results.slice(0, Math.max(0, Math.trunc(k)));
|
||||
const index = buildExactVectorIndex(
|
||||
rows.map(row => ({ id: Number(row.rowid), vector: decodeVector(String(row.embedding_json ?? "")) })),
|
||||
);
|
||||
return searchExactVectorIndex(index, queryEmbedding, k).map(hit => ({ rowid: hit.id, distance: 1 - hit.score }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -585,16 +579,10 @@ export function workingMemoryVecSearch(
|
||||
LIMIT ?
|
||||
`)
|
||||
.all(now.toISOString(), limit) as Record<string, unknown>[];
|
||||
const results: WorkingVectorResult[] = [];
|
||||
for (const row of rows) {
|
||||
const vec = decodeVector(String(row.embedding_json ?? ""));
|
||||
if (vec === null) continue;
|
||||
const sim = vectorCosineSimilarity(queryEmbedding, vec);
|
||||
if (sim === 0 && (queryEmbedding.every(n => n === 0) || vec.every(n => n === 0))) continue;
|
||||
results.push({ id: String(row.id), sim });
|
||||
}
|
||||
results.sort((a, b) => b.sim - a.sim || a.id.localeCompare(b.id));
|
||||
return results.slice(0, Math.max(0, Math.trunc(k)));
|
||||
const index = buildExactVectorIndex(
|
||||
rows.map(row => ({ id: String(row.id), vector: decodeVector(String(row.embedding_json ?? "")) })),
|
||||
);
|
||||
return searchExactVectorIndex(index, queryEmbedding, k).map(hit => ({ id: hit.id, sim: hit.score }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
export interface ExactVectorSearchHit<TId> {
|
||||
id: TId;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface ExactVectorIndex<TId> {
|
||||
readonly ids: readonly TId[];
|
||||
readonly matrix: Float32Array;
|
||||
readonly dimensions: number;
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
export interface VectorIndexRow<TId> {
|
||||
id: TId;
|
||||
vector: readonly number[] | null | undefined;
|
||||
}
|
||||
|
||||
export function buildExactVectorIndex<TId>(rows: readonly VectorIndexRow<TId>[]): ExactVectorIndex<TId> {
|
||||
const valid: Array<{ id: TId; vector: readonly number[]; norm: number }> = [];
|
||||
let dimensions = 0;
|
||||
for (const row of rows) {
|
||||
const vector = row.vector;
|
||||
if (!vector || vector.length === 0) continue;
|
||||
let normSq = 0;
|
||||
for (let i = 0; i < vector.length; i += 1) {
|
||||
const value = vector[i] ?? 0;
|
||||
if (!Number.isFinite(value)) {
|
||||
normSq = 0;
|
||||
break;
|
||||
}
|
||||
normSq += value * value;
|
||||
}
|
||||
if (normSq <= 0) continue;
|
||||
valid.push({ id: row.id, vector, norm: Math.sqrt(normSq) });
|
||||
if (vector.length > dimensions) dimensions = vector.length;
|
||||
}
|
||||
|
||||
const matrix = new Float32Array(valid.length * dimensions);
|
||||
const ids: TId[] = [];
|
||||
for (let row = 0; row < valid.length; row += 1) {
|
||||
const item = valid[row];
|
||||
ids.push(item.id);
|
||||
const offset = row * dimensions;
|
||||
for (let col = 0; col < item.vector.length; col += 1) {
|
||||
matrix[offset + col] = (item.vector[col] ?? 0) / item.norm;
|
||||
}
|
||||
}
|
||||
|
||||
return { ids, matrix, dimensions, count: ids.length };
|
||||
}
|
||||
|
||||
export function searchExactVectorIndex<TId>(
|
||||
index: ExactVectorIndex<TId>,
|
||||
query: readonly number[],
|
||||
limit: number,
|
||||
): ExactVectorSearchHit<TId>[] {
|
||||
const k = Math.max(0, Math.trunc(limit));
|
||||
if (k === 0 || index.count === 0 || index.dimensions === 0 || query.length === 0) return [];
|
||||
|
||||
let queryNormSq = 0;
|
||||
for (const value of query) {
|
||||
if (!Number.isFinite(value)) return [];
|
||||
queryNormSq += value * value;
|
||||
}
|
||||
if (queryNormSq <= 0) return [];
|
||||
const queryNorm = Math.sqrt(queryNormSq);
|
||||
const queryDimensions = Math.min(query.length, index.dimensions);
|
||||
const hits: ExactVectorSearchHit<TId>[] = [];
|
||||
|
||||
for (let row = 0; row < index.count; row += 1) {
|
||||
const offset = row * index.dimensions;
|
||||
let score = 0;
|
||||
for (let col = 0; col < queryDimensions; col += 1) {
|
||||
score += index.matrix[offset + col] * ((query[col] ?? 0) / queryNorm);
|
||||
}
|
||||
hits.push({ id: index.ids[row] as TId, score });
|
||||
}
|
||||
|
||||
hits.sort((a, b) => b.score - a.score);
|
||||
return hits.slice(0, Math.min(k, hits.length));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { buildExactVectorIndex, searchExactVectorIndex } from "../src/core/vector-index";
|
||||
|
||||
describe("exact vector index", () => {
|
||||
it("normalizes vectors and returns nearest ids by cosine score", () => {
|
||||
const index = buildExactVectorIndex([
|
||||
{ id: "x", vector: [1, 0] },
|
||||
{ id: "y", vector: [0, 2] },
|
||||
{ id: "z", vector: [0, 0] },
|
||||
]);
|
||||
|
||||
expect(index.count).toBe(2);
|
||||
expect(searchExactVectorIndex(index, [0, 3], 2)).toEqual([
|
||||
{ id: "y", score: 1 },
|
||||
{ id: "x", score: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns no hits for invalid or empty queries", () => {
|
||||
const index = buildExactVectorIndex([{ id: 1, vector: [1, 0] }]);
|
||||
|
||||
expect(searchExactVectorIndex(index, [], 10)).toEqual([]);
|
||||
expect(searchExactVectorIndex(index, [Number.NaN], 10)).toEqual([]);
|
||||
expect(searchExactVectorIndex(index, [1, 0], 0)).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user