<script setup lang="ts">
|
import type { AiKnowledgeSegmentApi } from '#/api/ai/knowledge/segment';
|
|
import { onMounted, reactive, ref } from 'vue';
|
import { useRoute, useRouter } from 'vue-router';
|
|
import { Page } from '@vben/common-ui';
|
import { IconifyIcon } from '@vben/icons';
|
|
import {
|
Button,
|
Card,
|
Empty,
|
InputNumber,
|
message,
|
Textarea,
|
} from 'ant-design-vue';
|
|
import { getKnowledge } from '#/api/ai/knowledge/knowledge';
|
import { searchKnowledgeSegment } from '#/api/ai/knowledge/segment';
|
|
type RetrievalSegment = AiKnowledgeSegmentApi.KnowledgeSegment & {
|
expanded?: boolean;
|
};
|
|
/** 知识库文档召回测试 */
|
defineOptions({ name: 'KnowledgeDocumentRetrieval' });
|
|
const route = useRoute();
|
const router = useRouter();
|
|
const loading = ref(false);
|
const segments = ref<RetrievalSegment[]>([]);
|
const queryParams = reactive<{
|
id: number | undefined;
|
content: string;
|
topK: number;
|
similarityThreshold: number;
|
}>({
|
id: undefined,
|
content: '',
|
topK: 10,
|
similarityThreshold: 0.5,
|
});
|
|
/** 执行召回测试 */
|
async function getRetrievalResult() {
|
if (!queryParams.content) {
|
message.warning('请输入查询文本');
|
return;
|
}
|
if (queryParams.id == null) return;
|
|
loading.value = true;
|
segments.value = [];
|
|
try {
|
const data = await searchKnowledgeSegment({
|
knowledgeId: queryParams.id,
|
content: queryParams.content,
|
topK: queryParams.topK,
|
similarityThreshold: queryParams.similarityThreshold,
|
});
|
segments.value = (data || []) as RetrievalSegment[];
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
/** 切换段落展开状态 */
|
function toggleExpand(segment: RetrievalSegment) {
|
segment.expanded = !segment.expanded;
|
}
|
|
/** 获取相似度分数的样式类名 */
|
function getScoreClass(score: number) {
|
if (score > 0.8) return 'bg-green-50 text-green-600';
|
if (score >= 0.6) return 'bg-yellow-50 text-yellow-600';
|
return 'bg-gray-100 text-gray-400';
|
}
|
|
/** 获取知识库配置信息 */
|
async function getKnowledgeInfo(id: number) {
|
try {
|
const knowledge = await getKnowledge(id);
|
if (knowledge) {
|
queryParams.topK = knowledge.topK || queryParams.topK;
|
queryParams.similarityThreshold =
|
knowledge.similarityThreshold || queryParams.similarityThreshold;
|
}
|
} catch {
|
// 获取知识库配置失败,使用默认值
|
}
|
}
|
|
/** 初始化 */
|
onMounted(() => {
|
if (!route.query.id) {
|
message.error('知识库 ID 不存在,无法进行召回测试');
|
router.back();
|
return;
|
}
|
const id = Number(route.query.id);
|
queryParams.id = id;
|
getKnowledgeInfo(id);
|
});
|
</script>
|
<template>
|
<Page auto-content-height>
|
<div class="mb-2">
|
<a-button type="default" @click="router.back()">
|
返回上一页
|
</a-button>
|
</div>
|
<div class="flex w-full gap-4">
|
<Card class="w-3/4 flex-1">
|
<div class="mb-15">
|
<h3 class="m-2 text-lg font-semibold leading-none tracking-tight">
|
召回测试
|
</h3>
|
<div class="m-2 text-sm text-gray-500">
|
根据给定的查询文本测试召回效果。
|
</div>
|
</div>
|
<div>
|
<div class="relative m-2">
|
<Textarea
|
v-model:value="queryParams.content"
|
:rows="8"
|
placeholder="请输入文本"
|
/>
|
<div class="absolute bottom-2 right-2 text-sm text-gray-400">
|
{{ queryParams.content?.length || 0 }} 字符
|
</div>
|
</div>
|
<div class="m-2 flex items-center">
|
<span class="w-16 text-gray-500">topK:</span>
|
<InputNumber
|
v-model:value="queryParams.topK"
|
:min="1"
|
:max="20"
|
class="w-full"
|
/>
|
</div>
|
<div class="m-2 flex items-center">
|
<span class="w-16 text-gray-500">相似度:</span>
|
<InputNumber
|
v-model:value="queryParams.similarityThreshold"
|
class="w-full"
|
:min="0"
|
:max="1"
|
:precision="2"
|
:step="0.01"
|
/>
|
</div>
|
<div class="flex justify-end">
|
<Button
|
type="primary"
|
@click="getRetrievalResult"
|
:loading="loading"
|
>
|
测试
|
</Button>
|
</div>
|
</div>
|
</Card>
|
<Card class="min-w-300 flex-1">
|
<!-- 加载中状态 -->
|
<template v-if="loading">
|
<div class="flex h-72 items-center justify-center">
|
<Empty description="正在检索中..." />
|
</div>
|
</template>
|
|
<!-- 有段落 -->
|
<template v-else-if="segments.length > 0">
|
<div class="mb-15 font-bold">{{ segments.length }} 个召回段落</div>
|
<div>
|
<div
|
v-for="(segment, index) in segments"
|
:key="index"
|
class="mt-2 rounded border border-solid border-gray-200 px-2 py-2"
|
>
|
<div
|
class="mb-2 flex items-center justify-between gap-8 text-sm text-gray-500"
|
>
|
<span>
|
分段({{ segment.id }}) · {{ segment.contentLength }} 字符数 ·
|
{{ segment.tokens }} Token
|
</span>
|
<span
|
class="whitespace-nowrap rounded-full px-2 py-1 text-sm"
|
:class="getScoreClass(segment.score ?? 0)"
|
>
|
score: {{ segment.score?.toFixed(4) }}
|
</span>
|
</div>
|
<div
|
class="mb-2 overflow-hidden whitespace-pre-wrap rounded bg-gray-50 text-sm transition-all duration-100"
|
:class="{
|
'line-clamp-2 max-h-40': !segment.expanded,
|
'max-h-[1500px]': segment.expanded,
|
}"
|
>
|
{{ segment.content }}
|
</div>
|
<div class="flex items-center justify-between gap-8">
|
<div class="flex items-center gap-1 text-sm text-gray-500">
|
<IconifyIcon icon="lucide:file-text" />
|
<span>{{ segment.documentName || '未知文档' }}</span>
|
</div>
|
<Button size="small" @click="toggleExpand(segment)">
|
{{ segment.expanded ? '收起' : '展开' }}
|
<IconifyIcon
|
:icon="
|
segment.expanded
|
? 'lucide:chevron-up'
|
: 'lucide:chevron-down'
|
"
|
/>
|
</Button>
|
</div>
|
</div>
|
</div>
|
</template>
|
|
<!-- 无召回结果 -->
|
<template v-else>
|
<div class="flex h-72 items-center justify-center">
|
<Empty description="暂无召回结果" />
|
</div>
|
</template>
|
</Card>
|
</div>
|
</Page>
|
</template>
|