Browse Source

cp vector search to plugin

czhen 3 years ago
parent
commit
a23a650e1d

+ 7 - 7
client/src/components/layout/Layout.tsx

@@ -82,13 +82,13 @@ const Layout = (props: any) => {
       label: navTrans('collection'),
       onClick: () => history.push('/collections'),
     },
-    {
-      icon: icons.navSearch,
-      label: navTrans('search'),
-      onClick: () => history.push('/search'),
-      iconActiveClass: 'activeSearchIcon',
-      iconNormalClass: 'normalSearchIcon',
-    },
+    // {
+    //   icon: icons.navSearch,
+    //   label: navTrans('search'),
+    //   onClick: () => history.push('/search'),
+    //   iconActiveClass: 'activeSearchIcon',
+    //   iconNormalClass: 'normalSearchIcon',
+    // },
   ];
 
   function importAll(r: any, outOfRoot = false) {

+ 1 - 1
client/src/http/Collection.ts

@@ -1,7 +1,7 @@
 import { ChildrenStatusType } from '../components/status/Types';
 import { CollectionView, InsertDataParam } from '../pages/collections/Types';
 import { Field } from '../pages/schema/Types';
-import { VectorSearchParam } from '../pages/seach/Types';
+import { VectorSearchParam } from '../pages/search/Types';
 import { QueryParam } from '../pages/query/Types';
 import { IndexState, ShowCollectionsType } from '../types/Milvus';
 import { formatNumber } from '../utils/Common';

+ 1 - 0
client/src/plugins/seach/Constants.ts

@@ -0,0 +1 @@
+export const TOP_K_OPTIONS = [50, 100, 150, 200, 250];

+ 289 - 0
client/src/plugins/seach/SearchParams.tsx

@@ -0,0 +1,289 @@
+import { makeStyles, Theme } from '@material-ui/core';
+import { FC, useCallback, useEffect, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import CustomInput from '../../components/customInput/CustomInput';
+import { ITextfieldConfig } from '../../components/customInput/Types';
+import CustomSelector from '../../components/customSelector/CustomSelector';
+import { Option } from '../../components/customSelector/Types';
+import {
+  DEFAULT_NLIST_VALUE,
+  DEFAULT_SEARCH_PARAM_VALUE_MAP,
+  INDEX_CONFIG,
+  METRIC_OPTIONS_MAP,
+  searchKeywordsType,
+} from '../../consts/Milvus';
+import { useFormValidation } from '../../hooks/Form';
+import { formatForm } from '../../utils/Form';
+import { SearchParamInputConfig, SearchParamsProps } from './Types';
+
+const getStyles = makeStyles((theme: Theme) => ({
+  selector: {
+    width: '100%',
+    marginTop: theme.spacing(2),
+  },
+  input: {
+    marginTop: theme.spacing(2),
+  },
+  inlineInput: {
+    marginTop: theme.spacing(2),
+    width: '48%',
+  },
+  inlineInputWrapper: {
+    display: 'flex',
+    justifyContent: 'space-between',
+  },
+}));
+
+const SearchParams: FC<SearchParamsProps> = ({
+  indexType,
+  indexParams,
+  searchParamsForm,
+  handleFormChange,
+  embeddingType,
+  metricType,
+  topK,
+  setParamsDisabled,
+  wrapperClass = '',
+}) => {
+  const { t: indexTrans } = useTranslation('index');
+  const { t: warningTrans } = useTranslation('warning');
+  const classes = getStyles();
+
+  const metricOptions: Option[] = METRIC_OPTIONS_MAP[embeddingType];
+
+  // search params key list, depends on index type
+  // e.g. ['nprobe']
+  const searchParams = useMemo(
+    (): searchKeywordsType[] =>
+      indexType !== ''
+        ? [...INDEX_CONFIG[indexType].search, 'round_decimal']
+        : ['round_decimal'],
+    [indexType]
+  );
+
+  const handleInputChange = useCallback(
+    (key: string, value: number) => {
+      const form = { ...searchParamsForm, [key]: value };
+      handleFormChange(form);
+    },
+    [handleFormChange, searchParamsForm]
+  );
+
+  /**
+   * function to transfer search params to CustomInput need config type
+   */
+  const getNumberInputConfig = useCallback(
+    (params: SearchParamInputConfig): ITextfieldConfig => {
+      const {
+        label,
+        key,
+        min,
+        max,
+        value,
+        handleChange,
+        isInt = true,
+      } = params;
+
+      // search_k range is special compared to others,need to be handled separately
+      // range: {-1} ∪ [top_k, n × n_trees]
+      const isSearchK = label === 'search_k';
+
+      const config: ITextfieldConfig = {
+        label,
+        key,
+        onChange: value => {
+          handleChange(value);
+        },
+        className: classes.inlineInput,
+        variant: 'filled',
+        type: 'number',
+        value,
+        validations: [
+          {
+            rule: 'require',
+            errorText: warningTrans('required', { name: label }),
+          },
+        ],
+      };
+      if (!isSearchK && min && max) {
+        config.validations?.push({
+          rule: 'range',
+          errorText: warningTrans('range', { min, max }),
+          extraParam: {
+            min,
+            max,
+            type: 'number',
+          },
+        });
+      }
+
+      if (isInt) {
+        config.validations?.push({
+          rule: 'integer',
+          errorText: warningTrans('integer', { name: label }),
+        });
+      }
+
+      // search_k
+      if (isSearchK) {
+        config.validations?.push({
+          rule: 'specValueOrRange',
+          errorText: warningTrans('specValueOrRange', {
+            name: label,
+            min,
+            max,
+            specValue: -1,
+          }),
+          extraParam: {
+            min,
+            max,
+            compareValue: -1,
+            type: 'number',
+          },
+        });
+      }
+      return config;
+    },
+    [classes.inlineInput, warningTrans]
+  );
+
+  const getSearchInputConfig = useCallback(
+    (paramKey: searchKeywordsType): ITextfieldConfig => {
+      const nlist = Number(
+        // nlist range is [1, 65536], if user didn't create index, we set 1024 as default nlist value
+        indexParams.find(p => p.key === 'nlist')?.value || DEFAULT_NLIST_VALUE
+      );
+
+      const configParamMap: {
+        [key in searchKeywordsType]: SearchParamInputConfig;
+      } = {
+        round_decimal: {
+          label: 'Round Decimals',
+          key: 'round_decimal',
+          value: searchParamsForm['round_decimal'] || '',
+          min: -1,
+          max: 10,
+          isInt: true,
+          handleChange: value => {
+            handleInputChange('round_decimal', value);
+          },
+          className: classes.inlineInput,
+        },
+        nprobe: {
+          label: 'nprobe',
+          key: 'nprobe',
+          value: searchParamsForm['nprobe'] || '',
+          min: 1,
+          max: nlist,
+          isInt: true,
+          handleChange: value => {
+            handleInputChange('nprobe', value);
+          },
+          className: classes.inlineInput,
+        },
+
+        ef: {
+          label: 'ef',
+          key: 'ef',
+          value: searchParamsForm['ef'] || '',
+          min: topK,
+          max: 32768,
+          isInt: true,
+          handleChange: value => {
+            handleInputChange('ef', value);
+          },
+        },
+        search_k: {
+          label: 'search_k',
+          key: 'search_k',
+          value: searchParamsForm['search_k'] || '',
+          min: topK,
+          // n * n_trees can be infinity
+          max: Infinity,
+          isInt: true,
+          handleChange: value => {
+            handleInputChange('search_k', value);
+          },
+        },
+        search_length: {
+          label: 'search_length',
+          key: 'search_length',
+          value: searchParamsForm['search_length'] || '',
+          min: 10,
+          max: 300,
+          isInt: true,
+          handleChange: value => {
+            handleInputChange('search_length', value);
+          },
+        },
+      };
+
+      const param = configParamMap[paramKey];
+      return getNumberInputConfig(param);
+    },
+    [
+      indexParams,
+      searchParamsForm,
+      classes.inlineInput,
+      topK,
+      getNumberInputConfig,
+      handleInputChange,
+    ]
+  );
+
+  useEffect(() => {
+    // generate different form according to search params
+    const form = searchParams.reduce(
+      (paramsForm, param) => ({
+        ...paramsForm,
+        [param]: DEFAULT_SEARCH_PARAM_VALUE_MAP[param],
+      }),
+      {}
+    );
+    handleFormChange(form);
+  }, [searchParams, handleFormChange]);
+
+  const checkedForm = useMemo(() => {
+    const { ...needCheckItems } = searchParamsForm;
+    return formatForm(needCheckItems);
+  }, [searchParamsForm]);
+
+  const { validation, checkIsValid, disabled } = useFormValidation(checkedForm);
+
+  useEffect(() => {
+    setParamsDisabled(disabled);
+  }, [disabled, setParamsDisabled]);
+
+  return (
+    <div className={wrapperClass}>
+      {/* metric type */}
+      <CustomSelector
+        options={metricOptions}
+        value={metricType}
+        label={indexTrans('metric')}
+        wrapperClass={classes.selector}
+        variant="filled"
+        onChange={(e: { target: { value: unknown } }) => {
+          // not selectable now, so not set onChange event
+        }}
+        // not selectable now
+        // readOnly can't avoid all events, so we use disabled instead
+        disabled={true}
+      />
+      <div className={classes.inlineInputWrapper}>
+        {/* dynamic params, now every type only has one param except metric type */}
+        {searchParams.map(param => (
+          <CustomInput
+            key={param}
+            type="text"
+            textConfig={getSearchInputConfig(param)}
+            checkValid={checkIsValid}
+            validInfo={validation}
+          />
+        ))}
+      </div>
+    </div>
+  );
+};
+
+export default SearchParams;

+ 129 - 0
client/src/plugins/seach/Styles.ts

@@ -0,0 +1,129 @@
+import { makeStyles, Theme } from '@material-ui/core';
+
+export const getVectorSearchStyles = makeStyles((theme: Theme) => ({
+  form: {
+    display: 'flex',
+    justifyContent: 'space-between',
+
+    '& .field': {
+      display: 'flex',
+      flexDirection: 'column',
+      flexBasis: '33%',
+
+      padding: theme.spacing(2, 3, 4),
+      backgroundColor: '#fff',
+      borderRadius: theme.spacing(0.5),
+      boxShadow: '3px 3px 10px rgba(0, 0, 0, 0.05)',
+
+      '& .textarea': {
+        border: `1px solid ${theme.palette.milvusGrey.main}`,
+        borderRadius: theme.spacing(0.5),
+        padding: theme.spacing(1),
+        paddingBottom: '18px',
+        marginTop: theme.spacing(2),
+      },
+
+      // reset default style
+      '& .textfield': {
+        padding: 0,
+        fontSize: '14px',
+        lineHeight: '20px',
+        fontWeight: 400,
+
+        '&::before': {
+          borderBottom: 'none',
+        },
+
+        '&::after': {
+          borderBottom: 'none',
+        },
+      },
+
+      '& .multiline': {
+        '& textarea': {
+          overflow: 'auto',
+          // change scrollbar style
+          '&::-webkit-scrollbar': {
+            width: '8px',
+          },
+
+          '&::-webkit-scrollbar-track': {
+            backgroundColor: '#f9f9f9',
+          },
+
+          '&::-webkit-scrollbar-thumb': {
+            borderRadius: '8px',
+            backgroundColor: '#eee',
+          },
+        },
+      },
+    },
+
+    '& .field-second': {
+      flexGrow: 1,
+      margin: theme.spacing(0, 1),
+    },
+
+    // Textfield component has more bottom space to show error msg when validation
+    // if still set padding-bottom, the whole form space will be stretched
+    '& .field-params': {
+      paddingBottom: 0,
+    },
+
+    '& .text': {
+      color: theme.palette.milvusGrey.dark,
+      fontWeight: 500,
+    },
+  },
+  selector: {
+    width: '100%',
+    marginTop: theme.spacing(2),
+  },
+  paramsWrapper: {
+    display: 'flex',
+    flexDirection: 'column',
+  },
+  toolbar: {
+    display: 'flex',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+
+    padding: theme.spacing(2, 0),
+
+    '& .left': {
+      display: 'flex',
+      alignItems: 'center',
+
+      '& .text': {
+        color: theme.palette.milvusGrey.main,
+      },
+    },
+    '& .right': {
+      '& .btn': {
+        marginRight: theme.spacing(1),
+      },
+      '& .icon': {
+        fontSize: '16px',
+      },
+    },
+  },
+  menuLabel: {
+    minWidth: '108px',
+
+    padding: theme.spacing(0, 1),
+    margin: theme.spacing(0, 1),
+
+    backgroundColor: '#fff',
+    color: theme.palette.milvusGrey.dark,
+  },
+  menuItem: {
+    fontWeight: 500,
+    fontSize: '12px',
+    lineHeight: '16px',
+    color: theme.palette.milvusGrey.dark,
+  },
+  error: {
+    marginTop: theme.spacing(1),
+    color: theme.palette.error.main,
+  },
+}));

+ 70 - 0
client/src/plugins/seach/Types.ts

@@ -0,0 +1,70 @@
+import { Option } from '../../components/customSelector/Types';
+import { searchKeywordsType } from '../../consts/Milvus';
+import { DataType, DataTypeEnum } from 'insight_src/pages/collections/Types';
+import { IndexView } from 'insight_src/pages/schema/Types';
+
+export interface SearchParamsProps {
+  // if user created index, pass metric type choosed when creating
+  // else pass empty string
+  metricType: string;
+  // used for getting metric type options
+  embeddingType: DataTypeEnum.FloatVector | DataTypeEnum.BinaryVector;
+  // default index type is FLAT
+  indexType: string;
+  // index extra params, e.g. nlist
+  indexParams: { key: string; value: string }[];
+  searchParamsForm: {
+    [key in string]: number;
+  };
+  topK: number;
+  handleFormChange: (form: { [key in string]: number }) => void;
+  wrapperClass?: string;
+  setParamsDisabled: (isDisabled: boolean) => void;
+}
+
+export interface SearchResultView {
+  // dynamic field names
+  [key: string]: string | number;
+  rank: number;
+  distance: number;
+}
+
+export interface FieldOption extends Option {
+  fieldType: DataType;
+  // used to get metric type, index type and index params for search params
+  // if user doesn't create index, default value is null
+  indexInfo: IndexView | null;
+  // used for check vector input validation
+  dimension: number;
+}
+
+export interface SearchParamInputConfig {
+  label: string;
+  key: searchKeywordsType;
+  min: number;
+  max: number;
+  isInt?: boolean;
+  // no value: empty string
+  value: number | string;
+  handleChange: (value: number) => void;
+  className?: string;
+}
+
+export interface VectorSearchParam {
+  expr?: string;
+  search_params: {
+    anns_field: string; // your vector field name
+    topk: string | number;
+    metric_type: string;
+    params: string;
+  };
+  vectors: any;
+  output_fields: string[];
+  vector_type: number | DataTypeEnum;
+}
+
+export interface SearchResult {
+  // dynamic field names
+  [key: string]: string | number;
+  score: number;
+}

+ 509 - 0
client/src/plugins/seach/VectorSearch.tsx

@@ -0,0 +1,509 @@
+import { TextField, Typography } from '@material-ui/core';
+import { useTranslation } from 'react-i18next';
+import { useNavigationHook } from '../../hooks/Navigation';
+import { ALL_ROUTER_TYPES } from '../../router/Types';
+import CustomSelector from '../../components/customSelector/CustomSelector';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import SearchParams from './SearchParams';
+import { DEFAULT_METRIC_VALUE_MAP } from '../../consts/Milvus';
+import { FieldOption, SearchResultView, VectorSearchParam } from './Types';
+import MilvusGrid from '../../components/grid/Grid';
+import EmptyCard from '../../components/cards/EmptyCard';
+import icons from '../../components/icons/Icons';
+import { usePaginationHook } from '../../hooks/Pagination';
+import CustomButton from '../../components/customButton/CustomButton';
+import SimpleMenu from '../../components/menu/SimpleMenu';
+import { TOP_K_OPTIONS } from './Constants';
+import { Option } from '../../components/customSelector/Types';
+import { CollectionHttp } from '../../http/Collection';
+import { CollectionData, DataTypeEnum } from 'insight_src/pages/collections/Types';
+import { IndexHttp } from '../../http/Index';
+import { getVectorSearchStyles } from './Styles';
+import { parseValue } from '../../utils/Insert';
+import {
+  classifyFields,
+  getDefaultIndexType,
+  getEmbeddingType,
+  getNonVectorFieldsForFilter,
+  getVectorFieldOptions,
+  transferSearchResult,
+} from '../../utils/search';
+import { ColDefinitionsType } from '../../components/grid/Types';
+import Filter from '../../components/advancedSearch';
+import { Field } from '../../components/advancedSearch/Types';
+import { useLocation } from 'react-router-dom';
+import { parseLocationSearch } from '../../utils/Format';
+
+const VectorSearch = () => {
+  useNavigationHook(ALL_ROUTER_TYPES.SEARCH);
+  const location = useLocation();
+
+  // i18n
+  const { t: searchTrans } = useTranslation('search');
+  const { t: btnTrans } = useTranslation('btn');
+  const classes = getVectorSearchStyles();
+
+  // data stored inside the component
+  const [tableLoading, setTableLoading] = useState<boolean>(false);
+  const [collections, setCollections] = useState<CollectionData[]>([]);
+  const [selectedCollection, setSelectedCollection] = useState<string>('');
+  const [fieldOptions, setFieldOptions] = useState<FieldOption[]>([]);
+  // fields for advanced filter
+  const [filterFields, setFilterFields] = useState<Field[]>([]);
+  const [selectedField, setSelectedField] = useState<string>('');
+  // search params form
+  const [searchParam, setSearchParam] = useState<{ [key in string]: number }>(
+    {}
+  );
+  // search params disable state
+  const [paramDisabled, setParamDisabled] = useState<boolean>(true);
+  // use null as init value before search, empty array means no results
+  const [searchResult, setSearchResult] = useState<SearchResultView[] | null>(
+    null
+  );
+  // default topK is 100
+  const [topK, setTopK] = useState<number>(100);
+  const [expression, setExpression] = useState<string>('');
+  const [vectors, setVectors] = useState<string>('');
+
+  const {
+    pageSize,
+    handlePageSize,
+    currentPage,
+    handleCurrentPage,
+    total,
+    data: result,
+    order,
+    orderBy,
+    handleGridSort,
+  } = usePaginationHook(searchResult || []);
+
+  const collectionOptions: Option[] = useMemo(
+    () =>
+      collections.map(c => ({
+        label: c._name,
+        value: c._name,
+      })),
+    [collections]
+  );
+
+  const outputFields: string[] = useMemo(() => {
+    const fields =
+      collections.find(c => c._name === selectedCollection)?._fields || [];
+    // vector field can't be output fields
+    const invalidTypes = ['BinaryVector', 'FloatVector'];
+    const nonVectorFields = fields.filter(
+      field => !invalidTypes.includes(field._fieldType)
+    );
+    return nonVectorFields.map(f => f._fieldName);
+  }, [selectedCollection, collections]);
+
+  const primaryKeyField = useMemo(() => {
+    const selectedCollectionInfo = collections.find(
+      c => c._name === selectedCollection
+    );
+    const fields = selectedCollectionInfo?._fields || [];
+    return fields.find(f => f._isPrimaryKey)?._fieldName;
+  }, [selectedCollection, collections]);
+
+  const colDefinitions: ColDefinitionsType[] = useMemo(() => {
+    /**
+     * id represents primary key, score represents distance
+     * since we transfer score to distance in the view, and original field which is primary key has already in the table
+     * we filter 'id' and 'score' to avoid redundant data
+     */
+    return searchResult && searchResult.length > 0
+      ? Object.keys(searchResult[0])
+          .filter(item => {
+            // if primary key field name is id, don't filter it
+            const invalidItems =
+              primaryKeyField === 'id' ? ['score'] : ['id', 'score'];
+            return !invalidItems.includes(item);
+          })
+          .map(key => ({
+            id: key,
+            align: 'left',
+            disablePadding: false,
+            label: key,
+          }))
+      : [];
+  }, [searchResult, primaryKeyField]);
+
+  const {
+    metricType,
+    indexType,
+    indexParams,
+    fieldType,
+    embeddingType,
+    selectedFieldDimension,
+  } = useMemo(() => {
+    if (selectedField !== '') {
+      // field options must contain selected field, so selectedFieldInfo will never undefined
+      const selectedFieldInfo = fieldOptions.find(
+        f => f.value === selectedField
+      );
+      const index = selectedFieldInfo?.indexInfo;
+      const embeddingType = getEmbeddingType(selectedFieldInfo!.fieldType);
+      const metric =
+        index?._metricType || DEFAULT_METRIC_VALUE_MAP[embeddingType];
+      const indexParams = index?._indexParameterPairs || [];
+      const dim = selectedFieldInfo?.dimension || 0;
+
+      return {
+        metricType: metric,
+        indexType: index?._indexType || getDefaultIndexType(embeddingType),
+        indexParams,
+        fieldType: DataTypeEnum[selectedFieldInfo?.fieldType!],
+        embeddingType,
+        selectedFieldDimension: dim,
+      };
+    }
+
+    return {
+      metricType: '',
+      indexType: '',
+      indexParams: [],
+      fieldType: 0,
+      embeddingType: DataTypeEnum.FloatVector,
+      selectedFieldDimension: 0,
+    };
+  }, [selectedField, fieldOptions]);
+
+  /**
+   * vector value validation
+   * @return whether is valid
+   */
+  const vectorValueValid = useMemo(() => {
+    // if user hasn't input value or not select field, don't trigger validation check
+    if (vectors === '' || selectedFieldDimension === 0) {
+      return true;
+    }
+    const value = parseValue(vectors);
+    const isArray = Array.isArray(value);
+    return isArray && value.length === selectedFieldDimension;
+  }, [vectors, selectedFieldDimension]);
+
+  const searchDisabled = useMemo(() => {
+    /**
+     * before search, user must:
+     * 1. enter vector value, it should be an array and length should be equal to selected field dimension
+     * 2. choose collection and field
+     * 3. set extra search params
+     */
+    const isInvalid =
+      vectors === '' ||
+      selectedCollection === '' ||
+      selectedField === '' ||
+      paramDisabled ||
+      !vectorValueValid;
+    return isInvalid;
+  }, [
+    paramDisabled,
+    selectedField,
+    selectedCollection,
+    vectors,
+    vectorValueValid,
+  ]);
+
+  // fetch data
+  const fetchCollections = useCallback(async () => {
+    const collections = await CollectionHttp.getCollections();
+    setCollections(collections);
+  }, []);
+
+  const fetchFieldsWithIndex = useCallback(
+    async (collectionName: string, collections: CollectionData[]) => {
+      const fields =
+        collections.find(c => c._name === collectionName)?._fields || [];
+      const indexes = await IndexHttp.getIndexInfo(collectionName);
+
+      const { vectorFields, nonVectorFields } = classifyFields(fields);
+
+      // only vector type fields can be select
+      const fieldOptions = getVectorFieldOptions(vectorFields, indexes);
+      setFieldOptions(fieldOptions);
+      if (fieldOptions.length > 0) {
+        // set first option value as default field value
+        const [{ value: defaultFieldValue }] = fieldOptions;
+        setSelectedField(defaultFieldValue as string);
+      }
+
+      // only non vector type fields can be advanced filter
+      const filterFields = getNonVectorFieldsForFilter(nonVectorFields);
+      setFilterFields(filterFields);
+    },
+    []
+  );
+
+  useEffect(() => {
+    fetchCollections();
+  }, [fetchCollections]);
+
+  // get field options with index when selected collection changed
+  useEffect(() => {
+    if (selectedCollection !== '') {
+      fetchFieldsWithIndex(selectedCollection, collections);
+    }
+  }, [selectedCollection, collections, fetchFieldsWithIndex]);
+
+  // set default collection value if is from overview page
+  useEffect(() => {
+    if (location.search && collections.length > 0) {
+      const { collectionName } = parseLocationSearch(location.search);
+      // collection name validation
+      const isNameValid = collections
+        .map(c => c._name)
+        .includes(collectionName);
+      isNameValid && setSelectedCollection(collectionName);
+    }
+  }, [location, collections]);
+
+  // icons
+  const VectorSearchIcon = icons.vectorSearch;
+  const ResetIcon = icons.refresh;
+  const ArrowIcon = icons.dropdown;
+
+  // methods
+  const handlePageChange = (e: any, page: number) => {
+    handleCurrentPage(page);
+  };
+  const handleReset = () => {
+    /**
+     * reset search includes:
+     * 1. reset vectors
+     * 2. reset selected collection and field
+     * 3. reset search params
+     * 4. reset advanced filter expression
+     * 5. clear search result
+     */
+    setVectors('');
+    setSelectedField('');
+    setSelectedCollection('');
+    setSearchResult(null);
+    setFilterFields([]);
+    setExpression('');
+  };
+  const handleSearch = async (topK: number, expr = expression) => {
+    const searhParamPairs = {
+      params: JSON.stringify(searchParam),
+      anns_field: selectedField,
+      topk: topK,
+      metric_type: metricType,
+      round_decimal: searchParam.round_decimal,
+    };
+
+    const params: VectorSearchParam = {
+      output_fields: outputFields,
+      expr,
+      search_params: searhParamPairs,
+      vectors: [parseValue(vectors)],
+      vector_type: fieldType,
+    };
+
+    setTableLoading(true);
+    try {
+      const res = await CollectionHttp.vectorSearchData(
+        selectedCollection,
+        params
+      );
+      setTableLoading(false);
+
+      const result = transferSearchResult(res.results);
+      setSearchResult(result);
+    } catch (err) {
+      setTableLoading(false);
+    }
+  };
+  const handleAdvancedFilterChange = (expression: string) => {
+    setExpression(expression);
+    if (!searchDisabled) {
+      handleSearch(topK, expression);
+    }
+  };
+
+  const handleVectorChange = (value: string) => {
+    setVectors(value);
+  };
+
+  return (
+    <section className="page-wrapper">
+      {/* form section */}
+      <form className={classes.form}>
+        {/**
+         * vector value textarea
+         * use field-params class because it also has error msg if invalid
+         */}
+        <fieldset className="field field-params">
+          <Typography className="text">
+            {searchTrans('firstTip', {
+              dimensionTip:
+                selectedFieldDimension !== 0
+                  ? `(dimension: ${selectedFieldDimension})`
+                  : '',
+            })}
+          </Typography>
+          <TextField
+            className="textarea"
+            InputProps={{
+              classes: {
+                root: 'textfield',
+                multiline: 'multiline',
+              },
+            }}
+            multiline
+            rows={5}
+            placeholder={searchTrans('vectorPlaceholder')}
+            value={vectors}
+            onChange={(e: React.ChangeEvent<{ value: unknown }>) => {
+              handleVectorChange(e.target.value as string);
+            }}
+          />
+          {/* validation */}
+          {!vectorValueValid && (
+            <Typography variant="caption" className={classes.error}>
+              {searchTrans('vectorValueWarning', {
+                dimension: selectedFieldDimension,
+              })}
+            </Typography>
+          )}
+        </fieldset>
+        {/* collection and field selectors */}
+        <fieldset className="field field-second">
+          <Typography className="text">{searchTrans('secondTip')}</Typography>
+          <CustomSelector
+            options={collectionOptions}
+            wrapperClass={classes.selector}
+            variant="filled"
+            label={searchTrans(
+              collectionOptions.length === 0 ? 'noCollection' : 'collection'
+            )}
+            disabled={collectionOptions.length === 0}
+            value={selectedCollection}
+            onChange={(e: { target: { value: unknown } }) => {
+              const collection = e.target.value;
+
+              setSelectedCollection(collection as string);
+              // every time selected collection changed, reset field
+              setSelectedField('');
+            }}
+          />
+          <CustomSelector
+            options={fieldOptions}
+            // readOnly can't avoid all events, so we use disabled instead
+            disabled={selectedCollection === ''}
+            wrapperClass={classes.selector}
+            variant="filled"
+            label={searchTrans('field')}
+            value={selectedField}
+            onChange={(e: { target: { value: unknown } }) => {
+              const field = e.target.value;
+              setSelectedField(field as string);
+            }}
+          />
+        </fieldset>
+        {/* search params selectors */}
+        <fieldset className="field field-params">
+          <Typography className="text">{searchTrans('thirdTip')}</Typography>
+          <SearchParams
+            wrapperClass={classes.paramsWrapper}
+            metricType={metricType!}
+            embeddingType={
+              embeddingType as
+                | DataTypeEnum.BinaryVector
+                | DataTypeEnum.FloatVector
+            }
+            indexType={indexType}
+            indexParams={indexParams!}
+            searchParamsForm={searchParam}
+            handleFormChange={setSearchParam}
+            topK={topK}
+            setParamsDisabled={setParamDisabled}
+          />
+        </fieldset>
+      </form>
+
+      {/**
+       * search toolbar section
+       * including topK selector, advanced filter, search and reset btn
+       */}
+      <section className={classes.toolbar}>
+        <div className="left">
+          <Typography variant="h5" className="text">
+            {`${searchTrans('result')}: `}
+          </Typography>
+          {/* topK selector */}
+          <SimpleMenu
+            label={searchTrans('topK', { number: topK })}
+            menuItems={TOP_K_OPTIONS.map(item => ({
+              label: item.toString(),
+              callback: () => {
+                setTopK(item);
+                if (!searchDisabled) {
+                  handleSearch(item);
+                }
+              },
+              wrapperClass: classes.menuItem,
+            }))}
+            buttonProps={{
+              className: classes.menuLabel,
+              endIcon: <ArrowIcon />,
+            }}
+            menuItemWidth="108px"
+          />
+
+          <Filter
+            title="Advanced Filter"
+            fields={filterFields}
+            filterDisabled={selectedField === '' || selectedCollection === ''}
+            onSubmit={handleAdvancedFilterChange}
+          />
+        </div>
+        <div className="right">
+          <CustomButton className="btn" onClick={handleReset}>
+            <ResetIcon classes={{ root: 'icon' }} />
+            {btnTrans('reset')}
+          </CustomButton>
+          <CustomButton
+            variant="contained"
+            disabled={searchDisabled}
+            onClick={() => handleSearch(topK)}
+          >
+            {btnTrans('search')}
+          </CustomButton>
+        </div>
+      </section>
+
+      {/* search result table section */}
+      {(searchResult && searchResult.length > 0) || tableLoading ? (
+        <MilvusGrid
+          toolbarConfigs={[]}
+          colDefinitions={colDefinitions}
+          rows={result}
+          rowCount={total}
+          primaryKey="rank"
+          page={currentPage}
+          onChangePage={handlePageChange}
+          rowsPerPage={pageSize}
+          setRowsPerPage={handlePageSize}
+          openCheckBox={false}
+          isLoading={tableLoading}
+          orderBy={orderBy}
+          order={order}
+          handleSort={handleGridSort}
+        />
+      ) : (
+        <EmptyCard
+          wrapperClass={`page-empty-card`}
+          icon={<VectorSearchIcon />}
+          text={
+            searchResult !== null
+              ? searchTrans('empty')
+              : searchTrans('startTip')
+          }
+        />
+      )}
+    </section>
+  );
+};
+
+export default VectorSearch;

+ 13 - 0
client/src/plugins/seach/config.json

@@ -0,0 +1,13 @@
+{
+  "name": "search",
+  "version": "0.1.0",
+  "client": {
+    "path": "search",
+    "entry": "VectorSearch.tsx",
+    "label": "Vector Search",
+    "iconName": "navSearch",
+    "auth": true,
+    "iconActiveClass": "activeSearchIcon",
+    "iconNormalClass": "normalSearchIcon"
+  }
+}

+ 6 - 6
client/src/router/Config.ts

@@ -2,7 +2,7 @@ import Collection from '../pages/collections/Collection';
 import Collections from '../pages/collections/Collections';
 import Connect from '../pages/connect/Connect';
 import Overview from '../pages/overview/Overview';
-import VectorSearch from '../pages/seach/VectorSearch';
+// import VectorSearch from '../pages/seach/VectorSearch';
 import { RouterConfigType } from './Types';
 import loadable from '@loadable/component';
 
@@ -29,11 +29,11 @@ const RouterConfig: RouterConfigType[] = [
     component: Collection,
     auth: true,
   },
-  {
-    path: '/search',
-    component: VectorSearch,
-    auth: true,
-  },
+  // {
+  //   path: '/search',
+  //   component: VectorSearch,
+  //   auth: true,
+  // },
 ];
 
 function importAll(r: any, outOfRoot = false) {

+ 1 - 1
client/src/utils/search.ts

@@ -10,7 +10,7 @@ import {
   FieldOption,
   SearchResult,
   SearchResultView,
-} from '../pages/seach/Types';
+} from '../pages/search/Types';
 
 /**
  * Do not change  vector search result default sort  by ourself.

+ 2 - 1
client/tsconfig.paths.json

@@ -2,7 +2,8 @@
   "compilerOptions": {
     "baseUrl": ".",
     "paths": {
-      "all_plugins/*": ["src/plugins"]
+      "all_plugins/*": ["src/plugins"],
+      "insight_src/*": ["src/*"]
     }
   }
 }