fix(memory): preserve document order for indexed embedding responses (#129227)

* fix(memory): preserve embedding input order across providers

* test(memory): preserve sparse-vector regression without array constructor
This commit is contained in:
Peter Steinberger
2026-08-25 03:30:17 -07:00
committed by GitHub
parent 452e734022
commit bcd383496c
5 changed files with 166 additions and 68 deletions
@@ -1,4 +1,50 @@
// Vector normalization helpers used before embedding similarity search.
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
/** Validate provider embeddings and restore their original request order. */
export function readEmbeddingVectors(
data: unknown,
expectedCount: number | undefined,
errorPrefix: string,
): number[][] {
const malformedResponse = () => new Error(`${errorPrefix}: malformed JSON response`);
if (!Array.isArray(data) || (expectedCount !== undefined && data.length !== expectedCount)) {
throw malformedResponse();
}
const vectors: number[][] = [];
let indexed: boolean | undefined;
for (let position = 0; position < data.length; position += 1) {
const entry = asOptionalRecord(data[position]);
const embedding = entry?.embedding;
const usesIndex = entry?.index !== undefined;
if (
!entry ||
!Array.isArray(embedding) ||
embedding.length === 0 ||
(indexed !== undefined && indexed !== usesIndex)
) {
throw malformedResponse();
}
for (const coordinate of embedding) {
if (typeof coordinate !== "number" || !Number.isFinite(coordinate)) {
throw malformedResponse();
}
}
indexed = usesIndex;
const index = usesIndex ? entry.index : position;
if (
typeof index !== "number" ||
!Number.isInteger(index) ||
index < 0 ||
index >= data.length ||
vectors[index] !== undefined
) {
throw malformedResponse();
}
vectors[index] = embedding;
}
return vectors;
}
/** Replace invalid coordinates and L2-normalize non-empty vectors. */
export function sanitizeAndNormalizeEmbedding(vec: number[]): number[] {
@@ -70,6 +70,54 @@ describe("fetchRemoteEmbeddingVectors", () => {
expect(requirePostJsonParams().signal).toBe(controller.signal);
});
it("returns indexed response vectors in their original request order", async () => {
postJsonMock.mockImplementationOnce(async (params) =>
params.parse({
data: [
{ index: 1, embedding: [0.2] },
{ index: 0, embedding: [0.1] },
],
}),
);
await expect(
fetchRemoteEmbeddingVectors({
url: "https://memory.example/v1/embeddings",
headers: {},
body: { input: ["first", "second"] },
errorPrefix: "embedding fetch failed",
}),
).resolves.toEqual([[0.1], [0.2]]);
});
it.each([
{ name: "mixed indexed and positional", indexes: [0, undefined] },
{ name: "duplicate", indexes: [0, 0] },
{ name: "out-of-range", indexes: [0, 2] },
{ name: "negative", indexes: [0, -1] },
{ name: "fractional", indexes: [0, 0.5] },
{ name: "non-numeric", indexes: [0, "1"] },
{ name: "null", indexes: [0, null] },
])("rejects $name embedding indexes", async ({ indexes }) => {
postJsonMock.mockImplementationOnce(async (params) =>
params.parse({
data: indexes.map((index) => ({
...(index === undefined ? {} : { index }),
embedding: [0.1],
})),
}),
);
await expect(
fetchRemoteEmbeddingVectors({
url: "https://memory.example/v1/embeddings",
headers: {},
body: { input: ["first", "second"] },
errorPrefix: "embedding fetch failed",
}),
).rejects.toThrow("embedding fetch failed: malformed JSON response");
});
it("throws a status-rich error on non-ok responses", async () => {
postJsonMock.mockRejectedValueOnce(new Error("embedding fetch failed: 403 forbidden"));
@@ -157,9 +205,33 @@ describe("fetchRemoteEmbeddingVectors", () => {
).resolves.toEqual([]);
});
it("rejects wrong nested embedding vector types", async () => {
it("accepts response-sized vectors when request input is not an array", async () => {
postJsonMock.mockImplementationOnce(async (params) =>
params.parse({
data: [
{ index: 1, embedding: [0.2] },
{ index: 0, embedding: [0.1] },
],
}),
);
await expect(
fetchRemoteEmbeddingVectors({
url: "https://memory.example/v1/embeddings",
headers: {},
body: { input: "query" },
errorPrefix: "embedding fetch failed",
}),
).resolves.toEqual([[0.1], [0.2]]);
});
it.each([
{ name: "non-numeric", embedding: [0.1, "bad"] },
{ name: "sparse", embedding: Object.assign(Array.of<number>(), { 1: 0.1 }) },
{ name: "non-finite", embedding: [Number.POSITIVE_INFINITY] },
])("rejects $name embedding coordinates", async ({ embedding }) => {
postJsonMock.mockImplementationOnce(async (params) => {
return await params.parse({ data: [{ embedding: [0.1, "bad"] }] });
return await params.parse({ data: [{ embedding }] });
});
await expect(
@@ -1,35 +1,12 @@
// Memory Host SDK module implements embeddings remote fetch behavior.
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import { readEmbeddingVectors } from "./embedding-vectors.js";
import type { SsrFPolicy } from "./openclaw-runtime-network.js";
import { postJson } from "./post-json.js";
// Fetches and validates OpenAI-compatible embedding responses.
/** Build the common malformed embedding response error. */
function malformedEmbeddingResponse(errorPrefix: string): Error {
return new Error(`${errorPrefix}: malformed JSON response`);
}
/** Validate and return one finite embedding vector. */
function readEmbeddingVector(value: unknown, errorPrefix: string): number[] {
if (!Array.isArray(value) || value.length === 0) {
throw malformedEmbeddingResponse(errorPrefix);
}
for (const entry of value) {
if (typeof entry !== "number" || !Number.isFinite(entry)) {
throw malformedEmbeddingResponse(errorPrefix);
}
}
return value;
}
/** Resolve expected response count from the request body when input is an array. */
function resolveExpectedEmbeddingCount(body: unknown): number | undefined {
const input = asOptionalRecord(body)?.input;
return Array.isArray(input) ? input.length : undefined;
}
/** POST an embedding request and return validated vectors in provider response order. */
/** POST an embedding request and return validated vectors in request order. */
export async function fetchRemoteEmbeddingVectors(params: {
url: string;
headers: Record<string, string>;
@@ -48,21 +25,12 @@ export async function fetchRemoteEmbeddingVectors(params: {
body: params.body,
errorPrefix: params.errorPrefix,
parse: (payload) => {
const root = asOptionalRecord(payload);
if (!root || !Array.isArray(root.data)) {
throw malformedEmbeddingResponse(params.errorPrefix);
}
const expectedCount = resolveExpectedEmbeddingCount(params.body);
if (expectedCount !== undefined && root.data.length !== expectedCount) {
throw malformedEmbeddingResponse(params.errorPrefix);
}
return root.data.map((entry) => {
const record = asOptionalRecord(entry);
if (!record) {
throw malformedEmbeddingResponse(params.errorPrefix);
}
return readEmbeddingVector(record.embedding, params.errorPrefix);
});
const input = asOptionalRecord(params.body)?.input;
return readEmbeddingVectors(
asOptionalRecord(payload)?.data,
Array.isArray(input) ? input.length : undefined,
params.errorPrefix,
);
},
});
}
@@ -910,6 +910,27 @@ describe("openai-compatible generic embedding provider", () => {
).rejects.toThrow("missing model");
});
it.each([
{
name: "out-of-order indexed",
data: [
{ index: 1, embedding: [0.2] },
{ index: 0, embedding: [0.1] },
],
},
{
name: "fully positional compatible-provider",
data: [{ embedding: [0.1] }, { embedding: [0.2] }],
},
])("returns $name responses in original document order", async ({ data }) => {
const server = await startEmbeddingServer({ respond: () => ({ data }) });
const { provider } = await createOpenAICompatibleEmbeddingProvider(
createOptions({ remote: { baseUrl: server.baseUrl } }),
);
await expect(provider.embedBatch(["first", "second"])).resolves.toEqual([[0.1], [0.2]]);
});
it.each([
{ name: "missing vectors", input: "hello", response: { data: [] } },
{ name: "empty direct vector", input: "hello", response: { data: [{ embedding: [] }] } },
@@ -918,6 +939,21 @@ describe("openai-compatible generic embedding provider", () => {
input: ["hello", "world"],
response: { data: [{ embedding: [1] }, { embedding: [] }] },
},
{
name: "mixed indexed and positional vectors",
input: ["hello", "world"],
response: { data: [{ index: 0, embedding: [1] }, { embedding: [2] }] },
},
{
name: "duplicate vector indexes",
input: ["hello", "world"],
response: {
data: [
{ index: 0, embedding: [1] },
{ index: 0, embedding: [2] },
],
},
},
{ name: "null response root", input: "hello", response: null },
])("rejects malformed $name with the provider-specific error", async ({ input, response }) => {
const server = await startEmbeddingServer({ respond: () => response });
@@ -1,8 +1,8 @@
// Builds OpenAI-compatible embedding provider entries for plugins.
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { asOptionalRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { readEmbeddingVectors } from "../../packages/memory-host-sdk/src/host/embedding-vectors.js";
import { readProviderJsonArrayFieldResponse } from "../agents/provider-http-errors.js";
import type {
AcquireConfiguredProviderLocalService,
@@ -247,31 +247,6 @@ function malformedEmbeddingResponse(): Error {
return new Error("openai-compatible embeddings failed: malformed JSON response");
}
function readEmbeddingVector(value: unknown): number[] {
if (!Array.isArray(value) || value.length === 0) {
throw malformedEmbeddingResponse();
}
for (const entry of value) {
if (typeof entry !== "number" || !Number.isFinite(entry)) {
throw malformedEmbeddingResponse();
}
}
return value;
}
function readEmbeddingVectors(data: unknown[], expectedCount: number): number[][] {
if (data.length !== expectedCount) {
throw malformedEmbeddingResponse();
}
return data.map((entry) => {
const record = asRecord(entry);
if (!record) {
throw malformedEmbeddingResponse();
}
return readEmbeddingVector(record.embedding);
});
}
async function readEmbeddingErrorBodySnippet(response: Response): Promise<string | undefined> {
if (!response.body || response.bodyUsed) {
return undefined;
@@ -337,6 +312,7 @@ async function postEmbeddingRequest(params: {
"data",
),
input.length,
"openai-compatible embeddings failed",
);
} finally {
await release();