feat: 收敛知识库检索调度与评分语义
- 固定 rag.engine 与 Milvus 配置,补齐启动期检索基础设施校验 - 支持调用方配置 retrievalMode,并统一知识库检索入口与结果来源展示 - 修正关键词检索 knowledgeId 过滤、混合检索评分归一化与本地 ES 默认配置
This commit is contained in:
@@ -49,6 +49,12 @@
|
||||
"publicChatCopySuccess": "Copied",
|
||||
"publicChatCopyFail": "Copy failed",
|
||||
"basicInfo": "Basic Info",
|
||||
"knowledgeRetrievalMode": "Retrieval Mode",
|
||||
"retrievalModes": {
|
||||
"hybrid": "Hybrid Retrieval",
|
||||
"vector": "Vector Retrieval",
|
||||
"keyword": "Keyword Retrieval"
|
||||
},
|
||||
"modal": {
|
||||
"createDescription": "Set the assistant appearance, identity and base availability.",
|
||||
"editDescription": "Update the assistant presentation and base availability.",
|
||||
|
||||
@@ -159,7 +159,13 @@
|
||||
"documentPreview": "DocumentPreview",
|
||||
"total": "Total",
|
||||
"segments": "Segments",
|
||||
"similarityScore": "SimilarityScore",
|
||||
"similarityScore": "Relevance",
|
||||
"searchFailed": "Search failed. Please try again later.",
|
||||
"hitSources": {
|
||||
"vector": "Vector Hit",
|
||||
"keyword": "Keyword Hit",
|
||||
"both": "Dual Hit"
|
||||
},
|
||||
"alibabaCloud": "AlibabaCloud",
|
||||
"tencentCloud": "tencentCloud",
|
||||
"vectorEmbedModelTips": "After successful vector data, it is not allowed to modify the vector model",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"button": {
|
||||
"save": "Save Configuration"
|
||||
},
|
||||
"engineHint": "The keyword search engine is controlled by platform-level configuration and is no longer configured per knowledge base.",
|
||||
"message": {
|
||||
"saveSuccess": "Configuration saved successfully",
|
||||
"saveFailed": "Configuration saved failed"
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
"publicChatCopySuccess": "复制成功",
|
||||
"publicChatCopyFail": "复制失败",
|
||||
"basicInfo": "基础信息",
|
||||
"knowledgeRetrievalMode": "检索方式",
|
||||
"retrievalModes": {
|
||||
"hybrid": "混合检索",
|
||||
"vector": "向量检索",
|
||||
"keyword": "关键词检索"
|
||||
},
|
||||
"modal": {
|
||||
"createDescription": "设置助手的外观、标识和基础发布状态。",
|
||||
"editDescription": "更新助手的展示信息与基础状态。",
|
||||
|
||||
@@ -159,7 +159,13 @@
|
||||
"documentPreview": "文档预览",
|
||||
"total": "共",
|
||||
"segments": "个分段",
|
||||
"similarityScore": "相似度",
|
||||
"similarityScore": "相关度",
|
||||
"searchFailed": "检索失败,请稍后重试",
|
||||
"hitSources": {
|
||||
"vector": "语义命中",
|
||||
"keyword": "关键词命中",
|
||||
"both": "双路命中"
|
||||
},
|
||||
"alibabaCloud": "阿里云",
|
||||
"tencentCloud": "腾讯云",
|
||||
"vectorEmbedModelTips": "成功向量数据之后不允许修改向量模型",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"button": {
|
||||
"save": "保存配置"
|
||||
},
|
||||
"engineHint": "关键词检索引擎由平台全局配置决定,知识库侧不再单独配置。",
|
||||
"message": {
|
||||
"saveSuccess": "配置保存成功",
|
||||
"saveFailed": "配置保存失败"
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import {
|
||||
ElAlert,
|
||||
ElAvatar,
|
||||
ElButton,
|
||||
ElCol,
|
||||
ElCollapse,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
ElOption,
|
||||
ElRow,
|
||||
ElSelect,
|
||||
@@ -63,6 +65,13 @@ interface ApiKeyOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
type RetrievalMode = 'HYBRID' | 'KEYWORD' | 'VECTOR';
|
||||
|
||||
interface BotKnowledgeBindingItem {
|
||||
knowledgeId: string;
|
||||
retrievalMode: RetrievalMode;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
bot?: BotInfo;
|
||||
hasSavePermission?: boolean;
|
||||
@@ -70,6 +79,7 @@ const props = defineProps<{
|
||||
const botStore = useBotStore();
|
||||
const route = useRoute();
|
||||
const botId = ref<string>((route.params.id as string) || '');
|
||||
const fallbackAvatarUrl = `${import.meta.env.BASE_URL || '/'}favicon.svg`;
|
||||
const options = ref<AiLlm[]>([]);
|
||||
const selectedId = ref<string>('');
|
||||
const llmConfig = ref({
|
||||
@@ -170,11 +180,11 @@ const iframeCodeHighlighted = computed(() => {
|
||||
}
|
||||
const escaped = escapeHtml(iframeCode.value);
|
||||
return escaped
|
||||
.replace(
|
||||
.replaceAll(
|
||||
/(<\/?)([a-zA-Z][\w-]*)/g,
|
||||
'$1<span class="hljs-name">$2</span>',
|
||||
)
|
||||
.replace(
|
||||
.replaceAll(
|
||||
/([:@a-zA-Z_][\w:-]*)=(".*?")/g,
|
||||
'<span class="hljs-attr">$1</span>=<span class="hljs-string">$2</span>',
|
||||
);
|
||||
@@ -202,6 +212,32 @@ const mcpToolData = ref<any>([]);
|
||||
const workflowData = ref<any[]>([]);
|
||||
const knowledgeData = ref<any[]>([]);
|
||||
const pluginToolData = ref<any[]>([]);
|
||||
const retrievalModeOptions = computed(() => [
|
||||
{ label: $t('bot.retrievalModes.hybrid'), value: 'HYBRID' },
|
||||
{ label: $t('bot.retrievalModes.vector'), value: 'VECTOR' },
|
||||
{ label: $t('bot.retrievalModes.keyword'), value: 'KEYWORD' },
|
||||
]);
|
||||
const normalizeRetrievalMode = (value?: string): RetrievalMode => {
|
||||
if (value === 'VECTOR' || value === 'KEYWORD' || value === 'HYBRID') {
|
||||
return value;
|
||||
}
|
||||
return 'HYBRID';
|
||||
};
|
||||
const buildKnowledgeBindingsPayload = (
|
||||
selectedIds: Array<number | string>,
|
||||
): BotKnowledgeBindingItem[] => {
|
||||
return [...new Set((selectedIds || []).map(String).filter(Boolean))].map(
|
||||
(knowledgeId) => {
|
||||
const existing = knowledgeData.value.find(
|
||||
(item) => String(item.id) === knowledgeId,
|
||||
);
|
||||
return {
|
||||
knowledgeId,
|
||||
retrievalMode: normalizeRetrievalMode(existing?.retrievalMode),
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
const getAiBotPluginToolList = async () => {
|
||||
api
|
||||
.post('/api/v1/pluginItem/tool/list', { botId: botId.value })
|
||||
@@ -226,6 +262,7 @@ const getAiBotKnowledgeList = async () => {
|
||||
knowledgeData.value = res.data.map((item: any) => {
|
||||
return {
|
||||
recordId: item.id,
|
||||
retrievalMode: normalizeRetrievalMode(item.options?.retrievalMode),
|
||||
...item.knowledge,
|
||||
};
|
||||
});
|
||||
@@ -419,7 +456,7 @@ const copyText = async (value: string) => {
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.pointerEvents = 'none';
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
document.body.append(textarea);
|
||||
|
||||
const selection = document.getSelection();
|
||||
const previousRange =
|
||||
@@ -430,7 +467,7 @@ const copyText = async (value: string) => {
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
const copied = document.execCommand('copy');
|
||||
|
||||
document.body.removeChild(textarea);
|
||||
textarea.remove();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
if (previousRange) {
|
||||
@@ -543,7 +580,7 @@ const confirmUpdateAiBotKnowledge = (data: any) => {
|
||||
api
|
||||
.post('/api/v1/botKnowledge/updateBotKnowledgeIds', {
|
||||
botId: botId.value,
|
||||
knowledgeIds: data,
|
||||
knowledgeBindings: buildKnowledgeBindingsPayload(data),
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
@@ -555,6 +592,31 @@ const confirmUpdateAiBotKnowledge = (data: any) => {
|
||||
});
|
||||
};
|
||||
|
||||
const updateKnowledgeBindings = (bindings: BotKnowledgeBindingItem[]) => {
|
||||
return api.post('/api/v1/botKnowledge/updateBotKnowledgeIds', {
|
||||
botId: botId.value,
|
||||
knowledgeBindings: bindings,
|
||||
});
|
||||
};
|
||||
|
||||
const handleKnowledgeRetrievalModeChange = async (
|
||||
item: any,
|
||||
value: RetrievalMode | string,
|
||||
) => {
|
||||
item.retrievalMode = normalizeRetrievalMode(value);
|
||||
const bindings = knowledgeData.value.map((knowledgeItem: any) => ({
|
||||
knowledgeId: String(knowledgeItem.id),
|
||||
retrievalMode: normalizeRetrievalMode(knowledgeItem.retrievalMode),
|
||||
}));
|
||||
const res = await updateKnowledgeBindings(bindings);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.updateOkMessage'));
|
||||
getAiBotKnowledgeList();
|
||||
return;
|
||||
}
|
||||
ElMessage.error(res.message || $t('message.updateFailMessage'));
|
||||
};
|
||||
|
||||
const confirmUpdateAiBotWorkflow = (data: any) => {
|
||||
api
|
||||
.post('/api/v1/botWorkflow/updateBotWorkflowIds', {
|
||||
@@ -616,7 +678,20 @@ const deleteBotMcpTool = (item: any) => {
|
||||
});
|
||||
};
|
||||
|
||||
const deleteKnowledge = (item: any) => {
|
||||
const deleteKnowledge = async (item: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
$t('message.deleteAlert'),
|
||||
$t('message.noticeTitle'),
|
||||
{
|
||||
confirmButtonText: $t('button.confirm'),
|
||||
cancelButtonText: $t('button.cancel'),
|
||||
type: 'warning',
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
api
|
||||
.post('/api/v1/botKnowledge/remove', {
|
||||
id: item.recordId,
|
||||
@@ -840,10 +915,10 @@ const handleBasicInfoChange = async (
|
||||
<div class="bot-avatar-panel">
|
||||
<span class="bot-avatar-label">{{ $t('common.avatar') }}</span>
|
||||
<div
|
||||
:class="[
|
||||
'bot-avatar-upload-wrap',
|
||||
!hasSavePermission || updatingBotIcon ? 'is-disabled' : '',
|
||||
]"
|
||||
class="bot-avatar-upload-wrap"
|
||||
:class="
|
||||
!hasSavePermission || updatingBotIcon ? 'is-disabled' : ''
|
||||
"
|
||||
>
|
||||
<UploadAvatar
|
||||
v-if="botInfo"
|
||||
@@ -1105,7 +1180,53 @@ const handleBasicInfoChange = async (
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<CollapseViewItem :data="knowledgeData" @delete="deleteKnowledge" />
|
||||
<div class="knowledge-binding-list">
|
||||
<div
|
||||
v-for="item in knowledgeData"
|
||||
:key="item.recordId"
|
||||
class="knowledge-binding-item"
|
||||
>
|
||||
<div class="knowledge-binding-main">
|
||||
<ElAvatar
|
||||
:src="item.icon || fallbackAvatarUrl"
|
||||
shape="circle"
|
||||
/>
|
||||
<div class="knowledge-binding-content">
|
||||
<div class="knowledge-binding-title">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="knowledge-binding-description">
|
||||
{{ item.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="knowledge-binding-actions" @click.stop>
|
||||
<ElSelect
|
||||
:model-value="item.retrievalMode"
|
||||
class="knowledge-binding-select"
|
||||
size="small"
|
||||
@change="
|
||||
(value) => handleKnowledgeRetrievalModeChange(item, value)
|
||||
"
|
||||
>
|
||||
<ElOption
|
||||
v-for="option in retrievalModeOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElIcon
|
||||
class="knowledge-binding-delete"
|
||||
color="var(--el-color-danger)"
|
||||
size="20px"
|
||||
@click="deleteKnowledge(item)"
|
||||
>
|
||||
<Delete />
|
||||
</ElIcon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCollapseItem>
|
||||
<ElCollapseItem>
|
||||
<template #title>
|
||||
@@ -1779,4 +1900,64 @@ const handleBasicInfoChange = async (
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.knowledge-binding-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
background-color: var(--bot-collapse-itme-back);
|
||||
}
|
||||
|
||||
.knowledge-binding-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background-color: hsl(var(--background));
|
||||
}
|
||||
|
||||
.knowledge-binding-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.knowledge-binding-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.knowledge-binding-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.knowledge-binding-description {
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.knowledge-binding-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.knowledge-binding-select {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.knowledge-binding-delete {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from 'element-plus';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { InfoFilled } from '@element-plus/icons-vue';
|
||||
import {
|
||||
@@ -80,29 +80,6 @@ watch(
|
||||
|
||||
const embeddingLlmList = ref<any>([]);
|
||||
const rerankerLlmList = ref<any>([]);
|
||||
const vecotrDatabaseList = ref<any>([
|
||||
{ value: 'milvus', label: 'Milvus' },
|
||||
{ value: 'redis', label: 'Redis' },
|
||||
{ value: 'opensearch', label: 'OpenSearch' },
|
||||
{ value: 'elasticsearch', label: 'ElasticSearch' },
|
||||
{ value: 'aliyun', label: $t('documentCollection.alibabaCloud') },
|
||||
{ value: 'qcloud', label: $t('documentCollection.tencentCloud') },
|
||||
]);
|
||||
|
||||
const milvusVectorStoreConfigPlaceholder =
|
||||
'uri=http://127.0.0.1:19530\n' +
|
||||
'databaseName=default\n' +
|
||||
'token=\n' +
|
||||
'username=\n' +
|
||||
'password=\n' +
|
||||
'autoCreateCollection=true';
|
||||
|
||||
const vectorStoreConfigPlaceholder = computed(() => {
|
||||
return entity.value?.vectorStoreType === 'milvus'
|
||||
? milvusVectorStoreConfigPlaceholder
|
||||
: '';
|
||||
});
|
||||
|
||||
const getEmbeddingLlmListData = async () => {
|
||||
try {
|
||||
const url = `/api/v1/documentCollection/modelList?modelType=embeddingModel`;
|
||||
@@ -146,15 +123,6 @@ const rules = ref({
|
||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||
],
|
||||
title: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
|
||||
vectorStoreType: [
|
||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||
],
|
||||
vectorStoreCollection: [
|
||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||
],
|
||||
vectorStoreConfig: [
|
||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||
],
|
||||
vectorEmbedModelId: [
|
||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||
],
|
||||
@@ -239,50 +207,6 @@ async function save() {
|
||||
:placeholder="$t('documentCollection.placeholder.description')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<!-- <ElFormItem
|
||||
prop="vectorStoreEnable"
|
||||
:label="$t('documentCollection.vectorStoreEnable')"
|
||||
>
|
||||
<ElSwitch v-model="entity.vectorStoreEnable" />
|
||||
</ElFormItem>-->
|
||||
<ElFormItem
|
||||
prop="vectorStoreType"
|
||||
:label="$t('documentCollection.vectorStoreType')"
|
||||
>
|
||||
<ElSelect
|
||||
v-model="entity.vectorStoreType"
|
||||
:placeholder="$t('documentCollection.placeholder.vectorStoreType')"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in vecotrDatabaseList"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value || ''"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
prop="vectorStoreCollection"
|
||||
:label="$t('documentCollection.vectorStoreCollection')"
|
||||
>
|
||||
<ElInput
|
||||
v-model.trim="entity.vectorStoreCollection"
|
||||
:placeholder="
|
||||
$t('documentCollection.placeholder.vectorStoreCollection')
|
||||
"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
prop="vectorStoreConfig"
|
||||
:label="$t('documentCollection.vectorStoreConfig')"
|
||||
>
|
||||
<ElInput
|
||||
v-model.trim="entity.vectorStoreConfig"
|
||||
:rows="4"
|
||||
type="textarea"
|
||||
:placeholder="vectorStoreConfigPlaceholder"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="vectorEmbedModelId">
|
||||
<template #label>
|
||||
<span style="display: flex; align-items: center">
|
||||
@@ -373,12 +297,6 @@ async function save() {
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
prop="searchEngineEnable"
|
||||
:label="$t('documentCollection.searchEngineEnable')"
|
||||
>
|
||||
<ElSwitch v-model="entity.searchEngineEnable" />
|
||||
</ElFormItem>
|
||||
<ElFormItem style="margin-top: 20px; text-align: right">
|
||||
<ElButton
|
||||
type="primary"
|
||||
|
||||
@@ -60,15 +60,6 @@ onMounted(async () => {
|
||||
const saveForm = ref<FormInstance>();
|
||||
const dialogVisible = ref(false);
|
||||
const isAdd = ref(true);
|
||||
const vecotrDatabaseList = ref<any>([
|
||||
{ value: 'milvus', label: 'Milvus' },
|
||||
{ value: 'redis', label: 'Redis' },
|
||||
{ value: 'opensearch', label: 'OpenSearch' },
|
||||
{ value: 'elasticsearch', label: 'ElasticSearch' },
|
||||
{ value: 'aliyun', label: $t('documentCollection.alibabaCloud') },
|
||||
{ value: 'qcloud', label: $t('documentCollection.tencentCloud') },
|
||||
]);
|
||||
|
||||
const defaultEntity = {
|
||||
collectionType: 'DOCUMENT',
|
||||
alias: '',
|
||||
@@ -125,15 +116,6 @@ const rules = ref({
|
||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||
],
|
||||
title: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
|
||||
vectorStoreType: [
|
||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||
],
|
||||
vectorStoreCollection: [
|
||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||
],
|
||||
vectorStoreConfig: [
|
||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||
],
|
||||
vectorEmbedModelId: [
|
||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||
],
|
||||
@@ -307,49 +289,6 @@ defineExpose({
|
||||
:placeholder="$t('documentCollection.placeholder.description')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<!-- <ElFormItem
|
||||
prop="vectorStoreEnable"
|
||||
:label="$t('documentCollection.vectorStoreEnable')"
|
||||
>
|
||||
<ElSwitch v-model="entity.vectorStoreEnable" />
|
||||
</ElFormItem>-->
|
||||
<ElFormItem
|
||||
prop="vectorStoreType"
|
||||
:label="$t('documentCollection.vectorStoreType')"
|
||||
>
|
||||
<ElSelect
|
||||
v-model="entity.vectorStoreType"
|
||||
:placeholder="$t('documentCollection.placeholder.vectorStoreType')"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in vecotrDatabaseList"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value || ''"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
prop="vectorStoreCollection"
|
||||
:label="$t('documentCollection.vectorStoreCollection')"
|
||||
>
|
||||
<ElInput
|
||||
v-model.trim="entity.vectorStoreCollection"
|
||||
:placeholder="
|
||||
$t('documentCollection.placeholder.vectorStoreCollection')
|
||||
"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
prop="vectorStoreConfig"
|
||||
:label="$t('documentCollection.vectorStoreConfig')"
|
||||
>
|
||||
<ElInput
|
||||
v-model.trim="entity.vectorStoreConfig"
|
||||
:rows="4"
|
||||
type="textarea"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="vectorEmbedModelId">
|
||||
<template #label>
|
||||
<span style="display: flex; align-items: center">
|
||||
@@ -440,12 +379,6 @@ defineExpose({
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
prop="searchEngineEnable"
|
||||
:label="$t('documentCollection.searchEngineEnable')"
|
||||
>
|
||||
<ElSwitch v-model="entity.searchEngineEnable" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</EasyFlowFormModal>
|
||||
</template>
|
||||
|
||||
@@ -3,20 +3,40 @@ import { ref } from 'vue';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import { ElButton, ElInput, ElMessage } from 'element-plus';
|
||||
import { ElButton, ElInput, ElMessage, ElOption, ElSelect } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import PreviewSearchKnowledge from '#/views/ai/documentCollection/PreviewSearchKnowledge.vue';
|
||||
|
||||
type RetrievalMode = 'HYBRID' | 'KEYWORD' | 'VECTOR';
|
||||
|
||||
interface SearchResultItem {
|
||||
sorting: number;
|
||||
content: string;
|
||||
score?: number;
|
||||
hitSource?: 'BOTH' | 'KEYWORD' | 'VECTOR';
|
||||
vectorScore?: number;
|
||||
keywordScore?: number;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
knowledgeId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const searchDataList = ref([]);
|
||||
|
||||
const searchDataList = ref<SearchResultItem[]>([]);
|
||||
const keyword = ref('');
|
||||
const retrievalMode = ref<RetrievalMode>('HYBRID');
|
||||
const previewSearchKnowledgeRef = ref();
|
||||
|
||||
const retrievalModeOptions = [
|
||||
{ label: $t('bot.retrievalModes.hybrid'), value: 'HYBRID' },
|
||||
{ label: $t('bot.retrievalModes.vector'), value: 'VECTOR' },
|
||||
{ label: $t('bot.retrievalModes.keyword'), value: 'KEYWORD' },
|
||||
];
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!keyword.value) {
|
||||
ElMessage.error($t('message.pleaseInputContent'));
|
||||
@@ -24,12 +44,22 @@ const handleSearch = () => {
|
||||
}
|
||||
previewSearchKnowledgeRef.value.loadingContent(true);
|
||||
api
|
||||
.get(
|
||||
`/api/v1/documentCollection/search?knowledgeId=${props.knowledgeId}&keyword=${keyword.value}`,
|
||||
)
|
||||
.get('/api/v1/documentCollection/search', {
|
||||
params: {
|
||||
knowledgeId: props.knowledgeId,
|
||||
keyword: keyword.value,
|
||||
retrievalMode: retrievalMode.value,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
previewSearchKnowledgeRef.value.loadingContent(false);
|
||||
searchDataList.value = res.data;
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.error($t('documentCollection.searchFailed'));
|
||||
searchDataList.value = [];
|
||||
})
|
||||
.finally(() => {
|
||||
previewSearchKnowledgeRef.value.loadingContent(false);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@@ -40,7 +70,16 @@ const handleSearch = () => {
|
||||
<ElInput
|
||||
v-model="keyword"
|
||||
:placeholder="$t('common.searchPlaceholder')"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<ElSelect v-model="retrievalMode" class="retrieval-select">
|
||||
<ElOption
|
||||
v-for="item in retrievalModeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElButton type="primary" @click="handleSearch">
|
||||
{{ $t('button.query') }}
|
||||
</ElButton>
|
||||
@@ -48,6 +87,7 @@ const handleSearch = () => {
|
||||
<div class="search-result">
|
||||
<PreviewSearchKnowledge
|
||||
:data="searchDataList"
|
||||
:retrieval-mode="retrievalMode"
|
||||
ref="previewSearchKnowledgeRef"
|
||||
/>
|
||||
</div>
|
||||
@@ -68,6 +108,12 @@ const handleSearch = () => {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.retrieval-select {
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
padding-top: 20px;
|
||||
flex: 1;
|
||||
|
||||
@@ -10,9 +10,6 @@ import {
|
||||
ElFormItem,
|
||||
ElInputNumber,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElSwitch,
|
||||
ElTooltip,
|
||||
} from 'element-plus';
|
||||
|
||||
@@ -32,7 +29,6 @@ const props = defineProps({
|
||||
onMounted(() => {
|
||||
getDocumentCollectionConfig();
|
||||
});
|
||||
const searchEngineEnable = ref(false);
|
||||
const baseOptions = ref<Record<string, any>>({});
|
||||
const getDocumentCollectionConfig = () => {
|
||||
api
|
||||
@@ -46,16 +42,13 @@ const getDocumentCollectionConfig = () => {
|
||||
: 5;
|
||||
searchConfig.simThreshold = options.simThreshold
|
||||
? Number(options.simThreshold)
|
||||
: 0.5;
|
||||
searchConfig.searchEngineType = options.searchEngineType || 'lucene';
|
||||
searchEngineEnable.value = !!data.searchEngineEnable;
|
||||
: 0.6;
|
||||
});
|
||||
};
|
||||
|
||||
const searchConfig = reactive({
|
||||
docRecallMaxNum: 5,
|
||||
simThreshold: 0.5,
|
||||
searchEngineType: 'lucene',
|
||||
simThreshold: 0.6,
|
||||
});
|
||||
|
||||
const submitConfig = () => {
|
||||
@@ -69,9 +62,7 @@ const submitConfig = () => {
|
||||
...baseOptions.value,
|
||||
docRecallMaxNum: searchConfig.docRecallMaxNum,
|
||||
simThreshold: searchConfig.simThreshold,
|
||||
searchEngineType: searchConfig.searchEngineType,
|
||||
},
|
||||
searchEngineEnable: searchEngineEnable.value,
|
||||
};
|
||||
|
||||
api
|
||||
@@ -85,26 +76,6 @@ const submitConfig = () => {
|
||||
console.error('保存配置失败:', error);
|
||||
});
|
||||
};
|
||||
|
||||
const searchEngineOptions = [
|
||||
{
|
||||
label: 'Lucene',
|
||||
value: 'lucene',
|
||||
},
|
||||
{
|
||||
label: 'ElasticSearch',
|
||||
value: 'elasticSearch',
|
||||
},
|
||||
];
|
||||
const handleSearchEngineEnableChange = () => {
|
||||
if (!props.manageable) {
|
||||
return;
|
||||
}
|
||||
api.post('/api/v1/documentCollection/update', {
|
||||
id: props.documentCollectionId,
|
||||
searchEngineEnable: searchEngineEnable.value,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -176,71 +147,12 @@ const handleSearchEngineEnableChange = () => {
|
||||
/>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<!-- 搜索引擎启用开关 -->
|
||||
<ElFormItem class="form-item">
|
||||
<div class="form-item-label">
|
||||
<span>{{
|
||||
$t('documentCollectionSearch.searchEngineEnable.label')
|
||||
}}</span>
|
||||
<ElTooltip
|
||||
:content="$t('documentCollectionSearch.searchEngineEnable.tooltip')"
|
||||
placement="top"
|
||||
effect="dark"
|
||||
class="label-tooltip"
|
||||
>
|
||||
<InfoFilled class="info-icon" />
|
||||
</ElTooltip>
|
||||
</div>
|
||||
<div class="form-item-content">
|
||||
<ElSwitch
|
||||
v-model="searchEngineEnable"
|
||||
@change="handleSearchEngineEnableChange"
|
||||
:active-text="$t('documentCollectionSearch.switch.on')"
|
||||
:inactive-text="$t('documentCollectionSearch.switch.off')"
|
||||
class="form-control switch-control"
|
||||
/>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<!-- 通过 searchEngineEnable 控制显示/隐藏 -->
|
||||
<ElFormItem
|
||||
v-if="searchEngineEnable"
|
||||
prop="searchEngineType"
|
||||
class="form-item"
|
||||
>
|
||||
<div class="form-item-label">
|
||||
<span>{{
|
||||
$t('documentCollectionSearch.searchEngineType.label')
|
||||
}}</span>
|
||||
<ElTooltip
|
||||
:content="$t('documentCollectionSearch.searchEngineType.tooltip')"
|
||||
placement="top"
|
||||
effect="dark"
|
||||
class="label-tooltip"
|
||||
>
|
||||
<InfoFilled class="info-icon" />
|
||||
</ElTooltip>
|
||||
</div>
|
||||
<div class="form-item-content">
|
||||
<ElSelect
|
||||
v-model="searchConfig.searchEngineType"
|
||||
:placeholder="
|
||||
$t('documentCollectionSearch.searchEngineType.placeholder')
|
||||
"
|
||||
class="form-control"
|
||||
>
|
||||
<ElOption
|
||||
v-for="option in searchEngineOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<div class="config-hint">
|
||||
{{ $t('documentCollectionSearch.engineHint') }}
|
||||
</div>
|
||||
|
||||
<div class="config-footer">
|
||||
<ElButton
|
||||
type="primary"
|
||||
@@ -352,6 +264,13 @@ const handleSearchEngineEnableChange = () => {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.config-hint {
|
||||
margin: 12px 0 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.config-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import { Document } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElIcon } from 'element-plus';
|
||||
// 定义类型接口(与 React 版本一致)
|
||||
import { ElButton, ElIcon, ElTag } from 'element-plus';
|
||||
|
||||
type RetrievalMode = 'HYBRID' | 'KEYWORD' | 'VECTOR';
|
||||
type HitSource = 'BOTH' | 'KEYWORD' | 'VECTOR';
|
||||
|
||||
interface PreviewItem {
|
||||
sorting: string;
|
||||
sorting: number | string;
|
||||
content: string;
|
||||
score: string;
|
||||
score?: number | string;
|
||||
hitSource?: HitSource;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
hideScore: {
|
||||
type: Boolean,
|
||||
@@ -46,8 +53,37 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
retrievalMode: {
|
||||
type: String as () => RetrievalMode,
|
||||
default: 'HYBRID',
|
||||
},
|
||||
});
|
||||
|
||||
const loadingStatus = ref(false);
|
||||
|
||||
const resolveHitSourceLabel = (hitSource?: HitSource) => {
|
||||
if (hitSource === 'VECTOR') {
|
||||
return $t('documentCollection.hitSources.vector');
|
||||
}
|
||||
if (hitSource === 'KEYWORD') {
|
||||
return $t('documentCollection.hitSources.keyword');
|
||||
}
|
||||
if (hitSource === 'BOTH') {
|
||||
return $t('documentCollection.hitSources.both');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const resolveHitSourceType = (hitSource?: HitSource) => {
|
||||
if (hitSource === 'VECTOR') {
|
||||
return 'success';
|
||||
}
|
||||
if (hitSource === 'KEYWORD') {
|
||||
return 'warning';
|
||||
}
|
||||
return 'info';
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
loadingContent: (state: boolean) => {
|
||||
loadingStatus.value = state;
|
||||
@@ -89,10 +125,22 @@ defineExpose({
|
||||
{{ item.sorting ?? index + 1 }}
|
||||
</div>
|
||||
<div class="el-list-item-meta">
|
||||
<div v-if="!hideScore">
|
||||
<div v-if="!hideScore" class="score-text">
|
||||
{{ $t('documentCollection.similarityScore') }}: {{ item.score }}
|
||||
</div>
|
||||
<div class="content-desc">{{ item.content }}</div>
|
||||
<div
|
||||
v-if="retrievalMode === 'HYBRID' && item.hitSource"
|
||||
class="hit-source-row"
|
||||
>
|
||||
<ElTag
|
||||
size="small"
|
||||
effect="plain"
|
||||
:type="resolveHitSourceType(item.hitSource)"
|
||||
>
|
||||
{{ resolveHitSourceLabel(item.hitSource) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -203,10 +251,20 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
.score-text {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.hit-source-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.el-list-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
padding: 18px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@
|
||||
});
|
||||
|
||||
const options = getOptions();
|
||||
const retrievalModeOptions: SelectItem[] = [
|
||||
{ value: 'HYBRID', label: '混合检索' },
|
||||
{ value: 'VECTOR', label: '向量检索' },
|
||||
{ value: 'KEYWORD', label: '关键词检索' }
|
||||
];
|
||||
|
||||
let knowledgeArray = $state<SelectItem[]>([]);
|
||||
onMount(async () => {
|
||||
@@ -78,6 +83,16 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!data.retrievalMode) {
|
||||
updateNodeData(currentNodeId, () => {
|
||||
return {
|
||||
retrievalMode: 'HYBRID'
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@@ -134,6 +149,18 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="setting-title">检索方式</div>
|
||||
<div class="setting-item">
|
||||
<Select items={retrievalModeOptions} style="width: 100%" placeholder="请选择检索方式" onSelect={(item)=>{
|
||||
const newValue = item.value;
|
||||
updateNodeData(currentNodeId, ()=>{
|
||||
return {
|
||||
retrievalMode: newValue
|
||||
}
|
||||
})
|
||||
}} value={data.retrievalMode ? [data.retrievalMode] : ['HYBRID']} />
|
||||
</div>
|
||||
|
||||
|
||||
<div class="setting-title">获取数据量</div>
|
||||
<div class="setting-item">
|
||||
@@ -184,4 +211,3 @@
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user