Browse Source

Support data explorer for search page (#664)

* init data explorer

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* part2

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* part3

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* add reset button

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* fix panel position

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* fix close button text

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* stablize color

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* hide panel while dragging

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* WIP

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* fix render issue

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* WIP

* fix issues

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* finish data explorer

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

---------

Signed-off-by: ryjiang <jiangruiyi@gmail.com>
ryjiang 8 months ago
parent
commit
0553ad6297

+ 1 - 1
client/package.json

@@ -25,7 +25,7 @@
     "@mui/x-tree-view": "^7.12.1",
     "@mui/x-tree-view": "^7.12.1",
     "axios": "^1.7.4",
     "axios": "^1.7.4",
     "codemirror": "^6.0.1",
     "codemirror": "^6.0.1",
-    "d3": "^7.8.5",
+    "d3": "^7.9.0",
     "dayjs": "^1.11.9",
     "dayjs": "^1.11.9",
     "file-saver": "^2.0.5",
     "file-saver": "^2.0.5",
     "i18next": "^20.3.1",
     "i18next": "^20.3.1",

+ 1 - 1
client/src/consts/Milvus.ts

@@ -332,7 +332,7 @@ export enum ConsistencyLevelEnum {
   Customized = 'Customized', // Users pass their own `guarantee_timestamp`.
   Customized = 'Customized', // Users pass their own `guarantee_timestamp`.
 }
 }
 
 
-export const TOP_K_OPTIONS = [50, 100, 150, 200, 250].map(v => ({
+export const TOP_K_OPTIONS = [15, 50, 100, 150, 200, 250].map(v => ({
   value: v,
   value: v,
   label: String(v),
   label: String(v),
 }));
 }));

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

@@ -37,6 +37,8 @@ const btnTrans = {
   applyFilter: '应用过滤器',
   applyFilter: '应用过滤器',
   createIndex: '创建索引',
   createIndex: '创建索引',
   edit: '编辑',
   edit: '编辑',
+  explore: '探索',
+  close: '关闭',
 
 
   // tips
   // tips
   loadColTooltip: '加载Collection',
   loadColTooltip: '加载Collection',

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

@@ -27,6 +27,7 @@ const searchTrans = {
   groupBy: '分组',
   groupBy: '分组',
   outputFields: '输出字段',
   outputFields: '输出字段',
   consistency: '一致性',
   consistency: '一致性',
+  graphNodeHoverTip: '双击以查看更多',
 };
 };
 
 
 export default searchTrans;
 export default searchTrans;

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

@@ -37,6 +37,8 @@ const btnTrans = {
   applyFilter: 'Apply Filters',
   applyFilter: 'Apply Filters',
   createIndex: 'Create Index',
   createIndex: 'Create Index',
   edit: 'Edit',
   edit: 'Edit',
+  explore: 'Explore',
+  close: 'Close',
 
 
   // tips
   // tips
   loadColTooltip: 'Load Collection',
   loadColTooltip: 'Load Collection',

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

@@ -27,6 +27,7 @@ const searchTrans = {
   groupBy: 'Group By',
   groupBy: 'Group By',
   outputFields: 'Outputs',
   outputFields: 'Outputs',
   consistency: 'Consistency',
   consistency: 'Consistency',
+  graphNodeHoverTip: 'Double click to explore more',
 };
 };
 
 
 export default searchTrans;
 export default searchTrans;

+ 2 - 1
client/src/pages/databases/Databases.tsx

@@ -182,7 +182,7 @@ const Databases = () => {
                 };
                 };
               }),
               }),
               globalParams: {
               globalParams: {
-                topK: 50,
+                topK: 15,
                 consistency_level: ConsistencyLevelEnum.Bounded,
                 consistency_level: ConsistencyLevelEnum.Bounded,
                 filter: '',
                 filter: '',
                 rerank: 'rrf',
                 rerank: 'rrf',
@@ -195,6 +195,7 @@ const Databases = () => {
                   : scalarFields,
                   : scalarFields,
               },
               },
               searchResult: null,
               searchResult: null,
+              graphData: { nodes: [], links: [] },
               searchLatency: 0,
               searchLatency: 0,
             },
             },
           ];
           ];

+ 305 - 0
client/src/pages/databases/collections/search/DataExplorer.tsx

@@ -0,0 +1,305 @@
+import { useEffect, useRef, useState } from 'react';
+import * as d3 from 'd3';
+import { useTheme } from '@mui/material';
+import { cloneObj } from '@/utils';
+import { getDataExplorerStyle } from './Styles';
+import { GraphData, GraphNode, GraphLink } from '../../types';
+import DataPanel from './DataPanel';
+
+interface DataExplorerProps {
+  data: GraphData;
+  width?: number;
+  height?: number;
+  onNodeClick?: (node: any) => void;
+}
+
+// Helper function to check if node already exists
+function findNodes(nodes: any[], vector: any) {
+  return nodes.filter(node => node.id === vector.id);
+}
+
+// Helper function to check if a link already exists
+function linkExists(links: any[], source: string, target: string) {
+  return links.some(link => link.source === source && link.target === target);
+}
+
+// d3 color scale for node color
+const color = d3.scaleOrdinal(d3.schemeCategory10);
+
+// Format Milvus search result to graph data
+export const formatMilvusData = (
+  graphData: GraphData,
+  searchedVector: {
+    id: string;
+    [key: string]: any;
+  },
+  results: any[]
+) => {
+  const graphDataCopy = cloneObj(graphData) as GraphData;
+  // if searchedVector's id is 'root', color = 0
+  // otherwise, find the largest color in the graphDataCopy.nodes, and color = largest color + 1
+  const color =
+    searchedVector.id === 'root'
+      ? 0
+      : Math.max(...graphDataCopy.nodes.map(node => node.color)) + 1;
+
+  // Add searched vector as a node if not present
+  const existingNodes = findNodes(graphDataCopy.nodes, searchedVector);
+  if (!existingNodes.length) {
+    graphDataCopy.nodes.push({
+      id: searchedVector.id,
+      data: searchedVector,
+      searchIds: [searchedVector.id],
+      color: color,
+    });
+  } else {
+    // Update existing node with new data
+    existingNodes.forEach(node => {
+      if (!node.searchIds.includes(searchedVector.id)) {
+        node.searchIds.push(searchedVector.id);
+      }
+    });
+  }
+
+  results.forEach(result => {
+    // Add result vector as a node if not present
+    const existingNodes = findNodes(graphDataCopy.nodes, result);
+    if (!existingNodes.length) {
+      graphDataCopy.nodes.push({
+        id: result.id,
+        data: result,
+        searchIds: [searchedVector.id],
+        color: color,
+      });
+    } else {
+      // Update existing node with new data
+      existingNodes.forEach(node => {
+        if (!node.searchIds.includes(searchedVector.id)) {
+          node.searchIds.push(searchedVector.id);
+        }
+      });
+    }
+
+    // Create a link between the searched vector and the result vector if not present
+    const sourceId = searchedVector.id;
+    const targetId = result.id;
+    if (!linkExists(graphDataCopy.links, sourceId, targetId)) {
+      graphDataCopy.links.push({
+        source: sourceId,
+        target: targetId,
+        score: result.score,
+      });
+    }
+  });
+
+  // Return formatted graph data
+  return graphDataCopy;
+};
+
+const DataExplorer = ({
+  data,
+  width = 800,
+  height = 600,
+  onNodeClick,
+}: DataExplorerProps) => {
+  // states
+  const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
+  //  we use ref to store the selected nodes, so that we need to re-render the component by this state withouth affacting the d3 effect
+  const [render, setRender] = useState<number>(0);
+  // theme
+  const theme = useTheme();
+  // classes
+  const classes = getDataExplorerStyle();
+
+  // ref
+  const rootRef = useRef<HTMLDivElement>(null);
+  const svgRef = useRef<SVGSVGElement>(null);
+  const gRef = useRef<SVGGElement>(null);
+  const selectedNodesRef = useRef<any[]>([]);
+
+  // filter out data from selectedNodesRef when data changes
+  useEffect(() => {
+    selectedNodesRef.current = selectedNodesRef.current.filter(node =>
+      data.nodes.some(n => n.id === node.id)
+    );
+    setRender(prev => prev + 1);
+  }, [JSON.stringify(data)]);
+
+  // d3 effect
+  useEffect(() => {
+    if (!svgRef.current || !gRef.current) return;
+    // if no data, clear selectedNodes and hoveredNode
+    if (!data.nodes.length) {
+      selectedNodesRef.current = [];
+      setHoveredNode(null);
+    }
+    // states
+    let _isDragging = false;
+
+    const svg = d3.select(svgRef.current);
+    const g = d3.select(gRef.current);
+
+    // Clear previous nodes and links before rendering new data
+    g.selectAll('*').remove(); // This removes all children of the <g> element
+
+    // Define zoom behavior
+    const zoom = d3
+      .zoom()
+      .scaleExtent([0.1, 10]) // Limit zoom scale
+      .on('zoom', event => {
+        // Apply transform to the g element (where the graph is)
+        g.attr('transform', event.transform);
+      });
+
+    // Apply zoom behavior to the svg
+    svg.call(zoom as any).on('dblclick.zoom', null);
+
+    // clone data to avoid mutating the original data
+    const links = cloneObj(data.links) as GraphLink[];
+    const nodes = cloneObj(data.nodes) as GraphNode[];
+
+    const simulation = d3
+      .forceSimulation(nodes as d3.SimulationNodeDatum[])
+      .force(
+        'link',
+        d3
+          .forceLink(links)
+          .id((d: any) => d.id)
+          .distance(d => {
+            const maxDistance = 150;
+            const minDistance = 50;
+            return maxDistance - d.score * (maxDistance - minDistance);
+          })
+      )
+      .force('charge', d3.forceManyBody().strength(-200))
+      .force('center', d3.forceCenter(width / 3, height / 3));
+
+    // Draw links
+    const link = g
+      .selectAll('.link')
+      .data(links)
+      .enter()
+      .append('line')
+      .attr('class', 'link')
+      .attr('stroke-width', 2)
+      .attr('stroke', theme.palette.divider);
+
+    // Draw nodes
+    const node = g
+      .selectAll('.node')
+      .data(nodes)
+      .enter()
+      .append('circle')
+      .attr('class', 'node')
+      .attr('r', d =>
+        selectedNodesRef.current.some(n => n.id === d.id) ? 16 : 8
+      )
+      .attr('fill', d => {
+        return color(d.color + '');
+      })
+      .attr('cursor', 'pointer')
+      .attr('stroke', d =>
+        selectedNodesRef.current.some(n => n.id === d.id)
+          ? 'black'
+          : 'transparent'
+      )
+      .attr('stroke-width', d =>
+        selectedNodesRef.current.some(n => n.id === d.id) ? 2 : 0
+      )
+      .on('mouseover', (event, d) => {
+        // Highlight the hovered node, keeping selected node unaffected
+        // d3.select(event.target).attr('stroke', 'black').attr('stroke-width', 2);
+        // calcuate the position of the hovered node, place the tooltip accordingly, it should
+        // get parent node's position and the mouse position
+        const parentPosition = rootRef.current?.getBoundingClientRect();
+        const nodeX = event.clientX - parentPosition!.left;
+        const nodeY = event.clientY - parentPosition!.top;
+
+        if (!_isDragging) {
+          setHoveredNode({ ...cloneObj(d), nodeX, nodeY });
+        }
+      })
+      .on('mouseout', () => {
+        setHoveredNode(null);
+      })
+      .on('click', function (event, d) {
+        const isAlreadySelected = selectedNodesRef.current.some(
+          node => node.id === d.id
+        );
+
+        // Update selected nodes
+        if (isAlreadySelected) {
+          selectedNodesRef.current = selectedNodesRef.current.filter(
+            node => node.id !== d.id
+          );
+        } else {
+          selectedNodesRef.current = [...selectedNodesRef.current, d];
+        }
+
+        // Add circle around the selected node and remove it when unselected
+        d3.select(this)
+          .attr('r', isAlreadySelected ? 8 : 16)
+          .attr('stroke', isAlreadySelected ? 'transparent' : 'black')
+          .attr('stroke-width', isAlreadySelected ? 0 : 2);
+
+        // Render the selected nodes
+        setRender(prev => prev + 1);
+      })
+      .on('dblclick', (event, d) => {
+        onNodeClick && onNodeClick(d);
+        // remove the selected node
+        setHoveredNode(null);
+      })
+      .call(
+        d3
+          .drag<SVGCircleElement, any>()
+          .on('start', (event, d) => {
+            _isDragging = true;
+            if (!event.active) simulation.alphaTarget(0.3).restart();
+            d.fx = d.x;
+            d.fy = d.y;
+          })
+          .on('drag', (event, d) => {
+            _isDragging = true;
+            d.fx = event.x;
+            d.fy = event.y;
+          })
+          .on('end', (event, d) => {
+            if (!event.active) simulation.alphaTarget(0);
+            d.fx = null;
+            d.fy = null;
+            _isDragging = false;
+          })
+      );
+
+    simulation.on('tick', () => {
+      link
+        .attr('x1', (d: any) => (d.source as any).x)
+        .attr('y1', (d: any) => (d.source as any).y)
+        .attr('x2', (d: any) => (d.target as any).x)
+        .attr('y2', (d: any) => (d.target as any).y);
+
+      node.attr('cx', d => (d as any).x).attr('cy', d => (d as any).y);
+    });
+  }, [JSON.stringify(data), width, height, theme]);
+
+  return (
+    <div className={classes.root} ref={rootRef}>
+      <svg ref={svgRef} width={width} height={height}>
+        <g ref={gRef} />
+      </svg>
+      {selectedNodesRef.current.length > 0 && (
+        <div className={classes.selectedNodes} data-render={render}>
+          {selectedNodesRef.current.map(node => (
+            <DataPanel key={node.id} node={node} color={color} />
+          ))}
+        </div>
+      )}
+      {hoveredNode && (
+        <DataPanel key={hoveredNode.id} node={hoveredNode} color={color} />
+      )}
+    </div>
+  );
+};
+
+export default DataExplorer;

+ 69 - 0
client/src/pages/databases/collections/search/DataPanel.tsx

@@ -0,0 +1,69 @@
+import { Typography, useTheme } from '@mui/material';
+import SyntaxHighlighter from 'react-syntax-highlighter';
+import { useTranslation } from 'react-i18next';
+import { vs2015, github } from 'react-syntax-highlighter/dist/esm/styles/hljs';
+import { GraphNode } from '../../types';
+
+const DataPanel = (props: { node: GraphNode; color: any }) => {
+  // i18n
+  const { t: searchTrans } = useTranslation('search');
+
+  // theme
+  const theme = useTheme();
+  // props
+  const { node, color } = props;
+  const data = node.data;
+
+  // format data to json
+  const json = JSON.stringify(data, null, 2);
+  const image = [];
+
+  // loop through the object find any value is an image url, add it into an image array;
+  for (const key in data) {
+    if (
+      typeof data[key] === 'string' &&
+      data[key].match(/\.(jpeg|jpg|gif|png)$/) != null
+    ) {
+      image.push(data[key]);
+    }
+  }
+
+  const styleObj = node.nodeY
+    ? {
+        top: node.nodeY + 16,
+        left: node.nodeX! + 16,
+        right: 'auto',
+        position: 'absolute',
+        borderColor: color(node.color + ''),
+      }
+    : { borderColor: color(node.color + '') };
+
+  return (
+    <div key={node.id} className="nodeInfo" style={styleObj as any}>
+      <div className="wrapper">
+        {image.map((url, i) => (
+          <a key={i} href={url} target="_blank">
+            <img src={url} />
+          </a>
+        ))}
+      </div>
+      <SyntaxHighlighter
+        language="json"
+        style={theme.palette.mode === 'dark' ? vs2015 : github}
+        customStyle={{ fontSize: 11, margin: 0 }}
+        wrapLines={true}
+        wrapLongLines={true}
+        showLineNumbers={false}
+      >
+        {json}
+      </SyntaxHighlighter>
+      {node.nodeY && (
+        <Typography className="tip">
+          {searchTrans('graphNodeHoverTip')}
+        </Typography>
+      )}
+    </div>
+  );
+};
+
+export default DataPanel;

+ 108 - 7
client/src/pages/databases/collections/search/Search.tsx

@@ -7,7 +7,7 @@ import {
   Checkbox,
   Checkbox,
 } from '@mui/material';
 } from '@mui/material';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { DataService } from '@/http';
+import { DataService, CollectionService } from '@/http';
 import { rootContext } from '@/context';
 import { rootContext } from '@/context';
 import Icons from '@/components/icons/Icons';
 import Icons from '@/components/icons/Icons';
 import AttuGrid from '@/components/grid/Grid';
 import AttuGrid from '@/components/grid/Grid';
@@ -32,6 +32,8 @@ import {
   getColumnWidth,
   getColumnWidth,
 } from '@/utils';
 } from '@/utils';
 import SearchParams from '../../../search/SearchParams';
 import SearchParams from '../../../search/SearchParams';
+import DataExplorer, { formatMilvusData } from './DataExplorer';
+import { GraphData, GraphNode } from '../../types';
 import {
 import {
   SearchParams as SearchParamsType,
   SearchParams as SearchParamsType,
   SearchSingleParams,
   SearchSingleParams,
@@ -50,6 +52,11 @@ export interface CollectionDataProps {
   setSearchParams: (params: SearchParamsType) => void;
   setSearchParams: (params: SearchParamsType) => void;
 }
 }
 
 
+const emptyExplorerData: GraphData = {
+  nodes: [],
+  links: [],
+};
+
 const Search = (props: CollectionDataProps) => {
 const Search = (props: CollectionDataProps) => {
   // props
   // props
   const { collections, collectionName, searchParams, setSearchParams } = props;
   const { collections, collectionName, searchParams, setSearchParams } = props;
@@ -63,6 +70,7 @@ const Search = (props: CollectionDataProps) => {
   // UI states
   // UI states
   const [tableLoading, setTableLoading] = useState<boolean>();
   const [tableLoading, setTableLoading] = useState<boolean>();
   const [highlightField, setHighlightField] = useState<string>('');
   const [highlightField, setHighlightField] = useState<string>('');
+  const [explorerOpen, setExplorerOpen] = useState<boolean>(false);
 
 
   // translations
   // translations
   const { t: searchTrans } = useTranslation('search');
   const { t: searchTrans } = useTranslation('search');
@@ -162,6 +170,13 @@ const Search = (props: CollectionDataProps) => {
       const s = cloneObj(searchParams) as SearchParamsType;
       const s = cloneObj(searchParams) as SearchParamsType;
       s.searchResult = results;
       s.searchResult = results;
       s.searchLatency = latency;
       s.searchLatency = latency;
+      const newGraphData = formatMilvusData(
+        emptyExplorerData,
+        { id: 'root' },
+        s.searchResult
+      );
+      s.graphData = newGraphData;
+
       setSearchParams({ ...s });
       setSearchParams({ ...s });
     },
     },
     [JSON.stringify(searchParams)]
     [JSON.stringify(searchParams)]
@@ -170,6 +185,7 @@ const Search = (props: CollectionDataProps) => {
   // execute search
   // execute search
   const onSearchClicked = useCallback(async () => {
   const onSearchClicked = useCallback(async () => {
     const params = buildSearchParams(searchParams);
     const params = buildSearchParams(searchParams);
+    setExplorerOpen(false);
 
 
     setTableLoading(true);
     setTableLoading(true);
     try {
     try {
@@ -185,6 +201,51 @@ const Search = (props: CollectionDataProps) => {
     }
     }
   }, [JSON.stringify(searchParams)]);
   }, [JSON.stringify(searchParams)]);
 
 
+  // execute explore
+  const onExploreClicked = () => {
+    setExplorerOpen(explorerOpen => !explorerOpen);
+  };
+
+  const onNodeClicked = useCallback(
+    async (node: GraphNode) => {
+      if (node.id === 'root') {
+        return;
+      }
+      setExplorerOpen(true);
+      setTableLoading(false);
+
+      const s = cloneObj(searchParams);
+      const params = cloneObj(buildSearchParams(searchParams));
+
+      try {
+        const query = await CollectionService.queryData(collectionName, {
+          expr: 'id == ' + node.id,
+          output_fields: ['*'],
+        });
+
+        // replace the vector data
+        params.data[0].data = query.data[0][params.data[0].anns_field];
+
+        const search = await DataService.vectorSearchData(
+          s.collection.collection_name,
+          params
+        );
+
+        const newGraphData = formatMilvusData(
+          s.graphData,
+          { id: node.id, data: params.data[0].data },
+          search.results
+        );
+
+        s.graphData = newGraphData;
+        setSearchParams({ ...s });
+      } catch (err) {
+        console.log('err', err);
+      }
+    },
+    [JSON.stringify(searchParams)]
+  );
+
   const searchResultMemo = useSearchResult(
   const searchResultMemo = useSearchResult(
     (searchParams && (searchParams.searchResult as SearchResultView[])) || []
     (searchParams && (searchParams.searchResult as SearchResultView[])) || []
   );
   );
@@ -213,6 +274,11 @@ const Search = (props: CollectionDataProps) => {
     setCurrentPage(0);
     setCurrentPage(0);
   }, [JSON.stringify(searchParams), setCurrentPage]);
   }, [JSON.stringify(searchParams), setCurrentPage]);
 
 
+  const onExplorerResetClicked = useCallback(() => {
+    onSearchClicked();
+    onExploreClicked();
+  }, [onSearchClicked, onSearchClicked]);
+
   const colDefinitions: ColDefinitionsType[] = useMemo(() => {
   const colDefinitions: ColDefinitionsType[] = useMemo(() => {
     if (!searchParams || !searchParams.collection) {
     if (!searchParams || !searchParams.collection) {
       return [];
       return [];
@@ -418,6 +484,7 @@ const Search = (props: CollectionDataProps) => {
             <CustomButton
             <CustomButton
               variant="contained"
               variant="contained"
               size="small"
               size="small"
+              className={classes.genBtn}
               disabled={disableSearch}
               disabled={disableSearch}
               tooltip={disableSearchTooltip}
               tooltip={disableSearchTooltip}
               tooltipPlacement="top"
               tooltipPlacement="top"
@@ -443,7 +510,7 @@ const Search = (props: CollectionDataProps) => {
                     className: 'textarea',
                     className: 'textarea',
                     onChange: onFilterChange,
                     onChange: onFilterChange,
                     value: searchParams.globalParams.filter,
                     value: searchParams.globalParams.filter,
-                    disabled: false,
+                    disabled: explorerOpen,
                     variant: 'filled',
                     variant: 'filled',
                     required: false,
                     required: false,
                     InputLabelProps: { shrink: true },
                     InputLabelProps: { shrink: true },
@@ -453,7 +520,7 @@ const Search = (props: CollectionDataProps) => {
                           title={''}
                           title={''}
                           showTitle={false}
                           showTitle={false}
                           fields={collection.schema.scalarFields}
                           fields={collection.schema.scalarFields}
-                          filterDisabled={false}
+                          filterDisabled={explorerOpen}
                           onSubmit={(value: string) => {
                           onSubmit={(value: string) => {
                             onFilterChange(value);
                             onFilterChange(value);
                           }}
                           }}
@@ -472,6 +539,20 @@ const Search = (props: CollectionDataProps) => {
                 />
                 />
               </div>
               </div>
               <div className="right">
               <div className="right">
+                <CustomButton
+                  onClick={() => {
+                    onExploreClicked();
+                  }}
+                  size="small"
+                  disabled={
+                    !searchParams.searchResult ||
+                    searchParams.searchResult!.length === 0
+                  }
+                  className={classes.btn}
+                  startIcon={<Icons.magic classes={{ root: 'icon' }} />}
+                >
+                  {btnTrans('explore')}
+                </CustomButton>
                 <CustomButton
                 <CustomButton
                   className={classes.btn}
                   className={classes.btn}
                   disabled={disableSearch}
                   disabled={disableSearch}
@@ -509,7 +590,9 @@ const Search = (props: CollectionDataProps) => {
                 </CustomButton>
                 </CustomButton>
                 <CustomButton
                 <CustomButton
                   className={classes.btn}
                   className={classes.btn}
-                  onClick={onResetClicked}
+                  onClick={
+                    explorerOpen ? onExplorerResetClicked : onResetClicked
+                  }
                   startIcon={<Icons.clear classes={{ root: 'icon' }} />}
                   startIcon={<Icons.clear classes={{ root: 'icon' }} />}
                 >
                 >
                   {btnTrans('reset')}
                   {btnTrans('reset')}
@@ -517,9 +600,27 @@ const Search = (props: CollectionDataProps) => {
               </div>
               </div>
             </section>
             </section>
 
 
-            {(searchParams.searchResult &&
-              searchParams.searchResult.length > 0) ||
-            tableLoading ? (
+            {explorerOpen ? (
+              <div className={classes.explorer}>
+                <DataExplorer
+                  data={searchParams.graphData}
+                  onNodeClick={onNodeClicked}
+                />
+                <CustomButton
+                  onClick={() => {
+                    setExplorerOpen(false);
+                  }}
+                  size="small"
+                  startIcon={<Icons.clear classes={{ root: 'icon' }} />}
+                  className={classes.closeBtn}
+                  variant="contained"
+                >
+                  {btnTrans('close')}
+                </CustomButton>
+              </div>
+            ) : (searchParams.searchResult &&
+                searchParams.searchResult.length > 0) ||
+              tableLoading ? (
               <AttuGrid
               <AttuGrid
                 toolbarConfigs={[]}
                 toolbarConfigs={[]}
                 colDefinitions={colDefinitions}
                 colDefinitions={colDefinitions}

+ 2 - 2
client/src/pages/databases/collections/search/SearchGlobalParams.tsx

@@ -1,4 +1,4 @@
-import { useCallback, ChangeEvent } from 'react';
+import { useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { Slider } from '@mui/material';
 import { Slider } from '@mui/material';
 import CustomInput from '@/components/customInput/CustomInput';
 import CustomInput from '@/components/customInput/CustomInput';
@@ -234,7 +234,7 @@ const SearchGlobalParams = (props: SearchGlobalProps) => {
                     }}
                     }}
                     aria-labelledby="weight-slider"
                     aria-labelledby="weight-slider"
                     valueLabelDisplay="auto"
                     valueLabelDisplay="auto"
-                    size='small'
+                    size="small"
                     step={0.1}
                     step={0.1}
                     min={0}
                     min={0}
                     max={1}
                     max={1}

+ 73 - 0
client/src/pages/databases/collections/search/Styles.ts

@@ -195,4 +195,77 @@ export const getQueryStyles = makeStyles((theme: Theme) => ({
     height: 56,
     height: 56,
     width: 80,
     width: 80,
   },
   },
+
+  explorer: {
+    display: 'flex',
+    flexDirection: 'column',
+    height: '100%',
+    position: 'relative',
+    flexGrow: 1,
+  },
+  closeBtn: {
+    position: 'absolute',
+    top: 8,
+    left: 8,
+    zIndex: 1,
+    padding: '4px 8px',
+  },
+  resetBtn: {
+    position: 'absolute',
+    top: 8,
+    left: 90,
+    zIndex: 1,
+    padding: '4px 8px',
+  },
+}));
+
+export const getDataExplorerStyle = makeStyles((theme: Theme) => ({
+  root: {
+    '& .nodeInfo': {
+      display: 'flex',
+      flexDirection: 'column',
+      padding: '8px',
+      backgroundColor: theme.palette.background.paper,
+      border: `1px solid ${theme.palette.divider}`,
+      borderRadius: 8,
+      boxShadow: '0px 6px 30px rgba(0, 0, 0, 0.1)',
+      maxWidth: 240,
+      overflow: 'auto',
+      zIndex: 1,
+      '& .wrapper': {
+        display: 'flex',
+        flexDirection: 'row',
+        flexWrap: 'wrap',
+        gap: 4,
+    
+        justifyContent: 'center',
+        '& img': {
+          display: 'inline-block',
+          maxWidth: 120,
+          maxHeight: 120,
+          objectFit: 'contain',
+        },
+      },
+      '& .tip': {
+        color: theme.palette.text.secondary,
+        fontSize: 12,
+        textAlign: 'center',
+      }
+    },
+  },
+  selectedNodes: {
+    position: 'absolute',
+    display: 'flex',
+    flexDirection: 'column',
+    top: 8,
+    right: 8,
+    borderRadius: 8,
+    gap: 8,
+    maxHeight: '100%',
+    overflow: 'auto',
+    backgroundColor: theme.palette.background.paper,
+    '& .nodeInfo': {
+      boxShadow: 'none',
+    },
+  },
 }));
 }));

+ 23 - 1
client/src/pages/databases/types.ts

@@ -1,4 +1,4 @@
-import { FieldObject, CollectionObject, RerankerObj } from '@server/types';
+import { FieldObject, CollectionObject } from '@server/types';
 
 
 export type SearchSingleParams = {
 export type SearchSingleParams = {
   anns_field: string;
   anns_field: string;
@@ -28,10 +28,32 @@ export type SearchResultView = {
   distance: number;
   distance: number;
 };
 };
 
 
+export type GraphNode = {
+  id: string;
+  data: any;
+  x?: number;
+  y?: number;
+  searchIds: string[];
+  color: number;
+  nodeY?: number;
+  nodeX?: number;
+}; // Add optional x, y for SimulationNodeDatum
+export type GraphLink = {
+  source: string;
+  target: string;
+  score: number;
+};
+
+export type GraphData = {
+  nodes: GraphNode[];
+  links: GraphLink[];
+};
+
 export type SearchParams = {
 export type SearchParams = {
   collection: CollectionObject;
   collection: CollectionObject;
   searchParams: SearchSingleParams[];
   searchParams: SearchSingleParams[];
   globalParams: GlobalParams;
   globalParams: GlobalParams;
   searchResult: SearchResultView[] | null;
   searchResult: SearchResultView[] | null;
+  graphData: GraphData;
   searchLatency: number;
   searchLatency: number;
 };
 };

+ 1 - 1
client/yarn.lock

@@ -2457,7 +2457,7 @@ d3-zoom@3:
     d3-selection "2 - 3"
     d3-selection "2 - 3"
     d3-transition "2 - 3"
     d3-transition "2 - 3"
 
 
-d3@^7.8.5:
+d3@^7.9.0:
   version "7.9.0"
   version "7.9.0"
   resolved "https://registry.yarnpkg.com/d3/-/d3-7.9.0.tgz#579e7acb3d749caf8860bd1741ae8d371070cd5d"
   resolved "https://registry.yarnpkg.com/d3/-/d3-7.9.0.tgz#579e7acb3d749caf8860bd1741ae8d371070cd5d"
   integrity sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==
   integrity sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==