You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
518 lines
16 KiB
Vue
518 lines
16 KiB
Vue
<!-- 分段设置组件 —— 对应源代码 SegmentSetting.tsx -->
|
|
<template>
|
|
<div :class="[styles.content, styles.segmentContent]">
|
|
<h3 :class="styles.segmentTitle">分段设置</h3>
|
|
<div :class="styles.segmentLayout">
|
|
<div :class="styles.segmentLeft">
|
|
<SegmenteInfo
|
|
:indexing-estimate="indexingEstimate"
|
|
:set-indexing-estimate="setIndexingEstimate"
|
|
:preview-indexing-estimate="previewIndexingEstimate"
|
|
:file-list="fileList"
|
|
:selected-file-uid="selectedFileUid"
|
|
:set-selected-file-uid="setSelectedFileUid"
|
|
:set-current-file-index="setCurrentFileIndex"
|
|
/>
|
|
|
|
<ElFormItem label="Embedding 模型" :class="styles.embeddingItem">
|
|
<ElSelect
|
|
:class="styles.embeddingSelect"
|
|
placeholder="搜索模型"
|
|
filterable
|
|
:model-value="toModelSelectValue(embeddingModel)"
|
|
:loading="loadingTextEmbedding"
|
|
@change="(value: any) => setEmbeddingModel(parseModelSelectValue(String(value ?? '')))"
|
|
>
|
|
<ElOptionGroup
|
|
v-for="group in embeddingOptions"
|
|
:key="group.label"
|
|
:label="group.label"
|
|
>
|
|
<ElOption
|
|
v-for="opt in group.options"
|
|
:key="opt.value"
|
|
:value="opt.value"
|
|
:label="opt.label"
|
|
>
|
|
<div :class="styles.embeddingOption">
|
|
<img
|
|
v-if="opt.icon"
|
|
:class="styles.embeddingOptionIcon"
|
|
:src="opt.icon"
|
|
alt=""
|
|
/>
|
|
<span :class="styles.embeddingOptionLabel">{{ opt.label }}</span>
|
|
</div>
|
|
</ElOption>
|
|
</ElOptionGroup>
|
|
</ElSelect>
|
|
</ElFormItem>
|
|
|
|
<div :class="styles.retrievalSection">
|
|
<h3 :class="styles.segmentTitle">检索设置</h3>
|
|
|
|
<div :class="styles.settingCard">
|
|
<div :class="styles.settingHeader">
|
|
<div :class="styles.settingIcon">
|
|
<ElIcon><Search /></ElIcon>
|
|
</div>
|
|
<div>
|
|
<div :class="styles.settingName">混合检索</div>
|
|
<div :class="styles.settingDesc">
|
|
同时执行全文检索和向量检索,并应用重排序步骤,从两类查询结果中选择匹配用户问题的最佳结果,用户可以选择设置权重或配置重新排序模型。
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div :class="styles.retrievalForm">
|
|
<div :class="styles.retrievalRerankRow">
|
|
<div :class="styles.retrievalLabel">
|
|
<span>
|
|
Rerank 模型
|
|
<ElIcon :class="styles.infoIcon"><InfoFilled /></ElIcon>
|
|
</span>
|
|
</div>
|
|
<ElSwitch :model-value="rerankEnabled" @update:model-value="(v: any) => setRerankEnabled(Boolean(v))" />
|
|
</div>
|
|
|
|
<ElSelect
|
|
v-if="rerankEnabled"
|
|
:class="styles.retrievalModelSelect"
|
|
placeholder="搜索模型"
|
|
filterable
|
|
:model-value="toModelSelectValue(rerankModel)"
|
|
:loading="loadingRerank"
|
|
@change="(value: any) => setRerankModel(parseModelSelectValue(String(value ?? '')))"
|
|
>
|
|
<ElOptionGroup
|
|
v-for="group in rerankOptions"
|
|
:key="group.label"
|
|
:label="group.label"
|
|
>
|
|
<ElOption
|
|
v-for="opt in group.options"
|
|
:key="opt.value"
|
|
:value="opt.value"
|
|
:label="opt.label"
|
|
>
|
|
<div :class="styles.embeddingOption">
|
|
<img
|
|
v-if="opt.icon"
|
|
:class="styles.embeddingOptionIcon"
|
|
:src="opt.icon"
|
|
alt=""
|
|
/>
|
|
<span :class="styles.embeddingOptionLabel">{{ opt.label }}</span>
|
|
</div>
|
|
</ElOption>
|
|
</ElOptionGroup>
|
|
</ElSelect>
|
|
|
|
<div :class="styles.retrievalRow">
|
|
<div :class="styles.retrievalControl">
|
|
<div :class="styles.retrievalControlHeader">
|
|
<span>
|
|
Top K
|
|
<ElIcon :class="styles.infoIcon"><InfoFilled /></ElIcon>
|
|
</span>
|
|
</div>
|
|
<div :class="styles.retrievalControlBody">
|
|
<ElInputNumber
|
|
:class="styles.retrievalNumberInput"
|
|
:min="1"
|
|
:max="20"
|
|
:model-value="topK"
|
|
@update:model-value="(v: any) => setTopK(Number(v ?? 1))"
|
|
/>
|
|
<ElSlider
|
|
:class="styles.retrievalSlider"
|
|
:min="1"
|
|
:max="20"
|
|
:model-value="topK"
|
|
@update:model-value="(v: any) => setTopK(Number(v))"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div :class="styles.retrievalControl">
|
|
<div :class="styles.retrievalControlHeader">
|
|
<span>
|
|
Score 阈值
|
|
<ElIcon :class="styles.infoIcon"><InfoFilled /></ElIcon>
|
|
</span>
|
|
<ElSwitch
|
|
size="small"
|
|
:model-value="scoreThresholdEnabled"
|
|
@update:model-value="(v: any) => setScoreThresholdEnabled(Boolean(v))"
|
|
/>
|
|
</div>
|
|
<div :class="styles.retrievalControlBody">
|
|
<ElInputNumber
|
|
:class="styles.retrievalNumberInput"
|
|
:min="0"
|
|
:max="1"
|
|
:step="0.01"
|
|
:model-value="scoreThreshold"
|
|
:disabled="!scoreThresholdEnabled"
|
|
@update:model-value="(v: any) => setScoreThreshold(Number(v ?? 0))"
|
|
/>
|
|
<ElSlider
|
|
:class="styles.retrievalSlider"
|
|
:min="0"
|
|
:max="1"
|
|
:step="0.01"
|
|
:model-value="scoreThreshold"
|
|
:disabled="!scoreThresholdEnabled"
|
|
@update:model-value="(v: any) => setScoreThreshold(Number(v))"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<SegmentedPreview
|
|
:selected-filename="selectedFilename"
|
|
:selected-ext="selectedExt"
|
|
:selected-icon-style="selectedIconStyle"
|
|
:loading-indexing-estimate="loadingIndexingEstimate"
|
|
:indexing-estimate-preview-chunks="indexingEstimatePreviewChunks"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
defineOptions({ name: "SegmentSetting" });
|
|
|
|
import { watch } from "vue";
|
|
import { Search, InfoFilled } from "@element-plus/icons-vue";
|
|
import SegmenteInfo from "./SegmenteInfo.vue";
|
|
import SegmentedPreview from "./SegmentedPreview.vue";
|
|
import { getFileIconStyle, type FileIconStyle } from "./fileStyles";
|
|
import { useDifyGet, useDifyPost } from "../useDifyApis";
|
|
import { knowledgeBase } from "../api";
|
|
import {
|
|
decodeInputValue,
|
|
getQueryIndexingEstimateJson,
|
|
type IndexingEstimate,
|
|
type ModelType,
|
|
type UploadFile,
|
|
} from "../types";
|
|
import styles from "../style/createKnowledge.module.scss";
|
|
|
|
type IndexingEstimatePreviewChunk = {
|
|
content?: string;
|
|
summary?: string | null;
|
|
child_chunks?: unknown;
|
|
};
|
|
|
|
type LocalizedText = {
|
|
zh_Hans?: string;
|
|
en_US?: string;
|
|
[key: string]: string | undefined;
|
|
};
|
|
|
|
type TextEmbeddingModel = {
|
|
model: string;
|
|
label?: LocalizedText;
|
|
model_type?: string;
|
|
status?: string;
|
|
deprecated?: boolean;
|
|
};
|
|
|
|
type TextEmbeddingProvider = {
|
|
provider: string;
|
|
label?: LocalizedText;
|
|
icon_small?: LocalizedText;
|
|
status?: string;
|
|
models?: TextEmbeddingModel[];
|
|
};
|
|
|
|
type ModelOption = {
|
|
key: string;
|
|
value: string;
|
|
label: string;
|
|
icon?: string;
|
|
};
|
|
|
|
type ModelOptionGroup = {
|
|
label: string;
|
|
options: ModelOption[];
|
|
};
|
|
|
|
interface Props {
|
|
fileList: UploadFile[];
|
|
fileIds: string[];
|
|
rerankModel: ModelType;
|
|
embeddingModel: ModelType;
|
|
indexingEstimate: IndexingEstimate;
|
|
rerankEnabled: boolean;
|
|
topK: number;
|
|
scoreThreshold: number;
|
|
scoreThresholdEnabled: boolean;
|
|
setRerankEnabled: (value: boolean) => void;
|
|
setTopK: (value: number) => void;
|
|
setScoreThreshold: (value: number) => void;
|
|
setScoreThresholdEnabled: (value: boolean) => void;
|
|
setRerankModel: (model: ModelType) => void;
|
|
setEmbeddingModel: (model: ModelType) => void;
|
|
setIndexingEstimate: (estimate: IndexingEstimate) => void;
|
|
}
|
|
|
|
const props = defineProps<Props>();
|
|
|
|
const selectedFileUid = ref<string>(props.fileList[0]?.uid ?? "");
|
|
const currentFileIndex = ref(0);
|
|
|
|
const selectedFile = computed(() =>
|
|
props.fileList.find((file) => file.uid === selectedFileUid.value)
|
|
);
|
|
|
|
const selectedFilename = computed(() => selectedFile.value?.name);
|
|
const selectedExt = computed(() => {
|
|
const name = selectedFilename.value;
|
|
if (!name || !name.includes(".")) return "";
|
|
return name.split(".").pop()?.toLowerCase() || "";
|
|
});
|
|
const selectedIconStyle = computed<FileIconStyle>(() =>
|
|
getFileIconStyle(selectedExt.value)
|
|
);
|
|
|
|
// 预览块
|
|
const {
|
|
result: indexingEstimateResult,
|
|
doPost: postIndexingEstimate,
|
|
loading: loadingIndexingEstimate,
|
|
} = useDifyPost({
|
|
url: knowledgeBase.indexingEstimate,
|
|
});
|
|
|
|
// textEmbedding 模型
|
|
const { result: textModeldingResult, loading: loadingTextEmbedding } = useDifyGet({
|
|
url: knowledgeBase.textEmbedding,
|
|
moreOperations: {
|
|
immediate: true,
|
|
},
|
|
});
|
|
|
|
// rerank 模型
|
|
const { result: rerankResult, loading: loadingRerank } = useDifyGet({
|
|
url: knowledgeBase.rerank,
|
|
moreOperations: {
|
|
immediate: true,
|
|
},
|
|
});
|
|
|
|
watch(
|
|
() => props.fileList,
|
|
(fileList) => {
|
|
if (!fileList?.length) {
|
|
selectedFileUid.value = "";
|
|
return;
|
|
}
|
|
if (selectedFileUid.value && fileList.some((f) => f.uid === selectedFileUid.value)) {
|
|
return;
|
|
}
|
|
selectedFileUid.value = fileList[0]?.uid ?? "";
|
|
},
|
|
{ immediate: true, deep: true }
|
|
);
|
|
|
|
watch(
|
|
[currentFileIndex, selectedFileUid],
|
|
() => {
|
|
if (selectedFileUid.value) {
|
|
previewIndexingEstimate();
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
const indexingEstimatePreviewChunks = computed<IndexingEstimatePreviewChunk[]>(() => {
|
|
const resp = indexingEstimateResult.value as any;
|
|
const payload = resp?.data ?? resp;
|
|
return Array.isArray(payload?.preview) ? payload.preview : [];
|
|
});
|
|
|
|
function pickText(text?: LocalizedText): string {
|
|
return text?.zh_Hans ?? text?.en_US ?? Object.values(text ?? {})[0] ?? "";
|
|
}
|
|
|
|
function formatIconUrl(icon?: LocalizedText): string {
|
|
const url = pickText(icon);
|
|
return url || "";
|
|
}
|
|
|
|
function toModelSelectValue(model: ModelType): string | undefined {
|
|
if (!model?.currentProvider || !model?.currentModel) return undefined;
|
|
return `${model.currentProvider}::${model.currentModel}`;
|
|
}
|
|
|
|
function parseModelSelectValue(value: string): ModelType {
|
|
const [currentProvider, ...rest] = String(value ?? "").split("::");
|
|
const currentModel = rest.join("::");
|
|
return {
|
|
currentProvider: currentProvider ?? "",
|
|
currentModel: currentModel ?? "",
|
|
};
|
|
}
|
|
|
|
// 点击预览分段
|
|
function previewIndexingEstimate() {
|
|
const fileId = props.fileIds[currentFileIndex.value];
|
|
if (!fileId) return;
|
|
postIndexingEstimate({
|
|
data: getQueryIndexingEstimateJson(
|
|
[fileId],
|
|
[
|
|
{
|
|
id: "remove_extra_spaces",
|
|
enabled: props.indexingEstimate.pre_processing_rules.includes(
|
|
"remove_extra_spaces"
|
|
),
|
|
},
|
|
{
|
|
id: "remove_urls_emails",
|
|
enabled: props.indexingEstimate.pre_processing_rules.includes(
|
|
"remove_urls_emails"
|
|
),
|
|
},
|
|
],
|
|
{
|
|
separator: decodeInputValue(props.indexingEstimate.segmentIdentifier),
|
|
max_tokens: props.indexingEstimate.maximumSegmentLength,
|
|
chunk_overlap: props.indexingEstimate.segmentOverlapLength,
|
|
}
|
|
),
|
|
});
|
|
}
|
|
|
|
function setCurrentFileIndex(index: number) {
|
|
currentFileIndex.value = index;
|
|
}
|
|
|
|
function setSelectedFileUid(uid: string) {
|
|
selectedFileUid.value = uid;
|
|
}
|
|
|
|
// textEmbedding 选择
|
|
const embeddingOptions = computed<ModelOptionGroup[]>(() => {
|
|
const resp = textModeldingResult.value as any;
|
|
const embeddingProviders: TextEmbeddingProvider[] = Array.isArray(resp?.data)
|
|
? resp.data
|
|
: [];
|
|
|
|
return embeddingProviders
|
|
.filter((p) => (p?.status ?? "active") === "active")
|
|
.map((provider) => {
|
|
const providerLabel = pickText(provider.label) || provider.provider;
|
|
const iconPath = formatIconUrl(provider.icon_small);
|
|
const providerIcon = iconPath ? "/p30" + iconPath : "";
|
|
const models = (provider.models ?? [])
|
|
.filter((m) => (m?.status ?? "active") === "active")
|
|
.filter((m) => !m?.deprecated)
|
|
.filter((m) => (m?.model_type ?? "text-embedding") === "text-embedding")
|
|
.map((model) => {
|
|
const modelLabel = pickText(model.label) || model.model;
|
|
return {
|
|
key: `${provider.provider}::${model.model}`,
|
|
value: `${provider.provider}::${model.model}`,
|
|
label: modelLabel,
|
|
icon: providerIcon,
|
|
};
|
|
});
|
|
return {
|
|
label: providerLabel,
|
|
options: models,
|
|
};
|
|
})
|
|
.filter((group) => group.options.length > 0);
|
|
});
|
|
|
|
// rerank 选择
|
|
const rerankOptions = computed<ModelOptionGroup[]>(() => {
|
|
const resp = rerankResult.value as any;
|
|
const rerankProviders: TextEmbeddingProvider[] = Array.isArray(resp?.data)
|
|
? resp.data
|
|
: [];
|
|
|
|
return rerankProviders
|
|
.filter((p) => (p?.status ?? "active") === "active")
|
|
.map((provider) => {
|
|
const providerLabel = pickText(provider.label) || provider.provider;
|
|
const iconPath = formatIconUrl(provider.icon_small);
|
|
const providerIcon = iconPath ? "/p30" + iconPath : "";
|
|
const models = (provider.models ?? [])
|
|
.filter((m) => (m?.status ?? "active") === "active")
|
|
.filter((m) => !m?.deprecated)
|
|
.filter((m) => (m?.model_type ?? "rerank") === "rerank")
|
|
.map((model) => {
|
|
const modelLabel = pickText(model.label) || model.model;
|
|
return {
|
|
key: `${provider.provider}::${model.model}`,
|
|
value: `${provider.provider}::${model.model}`,
|
|
label: modelLabel,
|
|
icon: providerIcon,
|
|
};
|
|
});
|
|
return {
|
|
label: providerLabel,
|
|
options: models,
|
|
};
|
|
})
|
|
.filter((group) => group.options.length > 0);
|
|
});
|
|
|
|
// 默认选择第一个模型
|
|
watch(
|
|
[embeddingOptions, () => props.embeddingModel],
|
|
() => {
|
|
const firstValue = embeddingOptions.value?.[0]?.options?.[0]?.value;
|
|
if (!firstValue) return;
|
|
let exists = false;
|
|
for (const group of embeddingOptions.value) {
|
|
for (const opt of group.options) {
|
|
if (opt.value === toModelSelectValue(props.embeddingModel)) {
|
|
exists = true;
|
|
}
|
|
}
|
|
}
|
|
if (
|
|
!props.embeddingModel?.currentProvider ||
|
|
!props.embeddingModel?.currentModel ||
|
|
!exists
|
|
) {
|
|
props.setEmbeddingModel(parseModelSelectValue(firstValue));
|
|
}
|
|
},
|
|
{ deep: true }
|
|
);
|
|
|
|
watch(
|
|
[rerankOptions, () => props.rerankModel],
|
|
() => {
|
|
const firstValue = rerankOptions.value?.[0]?.options?.[0]?.value;
|
|
if (!firstValue) return;
|
|
let exists = false;
|
|
for (const group of rerankOptions.value) {
|
|
for (const opt of group.options) {
|
|
if (opt.value === toModelSelectValue(props.rerankModel)) {
|
|
exists = true;
|
|
}
|
|
}
|
|
}
|
|
if (
|
|
!props.rerankModel?.currentProvider ||
|
|
!props.rerankModel?.currentModel ||
|
|
!exists
|
|
) {
|
|
props.setRerankModel(parseModelSelectValue(firstValue));
|
|
}
|
|
},
|
|
{ deep: true }
|
|
);
|
|
</script>
|