Browse Source

Merge pull request #151 from Tumao727/feature/vector-search

add vector seach
nameczz 4 years ago
parent
commit
c37b40d878
37 changed files with 1264 additions and 64 deletions
  1. 3 0
      client/src/assets/icons/nav-search.svg
  2. 5 0
      client/src/assets/icons/search.svg
  3. 19 8
      client/src/components/advancedSearch/Filter.tsx
  4. 1 0
      client/src/components/advancedSearch/Types.ts
  5. 12 0
      client/src/components/icons/Icons.tsx
  6. 4 0
      client/src/components/icons/Types.ts
  7. 0 3
      client/src/components/insert/Preview.tsx
  8. 30 7
      client/src/components/layout/Layout.tsx
  9. 9 3
      client/src/components/menu/SimpleMenu.tsx
  10. 2 0
      client/src/components/menu/Types.ts
  11. 74 12
      client/src/consts/Milvus.tsx
  12. 6 7
      client/src/hooks/Navigation.ts
  13. 6 0
      client/src/http/BaseModel.ts
  14. 8 0
      client/src/http/Collection.ts
  15. 4 0
      client/src/http/Index.ts
  16. 1 0
      client/src/i18n/cn/nav.ts
  17. 15 0
      client/src/i18n/cn/search.ts
  18. 2 0
      client/src/i18n/cn/warning.ts
  19. 1 0
      client/src/i18n/en/nav.ts
  20. 15 0
      client/src/i18n/en/search.ts
  21. 2 0
      client/src/i18n/en/warning.ts
  22. 4 0
      client/src/i18n/index.ts
  23. 6 1
      client/src/pages/connect/Connect.tsx
  24. 10 7
      client/src/pages/schema/Create.tsx
  25. 0 1
      client/src/pages/schema/Schema.tsx
  26. 2 0
      client/src/pages/schema/Types.ts
  27. 1 0
      client/src/pages/seach/Constants.ts
  28. 259 0
      client/src/pages/seach/SearchParams.tsx
  29. 119 0
      client/src/pages/seach/Styles.ts
  30. 62 0
      client/src/pages/seach/Types.ts
  31. 422 0
      client/src/pages/seach/VectorSearch.tsx
  32. 6 0
      client/src/router/Config.ts
  33. 2 2
      client/src/router/Types.ts
  34. 18 7
      client/src/utils/Format.ts
  35. 1 1
      client/src/utils/Insert.ts
  36. 29 5
      client/src/utils/Validation.ts
  37. 104 0
      client/src/utils/search.ts

+ 3 - 0
client/src/assets/icons/nav-search.svg

@@ -0,0 +1,3 @@
+<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M11.165 0.902214C11.5044 1.04952 11.706 1.40291 11.6601 1.76999L10.9438 7.49997H17.4998C17.8232 7.49997 18.1174 7.68702 18.2545 7.97984C18.3917 8.27266 18.347 8.61838 18.14 8.86679L9.8067 18.8668C9.56987 19.151 9.17403 19.245 8.83469 19.0977C8.49534 18.9504 8.29373 18.597 8.33962 18.2299L9.05586 12.5H2.49985C2.1765 12.5 1.88234 12.3129 1.74519 12.0201C1.60804 11.7273 1.65266 11.3815 1.85966 11.1331L10.193 1.13314C10.4298 0.84895 10.8257 0.754907 11.165 0.902214ZM4.27905 10.8333H9.99985C10.2389 10.8333 10.4664 10.9359 10.6246 11.1151C10.7828 11.2943 10.8564 11.5328 10.8267 11.77L10.346 15.6163L15.7206 9.16663H9.99985C9.76082 9.16663 9.5333 9.06399 9.37512 8.8848C9.21693 8.70561 9.1433 8.46712 9.17295 8.22994L9.65373 4.38368L4.27905 10.8333Z" fill="#06AFF2"/>
+</svg>

+ 5 - 0
client/src/assets/icons/search.svg

@@ -0,0 +1,5 @@
+<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M29.29 17.7886C30.0117 16.9137 31.306 16.7894 32.181 17.511C33.0559 18.2326 33.1802 19.5269 32.4586 20.4019L22.9354 31.9485C22.2138 32.8234 20.9195 32.9477 20.0445 32.2261C19.1696 31.5044 19.0453 30.2101 19.7669 29.3352L29.29 17.7886Z" fill="#AEAEBB"/>
+<path d="M22 19.1014L15.3798 17.0207L22 14.8986V11L12 14.7785V19.2215L22 23V19.1014Z" fill="#AEAEBB"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M21 1C9.9543 1 1 9.9543 1 21C1 32.0457 9.9543 41 21 41C25.5938 41 29.8258 39.4512 33.2025 36.8474C33.3392 37.1868 33.545 37.5048 33.8201 37.7799L42.2201 46.1799C43.3136 47.2734 45.0864 47.2734 46.1799 46.1799C47.2734 45.0864 47.2734 43.3136 46.1799 42.2201L37.7799 33.8201C37.5048 33.545 37.1868 33.3392 36.8474 33.2025C39.4512 29.8258 41 25.5938 41 21C41 9.9543 32.0457 1 21 1ZM6.71429 21C6.71429 13.1102 13.1102 6.71429 21 6.71429C28.8898 6.71429 35.2857 13.1102 35.2857 21C35.2857 28.8898 28.8898 35.2857 21 35.2857C13.1102 35.2857 6.71429 28.8898 6.71429 21Z" fill="#AEAEBB"/>
+</svg>

+ 19 - 8
client/src/components/advancedSearch/Filter.tsx

@@ -1,9 +1,8 @@
-import React, { useState, useEffect } from 'react';
+import { useState, useEffect } from 'react';
 import {
   makeStyles,
   Theme,
   createStyles,
-  Button,
   Chip,
   Tooltip,
 } from '@material-ui/core';
@@ -11,12 +10,14 @@ import FilterListIcon from '@material-ui/icons/FilterList';
 import AdvancedDialog from './Dialog';
 import { FilterProps, ConditionData } from './Types';
 import { generateIdByHash } from '../../utils/Common';
+import CustomButton from '../customButton/CustomButton';
 
 const Filter = function Filter(props: FilterProps) {
   const {
     title = 'title',
     showTitle = true,
     className = '',
+    filterDisabled = false,
     tooltipPlacement = 'top',
     onSubmit,
     fields = [],
@@ -25,16 +26,22 @@ const Filter = function Filter(props: FilterProps) {
   const classes = useStyles();
 
   const [open, setOpen] = useState(false);
-  const [conditionSum, setConditionSum] = useState(0);
   const [flatConditions, setFlatConditions] = useState<any[]>([]);
   const [initConditions, setInitConditions] = useState<any[]>([]);
   const [isConditionsLegal, setIsConditionsLegal] = useState(false);
   const [filterExpression, setFilterExpression] = useState('');
 
+  // if fields if empty array, reset all conditions
+  useEffect(() => {
+    if (fields.length === 0) {
+      setFlatConditions([]);
+      setInitConditions([]);
+    }
+  }, [fields]);
+
   // Check all conditions are all correct.
   useEffect(() => {
     // Calc the sum of conditions.
-    setConditionSum(flatConditions.filter(i => i.type === 'condition').length);
     for (let i = 0; i < flatConditions.length; i++) {
       const { data, type } = flatConditions[i];
       if (type !== 'condition') continue;
@@ -256,11 +263,15 @@ const Filter = function Filter(props: FilterProps) {
   return (
     <>
       <div className={`${classes.wrapper} ${className}`} {...others}>
-        <Button className={`${classes.afBtn} af-btn`} onClick={handleClickOpen}>
+        <CustomButton
+          disabled={filterDisabled}
+          className={`${classes.afBtn} af-btn`}
+          onClick={handleClickOpen}
+        >
           <FilterListIcon />
           {showTitle ? title : ''}
-        </Button>
-        {conditionSum > 0 && (
+        </CustomButton>
+        {initConditions.length > 0 && (
           <Tooltip
             arrow
             interactive
@@ -268,7 +279,7 @@ const Filter = function Filter(props: FilterProps) {
             placement={tooltipPlacement}
           >
             <Chip
-              label={conditionSum}
+              label={initConditions.filter(i => i.type === 'condition').length}
               onDelete={handleDeleteAll}
               variant="outlined"
               size="small"

+ 1 - 0
client/src/components/advancedSearch/Types.ts

@@ -68,6 +68,7 @@ export interface FilterProps {
   className?: string;
   title: string;
   showTitle?: boolean;
+  filterDisabled?: boolean;
   others?: object;
   onSubmit: (data: any) => void;
   tooltipPlacement?: 'left' | 'right' | 'bottom' | 'top';

+ 12 - 0
client/src/components/icons/Icons.tsx

@@ -19,6 +19,8 @@ import ExitToAppIcon from '@material-ui/icons/ExitToApp';
 import ArrowForwardIosIcon from '@material-ui/icons/ArrowForwardIos';
 import RemoveCircleOutlineIcon from '@material-ui/icons/RemoveCircleOutline';
 import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';
+import CachedIcon from '@material-ui/icons/Cached';
+import FilterListIcon from '@material-ui/icons/FilterList';
 import { SvgIcon } from '@material-ui/core';
 import { ReactComponent as MilvusIcon } from '../../assets/icons/milvus.svg';
 import { ReactComponent as OverviewIcon } from '../../assets/icons/overview.svg';
@@ -29,6 +31,8 @@ import { ReactComponent as ReleaseIcon } from '../../assets/icons/release.svg';
 import { ReactComponent as LoadIcon } from '../../assets/icons/load.svg';
 import { ReactComponent as KeyIcon } from '../../assets/icons/key.svg';
 import { ReactComponent as UploadIcon } from '../../assets/icons/upload.svg';
+import { ReactComponent as VectorSearchIcon } from '../../assets/icons/nav-search.svg';
+import { ReactComponent as SearchEmptyIcon } from '../../assets/icons/search.svg';
 import { ReactComponent as CopyIcon } from '../../assets/icons/copy.svg';
 
 const icons: { [x in IconsType]: (props?: any) => React.ReactElement } = {
@@ -51,6 +55,8 @@ const icons: { [x in IconsType]: (props?: any) => React.ReactElement } = {
   rightArrow: (props = {}) => <ArrowForwardIosIcon {...props} />,
   remove: (props = {}) => <RemoveCircleOutlineIcon {...props} />,
   dropdown: (props = {}) => <ArrowDropDownIcon {...props} />,
+  refresh: (props = {}) => <CachedIcon {...props} />,
+  filter: (props = {}) => <FilterListIcon {...props} />,
 
   milvus: (props = {}) => (
     <SvgIcon viewBox="0 0 44 31" component={MilvusIcon} {...props} />
@@ -64,6 +70,9 @@ const icons: { [x in IconsType]: (props?: any) => React.ReactElement } = {
   navConsole: (props = {}) => (
     <SvgIcon viewBox="0 0 20 20" component={ConsoleIcon} {...props} />
   ),
+  navSearch: (props = {}) => (
+    <SvgIcon viewBox="0 0 20 20" component={VectorSearchIcon} {...props} />
+  ),
   info: (props = {}) => (
     <SvgIcon viewBox="0 0 16 16" component={InfoIcon} {...props} />
   ),
@@ -79,6 +88,9 @@ const icons: { [x in IconsType]: (props?: any) => React.ReactElement } = {
   upload: (props = {}) => (
     <SvgIcon viewBox="0 0 16 16" component={UploadIcon} {...props} />
   ),
+  vectorSearch: (props = {}) => (
+    <SvgIcon viewBox="0 0 48 48" component={SearchEmptyIcon} {...props} />
+  ),
   copyExpression: (props = {}) => (
     <SvgIcon viewBox="0 0 16 16" component={CopyIcon} {...props} />
   ),

+ 4 - 0
client/src/components/icons/Types.ts

@@ -15,6 +15,7 @@ export type IconsType =
   | 'navOverview'
   | 'navCollection'
   | 'navConsole'
+  | 'navSearch'
   | 'expandLess'
   | 'expandMore'
   | 'back'
@@ -27,4 +28,7 @@ export type IconsType =
   | 'key'
   | 'upload'
   | 'dropdown'
+  | 'vectorSearch'
+  | 'refresh'
+  | 'filter'
   | 'copyExpression';

+ 0 - 3
client/src/components/insert/Preview.tsx

@@ -59,9 +59,6 @@ const getStyles = makeStyles((theme: Theme) => ({
     },
   },
 
-  active: {
-    color: theme.palette.primary.main,
-  },
   menuIcon: {
     color: theme.palette.milvusGrey.dark,
   },

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

@@ -17,6 +17,18 @@ const useStyles = makeStyles((theme: Theme) =>
     },
     content: {
       display: 'flex',
+
+      '& .normalSearchIcon': {
+        '& path': {
+          fill: theme.palette.milvusGrey.dark,
+        },
+      },
+
+      '& .activeSearchIcon': {
+        '& path': {
+          fill: theme.palette.primary.main,
+        },
+      },
     },
     body: {
       flex: 1,
@@ -34,13 +46,17 @@ const Layout = (props: any) => {
   const { t: navTrans } = useTranslation('nav');
   const classes = useStyles();
   const location = useLocation();
-  const defaultActive = useMemo(
-    () =>
-      location.pathname.includes('collection')
-        ? navTrans('collection')
-        : navTrans('overview'),
-    [location, navTrans]
-  );
+  const defaultActive = useMemo(() => {
+    if (location.pathname.includes('collection')) {
+      return navTrans('collection');
+    }
+
+    if (location.pathname.includes('search')) {
+      return navTrans('search');
+    }
+
+    return navTrans('overview');
+  }, [location, navTrans]);
 
   const menuItems: NavMenuItem[] = [
     {
@@ -53,6 +69,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',
+    },
   ];
 
   return (

+ 9 - 3
client/src/components/menu/SimpleMenu.tsx

@@ -13,7 +13,7 @@ const getStyles = makeStyles((theme: Theme) => ({
     borderRadius: '4px',
   },
   menuItem: {
-    minWidth: '160px',
+    minWidth: (props: { minWidth: string }) => props.minWidth,
     padding: theme.spacing(1),
 
     '&:hover': {
@@ -23,10 +23,16 @@ const getStyles = makeStyles((theme: Theme) => ({
 }));
 
 const SimpleMenu: FC<SimpleMenuType> = props => {
-  const { label, menuItems, buttonProps, className = '' } = props;
+  const {
+    label,
+    menuItems,
+    buttonProps,
+    menuItemWidth = '160px',
+    className = '',
+  } = props;
   const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
 
-  const classes = getStyles();
+  const classes = getStyles({ minWidth: menuItemWidth });
   const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
     setAnchorEl(event.currentTarget);
   };

+ 2 - 0
client/src/components/menu/Types.ts

@@ -10,6 +10,8 @@ export type SimpleMenuType = {
   }[];
   buttonProps?: ButtonProps;
   className?: string;
+  // e.g. 160px
+  menuItemWidth?: string;
 };
 
 export type NavMenuItem = {

+ 74 - 12
client/src/consts/Milvus.tsx

@@ -1,3 +1,5 @@
+import { DataTypeEnum } from '../pages/collections/Types';
+
 export enum METRIC_TYPES_VALUES {
   L2 = 'L2',
   IP = 'IP',
@@ -55,7 +57,7 @@ export type indexConfigType = {
     create: string[];
     search: searchKeywordsType[];
   };
-}
+};
 
 // index
 export const FLOAT_INDEX_CONFIG: indexConfigType = {
@@ -67,10 +69,10 @@ export const FLOAT_INDEX_CONFIG: indexConfigType = {
     create: ['nlist', 'm'],
     search: ['nprobe'],
   },
-  // IVF_SQ8: {
-  //   create: ['nlist'],
-  //   search: ['nprobe'],
-  // },
+  IVF_SQ8: {
+    create: ['nlist'],
+    search: ['nprobe'],
+  },
   // IVF_SQ8_HYBRID: {
   //   create: ['nlist'],
   //   search: ['nprobe'],
@@ -91,9 +93,10 @@ export const FLOAT_INDEX_CONFIG: indexConfigType = {
   //   create: ['out_degree', 'candidate_pool_size', 'search_length', 'knng'],
   //   search: ['search_length'],
   // },}
-}
+};
 
 export const BINARY_INDEX_CONFIG: indexConfigType = {
+  // },
   BIN_FLAT: {
     create: ['nlist'],
     search: ['nprobe'],
@@ -120,13 +123,72 @@ export const m_OPTIONS = [
 ];
 
 export const INDEX_OPTIONS_MAP = {
-  FLOAT_INDEX: Object.keys(FLOAT_INDEX_CONFIG).map(v => ({ label: v, value: v })),
-  BINARY_INDEX: Object.keys(BINARY_INDEX_CONFIG).map(v => ({ label: v, value: v })),
+  [DataTypeEnum.FloatVector]: Object.keys(FLOAT_INDEX_CONFIG).map(v => ({
+    label: v,
+    value: v,
+  })),
+  [DataTypeEnum.BinaryVector]: Object.keys(BINARY_INDEX_CONFIG).map(v => ({
+    label: v,
+    value: v,
+  })),
 };
 
 export const PRIMARY_KEY_FIELD = 'INT64 (Primary key)';
 
-export enum EmbeddingTypeEnum {
-  float = 'FLOAT_INDEX',
-  binary = 'BINARY_INDEX',
-}
+export const METRIC_OPTIONS_MAP = {
+  [DataTypeEnum.FloatVector]: [
+    {
+      value: METRIC_TYPES_VALUES.L2,
+      label: METRIC_TYPES_VALUES.L2,
+    },
+    {
+      value: METRIC_TYPES_VALUES.IP,
+      label: METRIC_TYPES_VALUES.IP,
+    },
+  ],
+  [DataTypeEnum.BinaryVector]: [
+    {
+      value: METRIC_TYPES_VALUES.SUBSTRUCTURE,
+      label: METRIC_TYPES_VALUES.SUBSTRUCTURE,
+    },
+    {
+      value: METRIC_TYPES_VALUES.SUPERSTRUCTURE,
+      label: METRIC_TYPES_VALUES.SUPERSTRUCTURE,
+    },
+    {
+      value: METRIC_TYPES_VALUES.HAMMING,
+      label: METRIC_TYPES_VALUES.HAMMING,
+    },
+    {
+      value: METRIC_TYPES_VALUES.JACCARD,
+      label: METRIC_TYPES_VALUES.JACCARD,
+    },
+    {
+      value: METRIC_TYPES_VALUES.TANIMOTO,
+      label: METRIC_TYPES_VALUES.TANIMOTO,
+    },
+  ],
+};
+
+/**
+ * use L2 as float default metric type
+ * use Hamming as binary default metric type
+ */
+export const DEFAULT_METRIC_VALUE_MAP = {
+  [DataTypeEnum.FloatVector]: METRIC_TYPES_VALUES.L2,
+  [DataTypeEnum.BinaryVector]: METRIC_TYPES_VALUES.HAMMING,
+};
+
+// search params default value map
+export const DEFAULT_SEARCH_PARAM_VALUE_MAP: {
+  [key in searchKeywordsType]: number;
+} = {
+  // range: [top_k, 32768]
+  ef: 250,
+  // range: [1, nlist]
+  nprobe: 1,
+  // range: {-1} ∪ [top_k, n × n_trees]
+  search_k: 250,
+  // range: [10, 300]
+  search_length: 10,
+};

+ 6 - 7
client/src/hooks/Navigation.ts

@@ -1,6 +1,5 @@
 import { useContext, useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
-// import { useParams } from 'react-router-dom';
 import { navContext } from '../context/Navigation';
 import { ALL_ROUTER_TYPES, NavInfo } from '../router/Types';
 
@@ -10,7 +9,7 @@ export const useNavigationHook = (
     collectionName: string;
   }
 ) => {
-  const { t } = useTranslation('nav');
+  const { t: navTrans } = useTranslation('nav');
   const { setNavInfo } = useContext(navContext);
   const { collectionName } = extraParam || { collectionName: '' };
 
@@ -18,7 +17,7 @@ export const useNavigationHook = (
     switch (type) {
       case ALL_ROUTER_TYPES.OVERVIEW: {
         const navInfo: NavInfo = {
-          navTitle: t('overview'),
+          navTitle: navTrans('overview'),
           backPath: '',
         };
         setNavInfo(navInfo);
@@ -26,7 +25,7 @@ export const useNavigationHook = (
       }
       case ALL_ROUTER_TYPES.COLLECTIONS: {
         const navInfo: NavInfo = {
-          navTitle: t('collection'),
+          navTitle: navTrans('collection'),
           backPath: '',
         };
         setNavInfo(navInfo);
@@ -40,9 +39,9 @@ export const useNavigationHook = (
         setNavInfo(navInfo);
         break;
       }
-      case ALL_ROUTER_TYPES.CONSOLE: {
+      case ALL_ROUTER_TYPES.SEARCH: {
         const navInfo: NavInfo = {
-          navTitle: t('console'),
+          navTitle: navTrans('search'),
           backPath: '',
         };
         setNavInfo(navInfo);
@@ -51,5 +50,5 @@ export const useNavigationHook = (
       default:
         break;
     }
-  }, [type, t, setNavInfo, collectionName]);
+  }, [type, navTrans, setNavInfo, collectionName]);
 };

+ 6 - 0
client/src/http/BaseModel.ts

@@ -78,4 +78,10 @@ export default class BaseModel {
     const res = await http.post(path, data);
     return res.data;
   }
+
+  static async vectorSearch(options: updateParamsType) {
+    const { path, data } = options;
+    const res = await http.post(path, data);
+    return res.data.data;
+  }
 }

+ 8 - 0
client/src/http/Collection.ts

@@ -1,6 +1,7 @@
 import { ChildrenStatusType, StatusEnum } from '../components/status/Types';
 import { CollectionView, InsertDataParam } from '../pages/collections/Types';
 import { Field } from '../pages/schema/Types';
+import { VectorSearchParam } from '../pages/seach/Types';
 import { IndexState, ShowCollectionsType } from '../types/Milvus';
 import { formatNumber } from '../utils/Common';
 import BaseModel from './BaseModel';
@@ -80,6 +81,13 @@ export class CollectionHttp extends BaseModel implements CollectionView {
     });
   }
 
+  static vectorSearchData(collectionName: string, params: VectorSearchParam) {
+    return super.vectorSearch({
+      path: `${this.COLLECTIONS_URL}/${collectionName}/search`,
+      data: params,
+    });
+  }
+
   get _autoId() {
     return this.autoID;
   }

+ 4 - 0
client/src/http/Index.ts

@@ -76,4 +76,8 @@ export class IndexHttp extends BaseModel implements IndexView {
   get _fieldName() {
     return this.field_name;
   }
+
+  get _metricType() {
+    return this.params.find(p => p.key === 'metric_type')?.value || '';
+  }
 }

+ 1 - 0
client/src/i18n/cn/nav.ts

@@ -2,6 +2,7 @@ const navTrans = {
   overview: 'Overview',
   collection: 'Collection',
   console: 'Search Console',
+  search: 'Vector Search',
 };
 
 export default navTrans;

+ 15 - 0
client/src/i18n/cn/search.ts

@@ -0,0 +1,15 @@
+const searchTrans = {
+  firstTip: '1. Enter vector value',
+  secondTip: '2. Choose collection and field',
+  thirdTip: '3. Set search parameters',
+  vectorPlaceholder: 'Please input your vector value here, e.g. [1, 2, 3, 4]',
+  collection: 'Choose Collection',
+  field: 'Choose Field',
+  startTip: 'Start your vector search',
+  empty: 'No result',
+  result: 'Search Results',
+  topK: 'TopK {{number}}',
+  filter: 'Advanced Filter',
+};
+
+export default searchTrans;

+ 2 - 0
client/src/i18n/cn/warning.ts

@@ -3,6 +3,8 @@ const warningTrans = {
   positive: '{{name}} should be positive',
   integer: '{{name}} should be integers',
   range: 'range is {{min}} ~ {{max}}',
+  specValueOrRange:
+    '{{name}} should be {{specValue}}, or in range {{min}} ~ {{max}}',
 };
 
 export default warningTrans;

+ 1 - 0
client/src/i18n/en/nav.ts

@@ -2,6 +2,7 @@ const navTrans = {
   overview: 'Overview',
   collection: 'Collection',
   console: 'Search Console',
+  search: 'Vector Search',
 };
 
 export default navTrans;

+ 15 - 0
client/src/i18n/en/search.ts

@@ -0,0 +1,15 @@
+const searchTrans = {
+  firstTip: '1. Enter vector value',
+  secondTip: '2. Choose collection and field',
+  thirdTip: '3. Set search parameters',
+  vectorPlaceholder: 'Please input your vector value here, e.g. [1, 2, 3, 4]',
+  collection: 'Choose Collection',
+  field: 'Choose Field',
+  startTip: 'Start your vector search',
+  empty: 'No result',
+  result: 'Search Results',
+  topK: 'TopK {{number}}',
+  filter: 'Advanced Filter',
+};
+
+export default searchTrans;

+ 2 - 0
client/src/i18n/en/warning.ts

@@ -3,6 +3,8 @@ const warningTrans = {
   positive: '{{name}} should be positive',
   integer: '{{name}} should be integers',
   range: 'range is {{min}} ~ {{max}}',
+  specValueOrRange:
+    '{{name}} should be {{specValue}}, or in range {{min}} ~ {{max}}',
 };
 
 export default warningTrans;

+ 4 - 0
client/src/i18n/index.ts

@@ -23,6 +23,8 @@ import indexEn from './en/index';
 import indexCn from './cn/index';
 import insertEn from './en/insert';
 import insertCn from './cn/insert';
+import searchEn from './en/search';
+import searchCn from './cn/search';
 
 export const resources = {
   cn: {
@@ -37,6 +39,7 @@ export const resources = {
     success: successCn,
     index: indexCn,
     insert: insertCn,
+    search: searchCn,
   },
   en: {
     translation: commonEn,
@@ -50,6 +53,7 @@ export const resources = {
     success: successEn,
     index: indexEn,
     insert: insertEn,
+    search: searchEn,
   },
 };
 

+ 6 - 1
client/src/pages/connect/Connect.tsx

@@ -4,7 +4,12 @@ import { ITextfieldConfig } from '../../components/customInput/Types';
 import icons from '../../components/icons/Icons';
 import ConnectContainer from './ConnectContainer';
 import CustomInput from '../../components/customInput/CustomInput';
-import { useContext, useEffect, useMemo, useState } from 'react';
+import {
+  useContext,
+  // useEffect,
+  useMemo,
+  useState,
+} from 'react';
 import { formatForm } from '../../utils/Form';
 import { useFormValidation } from '../../hooks/Form';
 import CustomButton from '../../components/customButton/CustomButton';

+ 10 - 7
client/src/pages/schema/Create.tsx

@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import DialogTemplate from '../../components/customDialog/DialogTemplate';
 import {
-  EmbeddingTypeEnum,
   INDEX_CONFIG,
   INDEX_OPTIONS_MAP,
   MetricType,
@@ -10,6 +9,7 @@ import {
 } from '../../consts/Milvus';
 import { useFormValidation } from '../../hooks/Form';
 import { formatForm, getMetricOptions } from '../../utils/Form';
+import { getEmbeddingType } from '../../utils/search';
 import { DataType } from '../collections/Types';
 import CreateForm from './CreateForm';
 import { IndexType, ParamPair, INDEX_TYPES_ENUM } from './Types';
@@ -26,8 +26,14 @@ const CreateIndex = (props: {
   const { t: dialogTrans } = useTranslation('dialog');
   const { t: btnTrans } = useTranslation('btn');
 
-  const defaultIndexType = fieldType === 'BinaryVector' ? INDEX_TYPES_ENUM.BIN_IVF_FLAT : INDEX_TYPES_ENUM.IVF_FLAT;
-  const defaultMetricType = fieldType === 'BinaryVector' ? METRIC_TYPES_VALUES.HAMMING : METRIC_TYPES_VALUES.L2;
+  const defaultIndexType =
+    fieldType === 'BinaryVector'
+      ? INDEX_TYPES_ENUM.BIN_IVF_FLAT
+      : INDEX_TYPES_ENUM.IVF_FLAT;
+  const defaultMetricType =
+    fieldType === 'BinaryVector'
+      ? METRIC_TYPES_VALUES.HAMMING
+      : METRIC_TYPES_VALUES.L2;
 
   const [indexSetting, setIndexSetting] = useState<{
     index_type: IndexType;
@@ -68,10 +74,7 @@ const CreateIndex = (props: {
   }, [indexCreateParams, indexSetting]);
 
   const indexOptions = useMemo(() => {
-    const type =
-      fieldType === 'BinaryVector'
-        ? EmbeddingTypeEnum.binary
-        : EmbeddingTypeEnum.float;
+    const type = getEmbeddingType(fieldType);
     return INDEX_OPTIONS_MAP[type];
   }, [fieldType]);
 

+ 0 - 1
client/src/pages/schema/Schema.tsx

@@ -99,7 +99,6 @@ const Schema: FC<{
 
       try {
         const list = await fetchSchemaListWithIndex(collectionName);
-        console.log(list);
         const fields: FieldView[] = list.map(f =>
           Object.assign(f, {
             _fieldNameElement: (

+ 2 - 0
client/src/pages/schema/Types.ts

@@ -1,4 +1,5 @@
 import { ReactElement } from 'react';
+import { MetricType } from '../../consts/Milvus';
 import { DataType } from '../collections/Types';
 
 export enum INDEX_TYPES_ENUM {
@@ -47,6 +48,7 @@ export interface IndexView {
   _indexTypeElement?: ReactElement;
   _indexParameterPairs: { key: string; value: string }[];
   _indexParamElement?: ReactElement;
+  _metricType?: MetricType | string;
 }
 
 export type IndexType =

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

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

+ 259 - 0
client/src/pages/seach/SearchParams.tsx

@@ -0,0 +1,259 @@
+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_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),
+  },
+}));
+
+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(
+    () => (indexType !== '' ? INDEX_CONFIG[indexType].search : []),
+    [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.input,
+        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;
+    },
+    [warningTrans, classes.input]
+  );
+
+  const getSearchInputConfig = useCallback(
+    (paramKey: searchKeywordsType): ITextfieldConfig => {
+      const nlist = Number(
+        indexParams.find(p => p.key === 'nlist')?.value || 0
+      );
+
+      const configParamMap: {
+        [key in searchKeywordsType]: SearchParamInputConfig;
+      } = {
+        nprobe: {
+          label: 'nprobe',
+          key: 'nprobe',
+          value: searchParamsForm['nprobe'] || '',
+          min: 1,
+          max: nlist,
+          isInt: true,
+          handleChange: value => {
+            handleInputChange('nprobe', value);
+          },
+        },
+        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,
+      topK,
+      searchParamsForm,
+      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={true}
+      />
+
+      {/* 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>
+  );
+};
+
+export default SearchParams;

+ 119 - 0
client/src/pages/seach/Styles.ts

@@ -0,0 +1,119 @@
+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, 3),
+      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),
+    },
+
+    '& .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,
+  },
+}));

+ 62 - 0
client/src/pages/seach/Types.ts

@@ -0,0 +1,62 @@
+import { Option } from '../../components/customSelector/Types';
+import { searchKeywordsType } from '../../consts/Milvus';
+import { DataType, DataTypeEnum } from '../collections/Types';
+import { IndexView } from '../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;
+}
+
+export interface SearchParamInputConfig {
+  label: string;
+  key: searchKeywordsType;
+  min: number;
+  max: number;
+  isInt?: boolean;
+  // no value: empty string
+  value: number | string;
+  handleChange: (value: number) => void;
+}
+
+export interface VectorSearchParam {
+  expr?: string;
+  search_params: { key: string; value: string | number }[];
+  vectors: any;
+  output_fields: string[];
+  vector_type: number | DataTypeEnum;
+}
+
+export interface SearchResult {
+  // dynamic field names
+  [key: string]: string | number;
+  score: number;
+}

+ 422 - 0
client/src/pages/seach/VectorSearch.tsx

@@ -0,0 +1,422 @@
+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 '../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';
+
+const VectorSearch = () => {
+  useNavigationHook(ALL_ROUTER_TYPES.SEARCH);
+  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,
+  } = usePaginationHook(searchResult || []);
+
+  const searchDisabled = useMemo(() => {
+    /**
+     * before search, user must:
+     * 1. enter vector value
+     * 2. choose collection and field
+     * 3. set extra search params
+     */
+    const isInvalid =
+      vectors === '' ||
+      selectedCollection === '' ||
+      selectedField === '' ||
+      paramDisabled;
+    return isInvalid;
+  }, [paramDisabled, selectedField, selectedCollection, vectors]);
+
+  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 colDefinitions: ColDefinitionsType[] = useMemo(() => {
+    // filter id and score
+    return searchResult && searchResult.length > 0
+      ? Object.keys(searchResult[0])
+          .filter(item => item !== 'id' && item !== 'score')
+          .map(key => ({
+            id: key,
+            align: 'left',
+            disablePadding: false,
+            label: key,
+          }))
+      : [];
+  }, [searchResult]);
+
+  const { metricType, indexType, indexParams, fieldType, embeddingType } =
+    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 || [];
+
+        return {
+          metricType: metric,
+          indexType: index?._indexType || getDefaultIndexType(embeddingType),
+          indexParams,
+          fieldType: DataTypeEnum[selectedFieldInfo?.fieldType!],
+          embeddingType,
+        };
+      }
+
+      return {
+        metricType: '',
+        indexType: '',
+        indexParams: [],
+        fieldType: 0,
+        embeddingType: DataTypeEnum.FloatVector,
+      };
+    }, [selectedField, fieldOptions]);
+
+  // 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);
+      // 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]);
+
+  // 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 = [
+      // dynamic search params
+      {
+        key: 'params',
+        value: JSON.stringify(searchParam),
+      },
+      {
+        key: 'anns_field',
+        value: selectedField,
+      },
+      {
+        key: 'topk',
+        value: topK,
+      },
+      {
+        key: 'metric_type',
+        value: metricType,
+      },
+    ];
+
+    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 */}
+        <fieldset className="field">
+          <Typography className="text">{searchTrans('firstTip')}</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);
+            }}
+          />
+        </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('collection')}
+            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={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">
+          <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}
+        />
+      ) : (
+        <EmptyCard
+          wrapperClass={`page-empty-card`}
+          icon={<VectorSearchIcon />}
+          text={
+            searchResult !== null
+              ? searchTrans('empty')
+              : searchTrans('startTip')
+          }
+        />
+      )}
+    </section>
+  );
+};
+
+export default VectorSearch;

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

@@ -2,6 +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 { RouterConfigType } from './Types';
 
 const RouterConfig: RouterConfigType[] = [
@@ -25,6 +26,11 @@ const RouterConfig: RouterConfigType[] = [
     component: Collection,
     auth: true,
   },
+  {
+    path: '/search',
+    component: VectorSearch,
+    auth: true,
+  },
 ];
 
 export default RouterConfig;

+ 2 - 2
client/src/router/Types.ts

@@ -5,8 +5,8 @@ export enum ALL_ROUTER_TYPES {
   COLLECTIONS = 'collections',
   // '/collections/:collectionId'
   COLLECTION_DETAIL = 'collection_detail',
-  // '/console'
-  CONSOLE = 'console',
+  // 'search'
+  SEARCH = 'search',
 }
 
 export type NavInfo = {

+ 18 - 7
client/src/utils/Format.ts

@@ -74,18 +74,29 @@ export const getEnumKeyByValue = (enumObj: any, enumValue: any) => {
   return '--';
 };
 
+/**
+ *
+ * @param obj e.g. {name: 'test'}
+ * @returns key value pair, e.g. [{key: 'name', value: 'test'}]
+ */
+export const getKeyValuePairFromObj = (
+  obj: { [key in string]: any }
+): { key: string; value: any }[] => {
+  const pairs: { key: string; value: string }[] = Object.entries(obj).map(
+    ([key, value]) => ({
+      key,
+      value: value as string,
+    })
+  );
+  return pairs;
+};
+
 export const getKeyValueListFromJsonString = (
   json: string
 ): { key: string; value: string }[] => {
   try {
     const obj = JSON.parse(json);
-
-    const pairs: { key: string; value: string }[] = Object.entries(obj).map(
-      ([key, value]) => ({
-        key,
-        value: value as string,
-      })
-    );
+    const pairs = getKeyValuePairFromObj(obj);
 
     return pairs;
   } catch (err) {

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

@@ -26,7 +26,7 @@ const replaceKeysByIndex = (obj: any, newKeys: string[]) => {
   return Object.assign({}, ...keyValues);
 };
 
-const parseValue = (value: string) => {
+export const parseValue = (value: string) => {
   try {
     return JSON.parse(value);
   } catch (err) {

+ 29 - 5
client/src/utils/Validation.ts

@@ -14,15 +14,16 @@ export type ValidType =
   | 'dimension'
   | 'multiple'
   | 'partitionName'
-  | 'firstCharacter';
+  | 'firstCharacter'
+  | 'specValueOrRange';
 export interface ICheckMapParam {
   value: string;
   extraParam?: IExtraParam;
   rule: ValidType;
 }
 export interface IExtraParam {
-  // used for confirm type
-  compareValue?: string;
+  // used for confirm or any compare type
+  compareValue?: string | number;
   // used for length type
   min?: number;
   max?: number;
@@ -64,13 +65,13 @@ export const checkPasswordStrength = (value: string): boolean => {
 };
 
 export const checkRange = (param: {
-  value: string;
+  value: string | number;
   min?: number;
   max?: number;
   type?: 'string' | 'number';
 }): boolean => {
   const { value, min = 0, max = 0, type } = param;
-  const length = type === 'number' ? Number(value) : value.length;
+  const length = type === 'number' ? Number(value) : (value as string).length;
 
   let result = true;
   const conditionMap = {
@@ -181,6 +182,23 @@ export const checkDimension = (param: {
   return checkMultiple({ value, multipleNumber });
 };
 
+/**
+ * function to check whether value(type: number) is equal to specified value or in valid range
+ * @param param specValue and params checkRange function needed
+ * @returns whether input is valid
+ */
+export const checkSpecValueOrRange = (param: {
+  value: number;
+  min: number;
+  max: number;
+  compareValue: number;
+}): boolean => {
+  const { value, min, max, compareValue } = param;
+  return (
+    value === compareValue || checkRange({ min, max, value, type: 'number' })
+  );
+};
+
 export const getCheckResult = (param: ICheckMapParam): boolean => {
   const { value, extraParam = {}, rule } = param;
   const numberValue = Number(value);
@@ -215,6 +233,12 @@ export const getCheckResult = (param: ICheckMapParam): boolean => {
       value,
       invalidTypes: extraParam?.invalidTypes,
     }),
+    specValueOrRange: checkSpecValueOrRange({
+      value: Number(value),
+      min: extraParam?.min || 0,
+      max: extraParam?.max || 0,
+      compareValue: Number(extraParam.compareValue) || 0,
+    }),
   };
 
   return checkMap[rule];

+ 104 - 0
client/src/utils/search.ts

@@ -0,0 +1,104 @@
+import { Field } from '../components/advancedSearch/Types';
+import { DataType, DataTypeEnum } from '../pages/collections/Types';
+import {
+  FieldData,
+  IndexType,
+  IndexView,
+  INDEX_TYPES_ENUM,
+} from '../pages/schema/Types';
+import {
+  FieldOption,
+  SearchResult,
+  SearchResultView,
+} from '../pages/seach/Types';
+
+export const transferSearchResult = (
+  result: SearchResult[]
+): SearchResultView[] => {
+  const resultView = result
+    .sort((a, b) => a.score - b.score)
+    .map((r, index) => ({
+      rank: index + 1,
+      distance: r.score,
+      ...r,
+    }));
+
+  return resultView;
+};
+
+/**
+ * function to get EmbeddingType
+ * @param fieldType only vector type fields: 'BinaryVector' or 'FloatVector'
+ */
+export const getEmbeddingType = (
+  fieldType: DataType
+): DataTypeEnum.BinaryVector | DataTypeEnum.FloatVector => {
+  const type =
+    fieldType === 'BinaryVector'
+      ? DataTypeEnum.BinaryVector
+      : DataTypeEnum.FloatVector;
+  return type;
+};
+
+/**
+ * function to get default index type according to embedding type
+ * use FLAT as default float index type, BIN_FLAT as default binary index type
+ * @param embeddingType float or binary
+ * @returns index type
+ */
+export const getDefaultIndexType = (embeddingType: DataTypeEnum): IndexType => {
+  const defaultIndexType =
+    embeddingType === DataTypeEnum.FloatVector
+      ? INDEX_TYPES_ENUM.FLAT
+      : INDEX_TYPES_ENUM.BIN_FLAT;
+  return defaultIndexType;
+};
+
+/**
+ * funtion to divide fields into two categories: vector or nonVector
+ */
+export const classifyFields = (
+  fields: FieldData[]
+): { vectorFields: FieldData[]; nonVectorFields: FieldData[] } => {
+  const vectorTypes: DataType[] = ['BinaryVector', 'FloatVector'];
+  return fields.reduce(
+    (result, cur) => {
+      const changedFieldType = vectorTypes.includes(cur._fieldType)
+        ? 'vectorFields'
+        : 'nonVectorFields';
+
+      result[changedFieldType].push(cur);
+
+      return result;
+    },
+    { vectorFields: [] as FieldData[], nonVectorFields: [] as FieldData[] }
+  );
+};
+
+export const getVectorFieldOptions = (
+  fields: FieldData[],
+  indexes: IndexView[]
+): FieldOption[] => {
+  const options: FieldOption[] = fields.map(f => {
+    const embeddingType = getEmbeddingType(f._fieldType);
+    const defaultIndex = getDefaultIndexType(embeddingType);
+    const index = indexes.find(i => i._fieldName === f._fieldName);
+
+    return {
+      label: `${f._fieldName} (${index?._indexType || defaultIndex})`,
+      value: f._fieldName,
+      fieldType: f._fieldType,
+      indexInfo: index || null,
+    };
+  });
+
+  return options;
+};
+
+export const getNonVectorFieldsForFilter = (fields: FieldData[]): Field[] => {
+  const intTypes: DataType[] = ['Int8', 'Int16', 'Int32', 'Int64'];
+  return fields.map(f => ({
+    name: f._fieldName,
+    type: intTypes.includes(f._fieldType) ? 'int' : 'float',
+  }));
+};