mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
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:
committed by
GitHub
parent
452e734022
commit
bcd383496c
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user