feat: 完善Dify知识库管理召回测试模块,优化知识库上传文件时的分段配置设置

This commit is contained in:
PingChuan
2025-12-03 12:03:09 +08:00
parent 0f49426a2e
commit a13f3b3635
29 changed files with 4016 additions and 1880 deletions
@@ -1,202 +1,306 @@
import { useState } from 'react';
import {
Input,
Button,
Card,
Select,
Slider,
Table,
Tag,
Empty,
Spin,
message,
} from 'antd';
import { FileSearchOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { SearchOutlined, FileSearchOutlined } from '@ant-design/icons';
import { Button, Tag, Input, Slider, Spin, Select, Flex } from 'antd';
import type { RetrieveRecord } from '~/api/dify-dataset/type';
import { retrieveDataset } from '~/api/dify-dataset/api/segmentApi';
import { useRetrieveTest } from '~/hooks/dify-dataset-manager/retrieve-test';
import type { RetrieveTestProps } from '~/types/dify-dataset-manager/retrieve-test';
interface RetrieveTestProps {
datasetId: string;
// 颜色常量
const colors = {
bgContainer: '#fff',
bgLayout: '#f5f5f5',
bgElevated: '#fafafa',
border: '#e8e8e8',
text: '#262626',
textSecondary: '#8c8c8c',
textTertiary: '#bfbfbf',
textQuaternary: '#d9d9d9',
fillTertiary: '#f0f0f0',
};
/**
* 检索结果项组件
*/
function ResultItem({ record, index }: { record: RetrieveRecord; index: number }) {
const scorePercent = (record.score * 100).toFixed(1);
const scoreColor = record.score > 0.8 ? '#52c41a' : record.score > 0.5 ? '#faad14' : '#666';
return (
<Flex
vertical
gap={12}
style={{
padding: 16,
background: colors.bgContainer,
borderRadius: 8,
border: `1px solid ${colors.border}`,
}}
>
<Flex justify="space-between" align="center">
<Flex gap={8} align="center">
<Tag style={{ background: scoreColor, color: '#fff', border: 'none' }}>
{scorePercent}%
</Tag>
<span style={{ color: colors.textSecondary, fontSize: 12 }}>
#{index + 1} · {record.segment.word_count} · {record.segment.hit_count}
</span>
</Flex>
{record.segment.document && (
<span style={{ color: colors.textTertiary, fontSize: 12 }}>
: {record.segment.document.name}
</span>
)}
</Flex>
<div style={{
color: colors.text,
fontSize: 14,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
}}>
{record.segment.content.length > 500
? record.segment.content.substring(0, 500) + '...'
: record.segment.content}
</div>
{record.segment.answer && (
<Flex
vertical
gap={4}
style={{
padding: 12,
background: colors.fillTertiary,
borderRadius: 6,
}}
>
<span style={{ color: colors.textSecondary, fontSize: 12 }}>
:
</span>
<span style={{ color: colors.text, fontSize: 14 }}>
{record.segment.answer.length > 200
? record.segment.answer.substring(0, 200) + '...'
: record.segment.answer}
</span>
</Flex>
)}
</Flex>
);
}
/**
* 召回测试组件
* 用于测试知识库的检索效果
*/
export default function RetrieveTest({ datasetId }: RetrieveTestProps) {
const [searchQuery, setSearchQuery] = useState('');
const [retrieveResults, setRetrieveResults] = useState<RetrieveRecord[]>([]);
const [retrieving, setRetrieving] = useState(false);
const [searchMethod, setSearchMethod] = useState<string>('hybrid_search');
const [topK, setTopK] = useState<number>(5);
const {
searchQuery,
setSearchQuery,
retrieveResults,
retrieving,
searchMethod,
setSearchMethod,
topK,
setTopK,
handleRetrieve,
} = useRetrieveTest(datasetId);
/**
* 执行检索
*/
const handleRetrieve = async () => {
if (!searchQuery.trim()) {
message.warning('请输入检索关键词');
return;
}
if (!datasetId) {
message.warning('知识库ID不存在');
return;
}
setRetrieving(true);
try {
const response = await retrieveDataset(datasetId, searchQuery, {
search_method: searchMethod as any,
top_k: topK,
});
setRetrieveResults(response.records || []);
if (response.records?.length === 0) {
message.info('未找到匹配的结果');
}
} catch (err: any) {
console.error('检索失败:', err);
message.error(err.message || '检索失败');
} finally {
setRetrieving(false);
}
};
// 检索结果列定义
const columns: ColumnsType<RetrieveRecord> = [
{
title: '相关度',
dataIndex: 'score',
key: 'score',
width: 100,
render: (score: number) => (
<Tag color={score > 0.8 ? 'green' : score > 0.5 ? 'orange' : 'default'}>
{(score * 100).toFixed(1)}%
</Tag>
),
},
{
title: '内容',
key: 'content',
render: (_, record) => (
<div className="retrieve-result-content">
<div className="content-text">
{record.segment.content.length > 300
? record.segment.content.substring(0, 300) + '...'
: record.segment.content}
</div>
{record.segment.answer && (
<div className="answer-text">
<strong></strong>
{record.segment.answer.length > 150
? record.segment.answer.substring(0, 150) + '...'
: record.segment.answer}
</div>
)}
</div>
),
},
{
title: '字数',
key: 'word_count',
width: 80,
render: (_, record) => record.segment.word_count,
},
{
title: '命中次数',
key: 'hit_count',
width: 100,
render: (_, record) => record.segment.hit_count,
},
// 检索方式选项(只有3种)
const searchMethodOptions = [
{ label: '向量检索', value: 'semantic_search' },
{ label: '全文检索', value: 'full_text_search' },
{ label: '混合检索', value: 'hybrid_search' },
];
return (
<div className="retrieve-test-page">
{/* 页面标题 */}
<div className="page-header">
<h1></h1>
<p className="page-description">
</p>
</div>
<Flex
style={{
height: '100%',
minHeight: 'calc(100vh - 120px)',
}}
>
{/* 左侧面板 - 输入区域 */}
<Flex
vertical
gap={16}
style={{
width: 400,
minWidth: 400,
padding: 20,
background: colors.bgLayout,
borderRight: `1px solid ${colors.border}`,
}}
>
{/* 标题 */}
<Flex vertical gap={4}>
<h2 style={{
margin: 0,
fontSize: 18,
fontWeight: 600,
color: colors.text,
}}>
</h2>
<span style={{
fontSize: 13,
color: colors.textSecondary,
}}>
</span>
</Flex>
{/* 检索设置 */}
<Card className="retrieve-settings" size="small">
<div className="search-row">
<Input
placeholder="输入检索关键词..."
prefix={<FileSearchOutlined />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onPressEnter={handleRetrieve}
className="search-input"
/>
<Button
type="primary"
onClick={handleRetrieve}
loading={retrieving}
>
</Button>
</div>
<div className="options-row">
<div className="option-item">
<span className="option-label"></span>
{/* 查询输入区 */}
<Flex vertical gap={8}>
<Flex justify="space-between" align="center">
<span style={{
fontSize: 13,
color: colors.text,
fontWeight: 500,
}}>
</span>
<Select
value={searchMethod}
onChange={setSearchMethod}
style={{ width: 140 }}
options={[
{ value: 'keyword_search', label: '关键词搜索' },
{ value: 'semantic_search', label: '语义搜索' },
{ value: 'full_text_search', label: '全文搜索' },
{ value: 'hybrid_search', label: '混合搜索' },
]}
onChange={(value) => setSearchMethod(value as any)}
options={searchMethodOptions}
style={{ width: 130 }}
size="small"
/>
</div>
<div className="option-item">
<span className="option-label"> (Top K)</span>
</Flex>
<Input.TextArea
placeholder="请输入文本,建议使用简短的陈述句。"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
handleRetrieve();
}
}}
autoSize={{ minRows: 6, maxRows: 12 }}
style={{
background: colors.bgContainer,
resize: 'none',
}}
/>
<Flex justify="space-between" align="center">
<span style={{
fontSize: 12,
color: colors.textTertiary,
}}>
{searchQuery.length} / 200
</span>
<Button
type="primary"
icon={<SearchOutlined />}
onClick={handleRetrieve}
loading={retrieving}
>
</Button>
</Flex>
</Flex>
{/* 检索设置 */}
<Flex vertical gap={12}>
<span style={{
fontSize: 13,
color: colors.text,
fontWeight: 500,
}}>
</span>
<Flex align="center" gap={12}>
<span style={{
fontSize: 13,
color: colors.textSecondary,
whiteSpace: 'nowrap',
}}>
(Top K):
</span>
<Slider
value={topK}
onChange={setTopK}
min={1}
max={20}
style={{ width: 120 }}
style={{ flex: 1 }}
/>
<span className="option-value">{topK}</span>
</div>
</div>
</Card>
<span style={{
fontSize: 13,
color: colors.text,
minWidth: 24,
textAlign: 'right',
}}>
{topK}
</span>
</Flex>
</Flex>
</Flex>
{/* 检索结果 */}
<div className="retrieve-results">
{/* 右侧面板 - 结果展示 */}
<Flex
vertical
flex={1}
gap={16}
style={{
padding: 20,
background: colors.bgElevated,
overflow: 'auto',
}}
>
{retrieving ? (
<div className="loading-state">
<Flex
flex={1}
align="center"
justify="center"
vertical
gap={12}
>
<Spin size="large" />
<div className="loading-text">...</div>
</div>
<span style={{ color: colors.textSecondary }}>
...
</span>
</Flex>
) : retrieveResults.length === 0 ? (
<Empty
description="请输入关键词进行检索"
className="empty-state"
/>
<Flex
flex={1}
align="center"
justify="center"
vertical
gap={12}
>
<FileSearchOutlined style={{
fontSize: 48,
color: colors.textQuaternary,
}} />
<span style={{ color: colors.textTertiary }}>
</span>
</Flex>
) : (
<>
<div className="results-header">
<span> {retrieveResults.length} </span>
</div>
<Table
columns={columns}
dataSource={retrieveResults}
rowKey={(record) => record.segment.id}
pagination={false}
size="small"
/>
<Flex justify="space-between" align="center">
<span style={{
fontSize: 14,
color: colors.text,
fontWeight: 500,
}}>
</span>
<span style={{
fontSize: 13,
color: colors.textSecondary,
}}>
{retrieveResults.length}
</span>
</Flex>
<Flex vertical gap={12}>
{retrieveResults.map((record, index) => (
<ResultItem
key={record.segment.id}
record={record}
index={index}
/>
))}
</Flex>
</>
)}
</div>
</div>
</Flex>
</Flex>
);
}