Add form and validation for vector search (#1856)

* Add form and validation for vector search

* Add form and validation for vector search

* Add unit tests and merge forms
This commit is contained in:
sunghyunkang1111 2024-06-10 13:37:51 -05:00 committed by GitHub
parent 06d4829422
commit c680481fe0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 460 additions and 88 deletions

View File

@ -209,6 +209,7 @@
"test": "rimraf coverage && jest",
"test:debug": "jest --runInBand",
"test:e2e": "jest -c ./jest.config.playwright.js --detectOpenHandles",
"test:file": "jest --coverage=false",
"watch": "npm run start",
"wait-for-server": "wait-on -t 240000 -i 5000 -v https-get://0.0.0.0:1234/",
"build:ase": "gulp build:ase",

View File

@ -1,4 +1,4 @@
import { Icon, Label, Stack } from "@fluentui/react";
import { DirectionalHint, Icon, Label, Stack, TooltipHost } from "@fluentui/react";
import * as React from "react";
import { NormalizedEventKey } from "../../../Common/Constants";
import { accordionStackTokens } from "../Settings/SettingsRenderUtils";
@ -8,6 +8,7 @@ export interface CollapsibleSectionProps {
isExpandedByDefault: boolean;
onExpand?: () => void;
children: JSX.Element;
tooltipContent?: string | JSX.Element | JSX.Element[];
}
export interface CollapsibleSectionState {
@ -55,6 +56,19 @@ export class CollapsibleSectionComponent extends React.Component<CollapsibleSect
>
<Icon iconName={this.state.isExpanded ? "ChevronDown" : "ChevronRight"} />
<Label>{this.props.title}</Label>
{this.props.tooltipContent && (
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={this.props.tooltipContent}
styles={{
root: {
marginLeft: "0 !important",
},
}}
>
<Icon iconName="Info" className="panelInfoIcon" tabIndex={0} />
</TooltipHost>
)}
</Stack>
{this.state.isExpanded && this.props.children}
</>

View File

@ -21,7 +21,7 @@ import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
import { configContext, Platform } from "ConfigContext";
import * as DataModels from "Contracts/DataModels";
import { SubscriptionType } from "Contracts/SubscriptionType";
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import { AddVectorEmbeddingPolicyForm } from "Explorer/Panes/VectorSearchPanel/AddVectorEmbeddingPolicyForm";
import { useSidePanel } from "hooks/useSidePanel";
import { useTeachingBubble } from "hooks/useTeachingBubble";
import React from "react";
@ -82,22 +82,6 @@ export const AllPropertiesIndexed: DataModels.IndexingPolicy = {
excludedPaths: [],
};
const DefaultDatabaseVectorIndex: DataModels.IndexingPolicy = {
indexingMode: "consistent",
automatic: true,
includedPaths: [
{
path: "/*",
},
],
excludedPaths: [
{
path: '/"_etag"/?',
},
],
vectorIndexes: [],
};
export const DefaultVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy = {
vectorEmbeddings: [],
};
@ -122,8 +106,9 @@ export interface AddCollectionPanelState {
isExecuting: boolean;
isThroughputCapExceeded: boolean;
teachingBubbleStep: number;
vectorIndexingPolicy: string;
vectorEmbeddingPolicy: string;
vectorIndexingPolicy: DataModels.VectorIndex[];
vectorEmbeddingPolicy: DataModels.VectorEmbedding[];
vectorPolicyValidated: boolean;
}
export class AddCollectionPanel extends React.Component<AddCollectionPanelProps, AddCollectionPanelState> {
@ -159,8 +144,9 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
isExecuting: false,
isThroughputCapExceeded: false,
teachingBubbleStep: 0,
vectorIndexingPolicy: JSON.stringify(DefaultDatabaseVectorIndex, null, 2),
vectorEmbeddingPolicy: JSON.stringify(DefaultVectorEmbeddingPolicy, null, 2),
vectorEmbeddingPolicy: [],
vectorIndexingPolicy: [],
vectorPolicyValidated: true,
};
}
@ -896,61 +882,29 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
)}
{this.shouldShowVectorSearchParameters() && (
<Stack>
<CollapsibleSectionComponent
title="Indexing Policy"
isExpandedByDefault={false}
onExpand={() => {
this.scrollToSection("collapsibleVectorPolicySectionContent");
}}
>
<Stack id="collapsibleVectorPolicySectionContent" styles={{ root: { position: "relative" } }}>
<Link href="https://aka.ms/CosmosDBVectorSetup" target="_blank">
Learn more
</Link>
<EditorReact
language={"json"}
content={this.state.vectorIndexingPolicy}
isReadOnly={false}
wordWrap={"on"}
ariaLabel={"Editing indexing policy"}
lineNumbers={"on"}
scrollBeyondLastLine={false}
spinnerClassName="panelSectionSpinner"
monacoContainerStyles={{
minHeight: 200,
}}
onContentChanged={(newIndexingPolicy: string) => this.setVectorIndexingPolicy(newIndexingPolicy)}
/>
</Stack>
</CollapsibleSectionComponent>
<CollapsibleSectionComponent
title="Container Vector Policy"
isExpandedByDefault={false}
onExpand={() => {
this.scrollToSection("collapsibleVectorPolicySectionContent");
}}
tooltipContent={this.getContainerVectorPolicyTooltipContent()}
>
<Stack id="collapsibleVectorPolicySectionContent" styles={{ root: { position: "relative" } }}>
<Link href="https://aka.ms/CosmosDBVectorSetup" target="_blank">
Learn more
</Link>
<EditorReact
language={"json"}
content={this.state.vectorEmbeddingPolicy}
isReadOnly={false}
wordWrap={"on"}
ariaLabel={"Editing container vector policy"}
lineNumbers={"on"}
scrollBeyondLastLine={false}
spinnerClassName="panelSectionSpinner"
monacoContainerStyles={{
minHeight: 200,
<Stack styles={{ root: { paddingLeft: 40 } }}>
<AddVectorEmbeddingPolicyForm
vectorEmbedding={this.state.vectorEmbeddingPolicy}
vectorIndex={this.state.vectorIndexingPolicy}
onVectorEmbeddingChange={(
vectorEmbeddingPolicy: DataModels.VectorEmbedding[],
vectorIndexingPolicy: DataModels.VectorIndex[],
vectorPolicyValidated: boolean,
) => {
this.setState({ vectorEmbeddingPolicy, vectorIndexingPolicy, vectorPolicyValidated });
}}
onContentChanged={(newVectorEmbeddingPolicy: string) =>
this.setVectorEmbeddingPolicy(newVectorEmbeddingPolicy)
}
/>
</Stack>
</Stack>
</CollapsibleSectionComponent>
</Stack>
)}
@ -1159,13 +1113,13 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
}
}
private setVectorEmbeddingPolicy(vectorEmbeddingPolicy: string): void {
private setVectorEmbeddingPolicy(vectorEmbeddingPolicy: DataModels.VectorEmbedding[]): void {
this.setState({
vectorEmbeddingPolicy,
});
}
private setVectorIndexingPolicy(vectorIndexingPolicy: string): void {
private setVectorIndexingPolicy(vectorIndexingPolicy: DataModels.VectorIndex[]): void {
this.setState({
vectorIndexingPolicy,
});
@ -1251,6 +1205,18 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
);
}
private getContainerVectorPolicyTooltipContent(): JSX.Element {
return (
<Text variant="small">
Describe any properties in your data that contain vectors, so that they can be made available for similarity
queries.{" "}
<Link target="_blank" href="https://aka.ms/CosmosDBVectorSetup">
Learn more
</Link>
</Text>
);
}
private shouldShowCollectionThroughputInput(): boolean {
if (isServerlessAccount()) {
return false;
@ -1370,22 +1336,11 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return false;
}
if (this.shouldShowVectorSearchParameters()) {
try {
JSON.parse(this.state.vectorIndexingPolicy) as DataModels.IndexingPolicy;
} catch (e) {
this.setState({ errorMessage: "Invalid JSON format for indexingPolicy" });
if (this.shouldShowVectorSearchParameters() && !this.state.vectorPolicyValidated) {
this.setState({ errorMessage: "Please fix errors in container vector policy" });
return false;
}
try {
JSON.parse(this.state.vectorEmbeddingPolicy) as DataModels.VectorEmbeddingPolicy;
} catch (e) {
this.setState({ errorMessage: "Invalid JSON format for vectorEmbeddingPolicy" });
return false;
}
}
return true;
}
@ -1461,15 +1416,17 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
}
: undefined;
let indexingPolicy: DataModels.IndexingPolicy = this.state.enableIndexing
const indexingPolicy: DataModels.IndexingPolicy = this.state.enableIndexing
? AllPropertiesIndexed
: SharedDatabaseDefault;
let vectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy;
if (this.shouldShowVectorSearchParameters()) {
indexingPolicy = JSON.parse(this.state.vectorIndexingPolicy);
vectorEmbeddingPolicy = JSON.parse(this.state.vectorEmbeddingPolicy);
indexingPolicy.vectorIndexes = this.state.vectorIndexingPolicy;
vectorEmbeddingPolicy = {
vectorEmbeddings: this.state.vectorEmbeddingPolicy,
};
}
const telemetryData = {

View File

@ -0,0 +1,84 @@
import "@testing-library/jest-dom/extend-expect";
import { RenderResult, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { VectorEmbedding, VectorIndex } from "Contracts/DataModels";
import React from "react";
import { AddVectorEmbeddingPolicyForm } from "./AddVectorEmbeddingPolicyForm";
const mockVectorEmbedding: VectorEmbedding[] = [
{ path: "/vector1", dataType: "float32", distanceFunction: "euclidean", dimensions: 0 },
];
const mockVectorIndex: VectorIndex[] = [{ path: "/vector1", type: "flat" }];
const mockOnVectorEmbeddingChange = jest.fn();
describe("AddVectorEmbeddingPolicyForm", () => {
let component: RenderResult;
beforeEach(() => {
component = render(
<AddVectorEmbeddingPolicyForm
vectorEmbedding={mockVectorEmbedding}
vectorIndex={mockVectorIndex}
onVectorEmbeddingChange={mockOnVectorEmbeddingChange}
/>,
);
});
test("renders correctly", () => {
expect(screen.getByText("Vector embedding 1")).toBeInTheDocument();
expect(screen.getByPlaceholderText("/vector1")).toBeInTheDocument();
});
test("calls onVectorEmbeddingChange on adding a new vector embedding", () => {
fireEvent.click(screen.getByText("Add vector embedding"));
expect(mockOnVectorEmbeddingChange).toHaveBeenCalled();
});
test("calls onDelete when delete button is clicked", async () => {
const deleteButton = component.container.querySelector("#delete-vector-policy-1");
fireEvent.click(deleteButton);
expect(mockOnVectorEmbeddingChange).toHaveBeenCalled();
expect(screen.queryByText("Vector embedding 1")).toBeNull();
});
test("calls onVectorEmbeddingPathChange on input change", () => {
fireEvent.change(screen.getByPlaceholderText("/vector1"), { target: { value: "/newPath" } });
expect(mockOnVectorEmbeddingChange).toHaveBeenCalled();
});
test("validates input correctly", async () => {
fireEvent.change(screen.getByPlaceholderText("/vector1"), { target: { value: "" } });
await waitFor(() => expect(screen.getByText("Vector embedding path should not be empty")).toBeInTheDocument(), {
timeout: 1500,
});
await waitFor(
() =>
expect(
screen.getByText("Vector embedding dimension must be greater than 0 and less than or equal 4096"),
).toBeInTheDocument(),
{
timeout: 1500,
},
);
fireEvent.change(component.container.querySelector("#vector-policy-dimension-1"), { target: { value: "4096" } });
fireEvent.change(screen.getByPlaceholderText("/vector1"), { target: { value: "/vector1" } });
await waitFor(() => expect(screen.queryByText("Vector embedding path should not be empty")).toBeNull(), {
timeout: 1500,
});
await waitFor(
() => expect(screen.queryByText("Maximum allowed dimension for flat index is 505")).toBeInTheDocument(),
{
timeout: 1500,
},
);
});
test("duplicate vector path is not allowed", async () => {
fireEvent.click(screen.getByText("Add vector embedding"));
fireEvent.change(component.container.querySelector("#vector-policy-path-2"), { target: { value: "/vector1" } });
await waitFor(() => expect(screen.queryByText("Vector embedding path is already defined")).toBeNull(), {
timeout: 1500,
});
});
});

View File

@ -0,0 +1,300 @@
import {
DefaultButton,
Dropdown,
IDropdownOption,
IStyleFunctionOrObject,
ITextFieldStyleProps,
ITextFieldStyles,
IconButton,
Label,
Stack,
TextField,
} from "@fluentui/react";
import { VectorEmbedding, VectorIndex } from "Contracts/DataModels";
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
import {
getDataTypeOptions,
getDistanceFunctionOptions,
getIndexTypeOptions,
} from "Explorer/Panes/VectorSearchPanel/VectorSearchUtils";
import React, { FunctionComponent, useState } from "react";
export interface IAddVectorEmbeddingPolicyFormProps {
vectorEmbedding: VectorEmbedding[];
vectorIndex: VectorIndex[];
onVectorEmbeddingChange: (
vectorEmbeddings: VectorEmbedding[],
vectorIndexingPolicies: VectorIndex[],
validationPassed: boolean,
) => void;
}
export interface VectorEmbeddingPolicyData {
path: string;
dataType: VectorEmbedding["dataType"];
distanceFunction: VectorEmbedding["distanceFunction"];
dimensions: number;
indexType: VectorIndex["type"] | "none";
pathError: string;
dimensionsError: string;
}
type VectorEmbeddingPolicyProperty = "dataType" | "distanceFunction" | "indexType";
const textFieldStyles: IStyleFunctionOrObject<ITextFieldStyleProps, ITextFieldStyles> = {
fieldGroup: {
height: 27,
},
field: {
fontSize: 12,
padding: "0 8px",
},
};
const dropdownStyles = {
title: {
height: 27,
lineHeight: "24px",
fontSize: 12,
},
dropdown: {
height: 27,
lineHeight: "24px",
},
dropdownItem: {
fontSize: 12,
},
};
export const AddVectorEmbeddingPolicyForm: FunctionComponent<IAddVectorEmbeddingPolicyFormProps> = ({
vectorEmbedding,
vectorIndex,
onVectorEmbeddingChange,
}): JSX.Element => {
const onVectorEmbeddingPathError = (path: string, index?: number): string => {
let error = "";
if (!path) {
error = "Vector embedding path should not be empty";
}
if (
index >= 0 &&
vectorEmbeddingPolicyData?.find(
(vectorEmbedding: VectorEmbeddingPolicyData, dataIndex: number) =>
dataIndex !== index && vectorEmbedding.path === path,
)
) {
error = "Vector embedding path is already defined";
}
return error;
};
const onVectorEmbeddingDimensionError = (dimension: number, indexType: VectorIndex["type"] | "none"): string => {
let error = "";
if (dimension <= 0 || dimension > 4096) {
error = "Vector embedding dimension must be greater than 0 and less than or equal 4096";
}
if (indexType === "flat" && dimension > 505) {
error = "Maximum allowed dimension for flat index is 505";
}
return error;
};
const initializeData = (vectorEmbedding: VectorEmbedding[], vectorIndex: VectorIndex[]) => {
const mergedData: VectorEmbeddingPolicyData[] = [];
vectorEmbedding.forEach((embedding) => {
const matchingIndex = vectorIndex.find((index) => index.path === embedding.path);
mergedData.push({
...embedding,
indexType: matchingIndex?.type || "none",
pathError: onVectorEmbeddingPathError(embedding.path),
dimensionsError: onVectorEmbeddingDimensionError(embedding.dimensions, matchingIndex?.type || "none"),
});
});
return mergedData;
};
const [vectorEmbeddingPolicyData, setVectorEmbeddingPolicyData] = useState<VectorEmbeddingPolicyData[]>(
initializeData(vectorEmbedding, vectorIndex),
);
React.useEffect(() => {
propagateData();
}, [vectorEmbeddingPolicyData]);
const propagateData = () => {
const vectorEmbeddings: VectorEmbedding[] = vectorEmbeddingPolicyData.map((policy: VectorEmbeddingPolicyData) => ({
dataType: policy.dataType,
dimensions: policy.dimensions,
distanceFunction: policy.distanceFunction,
path: policy.path,
}));
const vectorIndexingPolicies: VectorIndex[] = vectorEmbeddingPolicyData
.filter((policy: VectorEmbeddingPolicyData) => policy.indexType !== "none")
.map(
(policy) =>
({
path: policy.path,
type: policy.indexType,
}) as VectorIndex,
);
const validationPassed = vectorEmbeddingPolicyData.every(
(policy: VectorEmbeddingPolicyData) => policy.pathError === "" && policy.dimensionsError === "",
);
onVectorEmbeddingChange(vectorEmbeddings, vectorIndexingPolicies, validationPassed);
};
const onVectorEmbeddingPathChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value.trim();
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
if (!vectorEmbeddings[index]?.path && !value.startsWith("/")) {
vectorEmbeddings[index].path = "/" + value;
} else {
vectorEmbeddings[index].path = value;
}
const error = onVectorEmbeddingPathError(value, index);
vectorEmbeddings[index].pathError = error;
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
const onVectorEmbeddingDimensionsChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value.trim()) || 0;
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
const vectorEmbedding = vectorEmbeddings[index];
vectorEmbeddings[index].dimensions = value;
const error = onVectorEmbeddingDimensionError(value, vectorEmbedding.indexType);
vectorEmbeddings[index].dimensionsError = error;
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
const onVectorEmbeddingIndexTypeChange = (index: number, option: IDropdownOption): void => {
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
const vectorEmbedding = vectorEmbeddings[index];
vectorEmbeddings[index].indexType = option.key as never;
const error = onVectorEmbeddingDimensionError(vectorEmbedding.dimensions, vectorEmbedding.indexType);
vectorEmbeddings[index].dimensionsError = error;
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
const onVectorEmbeddingPolicyChange = (
index: number,
option: IDropdownOption,
property: VectorEmbeddingPolicyProperty,
): void => {
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
vectorEmbeddings[index][property] = option.key as never;
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
const onAdd = () => {
setVectorEmbeddingPolicyData([
...vectorEmbeddingPolicyData,
{
path: "",
dataType: "float32",
distanceFunction: "euclidean",
dimensions: 0,
indexType: "none",
pathError: onVectorEmbeddingPathError(""),
dimensionsError: onVectorEmbeddingDimensionError(0, "none"),
},
]);
};
const onDelete = (index: number) => {
const vectorEmbeddings = vectorEmbeddingPolicyData.filter((_uniqueKey, j) => index !== j);
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
return (
<Stack tokens={{ childrenGap: 4 }}>
{vectorEmbeddingPolicyData.length > 0 &&
vectorEmbeddingPolicyData.map((vectorEmbeddingPolicy: VectorEmbeddingPolicyData, index: number) => (
<CollapsibleSectionComponent key={index} isExpandedByDefault={true} title={`Vector embedding ${index + 1}`}>
<Stack horizontal tokens={{ childrenGap: 4 }}>
<Stack
styles={{
root: {
margin: "0 0 6px 20px !important",
paddingLeft: 20,
width: "80%",
borderLeft: "1px solid",
},
}}
>
<Stack>
<Label styles={{ root: { fontSize: 12 } }}>Path</Label>
<TextField
id={`vector-policy-path-${index + 1}`}
required={true}
placeholder="/vector1"
styles={textFieldStyles}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onVectorEmbeddingPathChange(index, event)}
value={vectorEmbeddingPolicy.path || ""}
errorMessage={vectorEmbeddingPolicy.pathError}
/>
</Stack>
<Stack>
<Label styles={{ root: { fontSize: 12 } }}>Data type</Label>
<Dropdown
required={true}
styles={dropdownStyles}
options={getDataTypeOptions()}
selectedKey={vectorEmbeddingPolicy.dataType}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
onVectorEmbeddingPolicyChange(index, option, "dataType")
}
></Dropdown>
</Stack>
<Stack>
<Label styles={{ root: { fontSize: 12 } }}>Distance function</Label>
<Dropdown
required={true}
styles={dropdownStyles}
options={getDistanceFunctionOptions()}
selectedKey={vectorEmbeddingPolicy.distanceFunction}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
onVectorEmbeddingPolicyChange(index, option, "distanceFunction")
}
></Dropdown>
</Stack>
<Stack>
<Label styles={{ root: { fontSize: 12 } }}>Dimensions</Label>
<TextField
id={`vector-policy-dimension-${index + 1}`}
required={true}
styles={textFieldStyles}
value={String(vectorEmbeddingPolicy.dimensions || 0)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onVectorEmbeddingDimensionsChange(index, event)
}
errorMessage={vectorEmbeddingPolicy.dimensionsError}
/>
</Stack>
<Stack>
<Label styles={{ root: { fontSize: 12 } }}>Index type</Label>
<Dropdown
required={true}
styles={dropdownStyles}
options={getIndexTypeOptions()}
selectedKey={vectorEmbeddingPolicy.indexType}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
onVectorEmbeddingIndexTypeChange(index, option)
}
></Dropdown>
</Stack>
</Stack>
<IconButton
id={`delete-vector-policy-${index + 1}`}
iconProps={{ iconName: "Delete" }}
style={{ height: 27, margin: "auto" }}
onClick={() => onDelete(index)}
/>
</Stack>
</CollapsibleSectionComponent>
))}
<DefaultButton id={`add-vector-policy`} styles={{ root: { maxWidth: 170, fontSize: 12 } }} onClick={onAdd}>
Add vector embedding
</DefaultButton>
</Stack>
);
};

View File

@ -0,0 +1,16 @@
import { IDropdownOption } from "@fluentui/react";
const dataTypes = ["float32", "uint8", "int8"];
const distanceFunctions = ["euclidean", "cosine", "dotproduct"];
const indexTypes = ["none", "flat", "diskANN", "quantizedFlat"];
export const getDataTypeOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(dataTypes);
export const getDistanceFunctionOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(distanceFunctions);
export const getIndexTypeOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(indexTypes);
function createDropdownOptionsFromLiterals<T extends string>(literals: T[]): IDropdownOption[] {
return literals.map((value) => ({
key: value,
text: value,
}));
}