diff --git a/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.test.tsx b/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.test.tsx
index c08571cfc..421ca0d88 100644
--- a/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.test.tsx
+++ b/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.test.tsx
@@ -150,30 +150,54 @@ describe("VectorEmbeddingPoliciesComponent - embedding source", () => {
});
await waitFor(() => {
expect(screen.getByText("At least one source path is required")).toBeInTheDocument();
- expect(screen.getByText("Model name is required")).toBeInTheDocument();
- expect(screen.getByText("Endpoint is required")).toBeInTheDocument();
+ expect(screen.getByText("Embedding model name is required")).toBeInTheDocument();
+ expect(screen.getByText("Microsoft Foundry Endpoint is required")).toBeInTheDocument();
});
const last = onChange.mock.calls[onChange.mock.calls.length - 1];
expect(last[2]).toBe(false);
});
- test("invalid endpoint shows the https:// error", async () => {
+ test("source paths must start with slash and differ from vector path", async () => {
+ expandSection();
+ fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-sourcePaths-1"), {
+ target: { value: "description" },
+ });
+ await waitFor(() => expect(screen.getByText("Source paths must start with /")).toBeInTheDocument());
+
+ fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-sourcePaths-1"), {
+ target: { value: "/vector2" },
+ });
+ await waitFor(() => expect(screen.getByText("Source path must be different from vector path")).toBeInTheDocument());
+ });
+
+ test("invalid endpoint shows the Azure OpenAI or Foundry URL error", async () => {
expandSection();
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-endpoint-1"), {
target: { value: "not-a-url" },
});
- await waitFor(() => expect(screen.getByText("Endpoint must be a valid https:// URL")).toBeInTheDocument());
+ await waitFor(() =>
+ expect(screen.getByText("Endpoint must be a valid Azure OpenAI or Foundry https:// URL")).toBeInTheDocument(),
+ );
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-endpoint-1"), {
target: { value: "http://insecure.example.com" },
});
- await waitFor(() => expect(screen.getByText("Endpoint must be a valid https:// URL")).toBeInTheDocument());
+ await waitFor(() =>
+ expect(screen.getByText("Endpoint must be a valid Azure OpenAI or Foundry https:// URL")).toBeInTheDocument(),
+ );
+
+ fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-endpoint-1"), {
+ target: { value: "https://example.com" },
+ });
+ await waitFor(() =>
+ expect(screen.getByText("Endpoint must be a valid Azure OpenAI or Foundry https:// URL")).toBeInTheDocument(),
+ );
});
test("valid input propagates an embeddingSource with parsed sourcePaths", async () => {
expandSection();
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-sourcePaths-1"), {
- target: { value: "/description, title" },
+ target: { value: "/description, /title" },
});
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-deploymentName-1"), {
target: { value: "my-deployment" },
@@ -213,7 +237,7 @@ describe("VectorEmbeddingPoliciesComponent - embedding source", () => {
fireEvent.change(sourcePaths, { target: { value: "/description" } });
fireEvent.change(deploymentName, { target: { value: "d" } });
fireEvent.change(modelName, { target: { value: "m" } });
- fireEvent.change(endpoint, { target: { value: "https://x.example.com" } });
+ fireEvent.change(endpoint, { target: { value: "https://x.openai.azure.com" } });
await waitFor(() => {
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
@@ -264,6 +288,102 @@ describe("VectorEmbeddingPoliciesComponent - embedding source", () => {
await new Promise((resolve) => setTimeout(resolve, 100));
expect(onChange.mock.calls.length).toBe(stable);
});
+
+ test("model-specific dimension validation blocks out-of-range values", async () => {
+ expandSection();
+ fireEvent.change(view.container.querySelector("#vector-policy-dimension-1"), { target: { value: "3073" } });
+ fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-sourcePaths-1"), {
+ target: { value: "/description" },
+ });
+ fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-deploymentName-1"), {
+ target: { value: "text-embedding-3-large" },
+ });
+ fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-modelName-1"), {
+ target: { value: "text-embedding-3-large" },
+ });
+ fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-endpoint-1"), {
+ target: { value: "https://my-foundry.openai.azure.com" },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Dimension must be greater than 0 and less than or equal 3072")).toBeInTheDocument();
+ const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
+ expect(lastCall[2]).toBe(false);
+ });
+ });
+
+ test("existing embedding source allows endpoint edit but keeps source fields read-only", async () => {
+ const existingEmbedding: VectorEmbedding[] = [
+ {
+ path: "/vector4",
+ dataType: "float32",
+ distanceFunction: "cosine",
+ dimensions: 1536,
+ embeddingSource: {
+ sourcePaths: ["/description"],
+ deploymentName: "text-embedding-3-small",
+ modelName: "text-embedding-3-small",
+ endpoint: "https://old.openai.azure.com",
+ authType: "Entra",
+ },
+ },
+ ];
+ const existingOnChange = jest.fn();
+ const existingView = render(
+ ,
+ );
+
+ const sourcePaths = existingView.container.querySelector(
+ "#vector-policy-embeddingSource-sourcePaths-1",
+ ) as HTMLInputElement;
+ const endpoint = existingView.container.querySelector(
+ "#vector-policy-embeddingSource-endpoint-1",
+ ) as HTMLInputElement;
+ expect(sourcePaths).toBeDisabled();
+ expect(endpoint).not.toBeDisabled();
+
+ fireEvent.change(endpoint, { target: { value: "https://new.openai.azure.com" } });
+ await waitFor(() => {
+ const lastCall = existingOnChange.mock.calls[existingOnChange.mock.calls.length - 1];
+ expect(lastCall[2]).toBe(true);
+ expect(lastCall[0][0].embeddingSource.endpoint).toBe("https://new.openai.azure.com");
+ });
+ });
+
+ test("existing vector policy without an embedding source cannot add one", () => {
+ const existingEmbedding: VectorEmbedding[] = [
+ {
+ path: "/vector5",
+ dataType: "float32",
+ distanceFunction: "cosine",
+ dimensions: 1536,
+ },
+ ];
+ const existingView = render(
+ ,
+ );
+
+ fireEvent.click(existingView.container.querySelector('[data-test="VectorEmbeddingSource/Section/1"]'));
+
+ expect(existingView.container.querySelector("#vector-policy-embeddingSource-sourcePaths-1")).toBeDisabled();
+ expect(existingView.container.querySelector("#vector-policy-embeddingSource-deploymentName-1")).toBeDisabled();
+ expect(existingView.container.querySelector("#vector-policy-embeddingSource-modelName-1")).toBeDisabled();
+ expect(existingView.container.querySelector("#vector-policy-embeddingSource-authType-1")).toHaveAttribute(
+ "aria-disabled",
+ "true",
+ );
+ expect(existingView.container.querySelector("#vector-policy-embeddingSource-endpoint-1")).not.toBeDisabled();
+ });
});
describe("VectorEmbeddingPoliciesComponent - embedding source gating", () => {
diff --git a/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx b/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx
index 62da81074..44b85d0e1 100644
--- a/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx
+++ b/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx
@@ -41,6 +41,7 @@ export interface VectorEmbeddingPolicyData {
distanceFunction: VectorEmbedding["distanceFunction"];
dimensions: number;
indexType: VectorIndex["type"] | "none";
+ dataTypeError: string;
pathError: string;
dimensionsError: string;
vectorIndexShardKey?: string[];
@@ -54,6 +55,18 @@ export interface VectorEmbeddingPolicyData {
}
type VectorEmbeddingPolicyProperty = "dataType" | "distanceFunction" | "indexType";
+const embeddingSourceSupportedDataTypes: VectorEmbedding["dataType"][] = ["float32", "float16"];
+
+const getEmbeddingSourceDimensionLimit = (modelName: string | undefined): number | undefined => {
+ switch (modelName?.trim()) {
+ case "text-embedding-3-large":
+ return 3072;
+ case "text-embedding-3-small":
+ return 1536;
+ default:
+ return undefined;
+ }
+};
export const VectorEmbeddingPoliciesComponent: FunctionComponent = ({
vectorEmbeddingsBaseline,
@@ -95,7 +108,21 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent {
+ const onVectorEmbeddingDataTypeError = (
+ dataType: VectorEmbedding["dataType"],
+ embeddingSource?: VectorEmbeddingSource,
+ ): string => {
+ if (embeddingSource && !embeddingSourceSupportedDataTypes.includes(dataType)) {
+ return t(Keys.controls.vectorEmbeddingPolicies.embeddingSourceDataTypeError);
+ }
+ return "";
+ };
+
+ const onVectorEmbeddingDimensionError = (
+ dimension: number,
+ indexType: VectorIndex["type"] | "none",
+ embeddingSource?: VectorEmbeddingSource,
+ ): string => {
let error = "";
if (dimension <= 0 || dimension > 4096) {
error = t(Keys.controls.vectorEmbeddingPolicies.dimensionRangeError);
@@ -103,6 +130,15 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent 505) {
error = t(Keys.controls.vectorEmbeddingPolicies.dimensionFlatIndexError);
}
+ if (embeddingSource?.modelName === "text-embedding-ada-002" && dimension !== 1536) {
+ error = t(Keys.controls.vectorEmbeddingPolicies.adaDimensionError);
+ }
+ const modelDimensionLimit = getEmbeddingSourceDimensionLimit(embeddingSource?.modelName);
+ if (modelDimensionLimit && (dimension <= 0 || dimension > modelDimensionLimit)) {
+ error = t(Keys.controls.vectorEmbeddingPolicies.modelDimensionRangeError, {
+ max: modelDimensionLimit,
+ });
+ }
return error;
};
@@ -137,7 +173,12 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent
- policy.pathError === "" && policy.dimensionsError === "" && policy.embeddingSourceValid,
+ policy.pathError === "" &&
+ policy.dataTypeError === "" &&
+ policy.dimensionsError === "" &&
+ policy.embeddingSourceValid,
);
onVectorEmbeddingChange(vectorEmbeddings, vectorIndexes, validationPassed);
@@ -216,7 +260,7 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent {
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
vectorEmbeddings[index][property] = option.key as never;
+ if (property === "dataType") {
+ vectorEmbeddings[index].dataTypeError = onVectorEmbeddingDataTypeError(
+ vectorEmbeddings[index].dataType,
+ vectorEmbeddings[index].embeddingSource,
+ );
+ }
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
@@ -294,7 +348,15 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent, option: IDropdownOption) =>
onVectorEmbeddingPolicyChange(index, option, "dataType")
}
+ errorMessage={vectorEmbeddingPolicy.dataTypeError}
>
@@ -529,6 +593,7 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent
);
-const validateSourcePaths = (raw: string): string => {
+const validateSourcePaths = (raw: string, vectorPath: string): string => {
const parsed = parseSourcePaths(raw);
if (parsed.length === 0) {
return t(Keys.controls.vectorEmbeddingPolicies.sourcePathsRequiredError);
}
const seen = new Set();
for (const p of parsed) {
+ if (!p.startsWith("/")) {
+ return t(Keys.controls.vectorEmbeddingPolicies.sourcePathInvalidError);
+ }
+ if (p === vectorPath) {
+ return t(Keys.controls.vectorEmbeddingPolicies.sourcePathSameAsVectorPathError);
+ }
if (seen.has(p)) {
return t(Keys.controls.vectorEmbeddingPolicies.sourcePathDuplicateError);
}
@@ -64,7 +71,7 @@ const validateEndpoint = (value: string | undefined): string => {
if (!value || value.trim().length === 0) {
return t(Keys.controls.vectorEmbeddingPolicies.endpointRequiredError);
}
- if (!isValidHttpsUrl(value.trim())) {
+ if (!isValidFoundryEndpoint(value.trim())) {
return t(Keys.controls.vectorEmbeddingPolicies.endpointInvalidError);
}
return "";
@@ -72,6 +79,7 @@ const validateEndpoint = (value: string | undefined): string => {
export const VectorEmbeddingSourceComponent: FunctionComponent = ({
index,
+ vectorPath,
disabled,
initialEmbeddingSource,
discardChanges,
@@ -93,7 +101,7 @@ export const VectorEmbeddingSourceComponent: FunctionComponent 0 ||
endpoint.trim().length > 0;
- const sourcePathsError = hasAnyValue ? validateSourcePaths(sourcePathsRaw) : "";
+ const sourcePathsError = hasAnyValue ? validateSourcePaths(sourcePathsRaw, vectorPath) : "";
const deploymentNameError = hasAnyValue
? validateRequired(deploymentName, Keys.controls.vectorEmbeddingPolicies.deploymentNameRequiredError)
: "";
@@ -196,11 +204,11 @@ export const VectorEmbeddingSourceComponent: FunctionComponent
-