Commit 0f55590a by YeYuheng Committed by GitHub

Support image embedding indexes for multimodal dataset search (#6948)

* support image embedding dataset indexes

* feat(dataset): support image embedding system indexes

* fix: migrate legacy dataset search input

* perf: code

* perf: code

* submodule

* add test

* fix: migrate dataset search input

* doc

* test: update dataset search input expectation

---------

Co-authored-by: archer <545436317@qq.com>
parent 2de10d37
# 功能开发文档
# 功能开发文档
## 文档标识
- 任务前缀:`图搜图-当前需求`
- 文档文件名:`图搜图-当前需求-功能开发文档.md`
- 更新时间:2026-05-01
- 文档状态:`v2.0 反向核对完成,补齐重建链路与搜索测试页 UI`
- 文档定位:面向开发和 AI 实现的任务拆解文档。旧的 `图搜图-接入-*` 文档不覆盖,本文件按用户最新确认需求重新规划。
## 0. 开发目标与约束
- 功能目标:接入图搜图能力,覆盖 embedding 模型配置页 `支持图片识别` 开关、创建知识库模型选择、图片自动索引、图片向量索引生成、分块弹窗 UI、搜索测试本地上传图片、工作流知识库搜索节点 `Array<string>` 检索内容和后端图文混合检索。
- 代码范围:`packages/global`、`packages/service`、`packages/web`、`projects/app`、`pro/admin`、`document/content`。
- 非目标:不新增知识库类型;不新增搜索模式大类;不重做模型配置页整体框架;不做存量图片向量自动迁移;不做全量工作流文件系统重构;不改训练状态展示大类。
- 实现原则:最小改动、复用现有搜索/RRF/权限/计费能力;图片向量索引和 VLM 文本索引分清楚;能靠配置和 helper 解决的不要到处散落判断。
- 必须遵循规范:`references/style-standards-entry.md`。
- 适用维度:API[x] DB[x] Front[x] Logger[x] Package[x] BugFix[ ] DocUpdate[x] DocI18n[x]。
## 1. 实施任务拆解(可直接执行)
| 任务ID | 任务名称 | 责任层 | 输入 | 输出 | 完成定义(DoD) |
|---|---|---|---|---|---|
| T1 | 扩展 embedding 模型能力字段 | Global/Service | 模型配置 | `vision?: boolean` | 老配置可解析,helper 能判断 image 能力 |
| T2 | 改模型配置页 embedding 功能配置 | Front/API | embedding 模型设置表单 | `支持图片识别` 开关,默认关闭,打开后保存 `vision=true` | 样式参考 LLM 功能配置,关闭时 text-only |
| T3 | 改模型选择器标签 | Front | embedding model list | `Beta`、`多模态` 标签和 `多模态` hover 说明 | `Beta` 在前,长模型名不挤压标签;hover `多模态` 展示固定文案 |
| T4 | 改创建知识库弹窗 | Front/API | 新 UI 图、最新提示文案 | 宽弹窗、三模型字段、索引模型标签、QuestionTip | 创建成功参数保持兼容;索引模型和图片理解模型问号提示使用固定文案 |
| T5 | 改图片自动索引可用性 | Front/Global | 商业版、多模态、VLM 配置 | 动态 disabled、tooltip、tips | 5 种场景符合矩阵,禁用时提交值为 false |
| T6 | 增加图片 embedding 服务 | Service | 图片 URL/S3 key | 图片向量 | 支持 db/query 两种场景,错误可观测 |
| T7 | 增加图片向量索引类型和写入链路 | Global/Service/DB | 图片数据/图文数据 | `imageEmbedding` 索引向量 | 不污染现有 VLM `image` 文本索引 |
| T8 | 改训练和重建分流 | Service/Pro | 多模态/VLM 配置、当前 `vectorModel`、data.indexes、内容变化状态 | 文本索引和图片索引按当前模型能力做差量规划、分流训练/重建 | 重建不把图片引用当文本 embedding;内容未变只补缺失索引/清理失效索引;内容变更时按文本、VLM、图片 URL diff 精准重建 |
| T9 | 改搜索核心 | Service | `textQueries + queryImageUrls`、当前知识库 `vectorModel/vlmModel` | 按模型能力分支召回 + RRF | 多模态 embedding 直接图/文检索;有 VLM 时合并图片转文字召回;普通 embedding 有 VLM 时图片先转文字;普通 embedding 无 VLM 时不做图片检索 |
| T10 | 改搜索测试 API 和本地上传图片 | API/Front | 本地图片、搜索参数 | 可测试图片输入 | `text` 可选;支持仅图片搜索;最多 10 张;只支持图片,不支持文件;格式和大小跟随系统并过滤不合法输入;上传对象 3 小时过期 |
| T11 | 改搜索测试页 UI/store | Front | 最新搜索测试上传图片与历史 hover 图 | 搜索配置、测试按钮、历史无 icon、输入框内上传按钮、缩略图、上传中卡片、历史图片 hover 缩略图浮层 | 视觉与交互符合新稿;超过 10 张提示 `最多支持上传10张图片`;无图片搜索能力时禁用上传按钮并提示 |
| T12 | 改工作流知识库搜索节点 | Global/Service/Front | `Array<string>` 检索内容 | 同槽位接用户问题和文件链接,兼容层统一归一化后端仅保留图片链接 | 新节点为 arrayString,旧节点 string 由兼容层处理;PDF/docx 等非图片文件链接被过滤 |
| T13 | 改文件/图片分块弹窗 | Front/API | 第三、第四张图与最新补充图 | 新索引卡片、图片预览、图片内容、索引删除入口 | 多模态图片索引内容不可见;默认索引和多模态图片索引不可删;其他索引可删 |
| T14 | 补计费、日志、i18n、文档 | Service/Web/Docs | 新能力 | 用量统计、脱敏日志、中英文文档 | 文档和翻译同步 |
| T15 | 测试与验收 | Test | T1-T14 | 自动化/手工验证 | 局部测试通过,最终 `pnpm lint`/必要测试通过 |
### 1.1 技术实现流程图(必填)
```mermaid
flowchart TD
A["T1 模型能力字段<br/>vision"] --> B["T2 模型配置页<br/>支持图片识别开关"]
B --> C["T3/T4 创建知识库与模型下拉"]
B --> D["T5 图片自动索引矩阵"]
B --> E["T6 图片 embedding 服务"]
E --> F["T7 图片向量索引类型与写入"]
F --> G["T8 训练/重建分流"]
E --> H["T9 搜索核心<br/>文本 + 图片召回"]
H --> I["T10 搜索测试 API + 本地上传"]
I --> J["T11 搜索测试页 UI/store"]
H --> K["T12 工作流检索内容 Array<string>"]
G --> L["T13 分块弹窗索引卡片"]
J --> M["T14 文档/i18n/日志/计费"]
K --> M
L --> M
M --> N["T15 测试与验收"]
```
实现说明:
- `T1` 是所有判断的地基,别在前后端散写 `model.includes('xxx')` 这种土法炼钢。
- `T2` 是模型是否支持图片输入的唯一人工配置入口;embedding 模型复用现有 `vision` 字段,语义为“支持图片向量化”,不再新增 `modalities`。
- `T5` 是本次用户特别补充点,禁用状态和提示文案必须跟矩阵一致。
- `T12` 不能改成额外 `fileUrlList` 外露输入,用户已经确认“检索内容本身改 Array<string>”;但数组里的文件链接只有图片能参与检索,非图片文件链接必须在后端过滤。
## 2. 文件级改动清单
| 文件路径 | 改动类型 | 变更摘要 | 关键代码(可伪代码) | 关联任务ID |
|---|---|---|---|---|
| `packages/global/core/ai/model.schema.ts` | 修改 | `EmbeddingModelItemSchema` 增加/复用 `vision` | `vision: z.boolean().optional()` | T1 |
| `packages/service/core/ai/model.ts` | 修改 | 增加能力判断 helper | `isImageEmbeddingModel(model)` | T1 |
| `projects/app/src/pageComponents/account/model/AddModelBox.tsx` | 修改 | embedding 模型设置表单新增 `功能配置` 区域和 `支持图片识别` 开关 | 开关默认关;打开保存 `vision=true`;关闭保存/恢复为 `vision=false` 或缺省 | T2 |
| `projects/app/src/pageComponents/account/model/ModelConfigTable.tsx` | 修改 | 模型配置列表/设置入口识别 embedding `vision` | `vision=true` 显示 `多模态` 标签,`Beta` 在前 | T2/T3 |
| `projects/app/src/pages/api/core/ai/model/update.ts` | 修改 | 模型配置保存支持 embedding `vision` | schema parse 后保存 `vision`;老 embedding 无 `vision` 按 false | T2 |
| `projects/app/src/pages/api/core/ai/model/list.ts` | 修改/核查 | 模型配置列表返回 embedding `vision` | 前端下拉和配置页能拿到 image 能力 | T2/T3 |
| `packages/web/i18n/*/account_model.json` | 修改 | embedding 功能配置文案、图片理解模型 tip | 复用/新增 `支持图片识别` tip;更新/新增 `vlm_model_tip` 为 `自动标注文档里的图片并生成文本描述,辅助文本检索` | T2/T4 |
| `packages/web/i18n/*/common.json` | 修改 | 创建知识库索引模型 tip、多模态标签 hover 文案 | 更新 `core.dataset.embedding model tip` 或新增 `core.dataset.embedding_model_tip`;新增 `core.ai.model.multimodal_tip` | T3/T4/T14 |
| `projects/app/src/components/Select/AIModelSelector.tsx` | 修改 | 支持 `Beta`、`多模态` 标签顺序和 hover | tag list 先 beta 后 multimodal;`多模态` tag hover 展示 `多模态索引模型可以给图片生成向量。` | T3 |
| `projects/app/src/pageComponents/dataset/list/CreateModal.tsx` | 修改 | 弹窗和字段布局按第一张图,并接入新 QuestionTip 文案 | 宽度、label、selector 样式调整;`索引模型` 和 `图片理解模型` 问号提示用固定 i18n 文案 | T4 |
| `projects/app/src/pageComponents/dataset/detail/Form/CollectionChunkForm.tsx` | 修改 | 图片自动索引 disabled/tips 矩阵 | `getImageIndexConfigState()` | T5 |
| `packages/web/i18n/*/dataset.json` | 修改 | 图片自动索引动态文案、多模态图片索引默认说明、索引删除确认文案 | 新增 tips/default description/delete confirm key | T5/T13/T14 |
| `packages/service/core/ai/embedding/index.ts` | 修改 | 增加 `getVectorsByImage` | 图片输入转 embedding request | T6 |
| `packages/service/common/vectorDB/controller.ts` | 修改 | 支持外部预计算向量写入 | `insertDatasetVectors()` | T6/T7 |
| `packages/global/core/dataset/data/constants.ts` | 修改 | 新增 `DatasetDataIndexTypeEnum.imageEmbedding` | `imageEmbedding = 'imageEmbedding'` | T7 |
| `packages/global/core/dataset/constants.ts` | 修改 | 新增 `SearchScoreTypeEnum.imageEmbedding` | 用于 quote 分数展示 | T7/T9 |
| `projects/app/src/service/core/dataset/data/controller.ts` | 修改 | 数据写入按索引类型分流,并支持可删除索引同步清理后端数据/向量 | imageEmbedding 走图片向量;默认索引和 imageEmbedding 不允许删除;删除其他索引不能只做 UI 过滤 | T7/T13 |
| `projects/app/src/pages/api/core/dataset/collection/create/images.ts` | 修改 | 多模态索引模型时不再强制要求 VLM | `if (!vlm && !isImageEmbeddingModel) error` | T7/T8 |
| `projects/app/src/pages/api/core/dataset/data/insertImages.ts` | 修改 | 图片追加后按能力生成向量/文本索引 | 多模态无 VLM 仍可入队 | T7/T8 |
| `pro/admin/src/service/core/dataset/training/imageParse.ts` | 核查/复用 | 有 VLM 时继续生成文本描述索引;图片向量由后续 chunk/insert/rebuild 链路按多模态模型补齐 | 按能力分流 | T8 |
| `pro/admin/src/service/core/dataset/training/imageIndex.ts` | 修改 | 图文文档图片索引时仅生成 VLM 文本索引,保留原始 `q` 中的 markdown 图片引用 | 不复用 `image` 做图片向量;图片向量索引统一在 `generateVector` 建索引阶段补齐 | T8 |
| `projects/app/src/service/core/dataset/queues/generateVector.ts` | 修改 | 初次导入和重建时统一补齐 markdown 图片的 `imageEmbedding`,并按 `index.type` 分流 | 文本索引走文本 embedding;图片索引走 `getVectorsByImage`;不按文件格式分支 | T8 |
| `projects/app/src/pages/api/core/dataset/update.ts` | 修改 | 更新 `vectorModel` 或 `vlmModel` 后标记/触发全库重建 | 重建按切换后的模型组合重新生成索引 | T5/T8 |
| `projects/app/src/pages/api/core/dataset/data/update.ts` | 修改 | 单条更新索引时先生成差量重建计划 | 内容未变只补缺/清理;内容变更时保留未变化图片 URL 的 `imageEmbedding`;VLM 有无变化独立检查 | T8/T13 |
| `packages/service/core/dataset/search/controller.ts` | 修改 | 增加 `queryImageUrls`/图片向量召回 | `runImageRecall()` | T9 |
| `packages/global/openapi/core/dataset/api.ts` | 修改 | `SearchDatasetTestBodySchema` 支持 `text?`、`queryImageUrls?` | refine 至少一个输入;`queryImageUrls.max(10)`;允许纯图片搜索 | T10 |
| `projects/app/src/pages/api/core/dataset/searchTest.ts` | 修改 | 搜索测试接收文本和图片 | 上传后的图片 URL 参与搜索;拒绝超过 10 张图片;空文本 + 有图片可搜索 | T10 |
| 搜索测试图片上传接口/服务 | 新增/复用 | 上传本地图片为搜索测试临时输入 | 只接收图片;写入 S3 TTL,`expiredTime = addHours(new Date(), 3)`;不要复用正式图片集导入的 7 天过期策略 | T10 |
| `projects/app/src/web/core/dataset/api.ts` | 修改 | 更新搜索测试类型和图片上传 API wrapper | `postSearchText` 可保留名或重命名;图片上传只传图片文件 | T10 |
| `projects/app/src/pageComponents/dataset/detail/Test.tsx` | 修改 | 搜索测试页 UI 改版、本地上传图片和历史图片 hover 预览 | 搜索配置按钮、输入框左下角图片上传按钮、顶部缩略图/删除/上传中卡片、历史图片 hover 缩略图浮层、测试按钮下移;最多 10 张和过滤规则;普通 embedding 且无 VLM 时禁用图片按钮 | T11 |
| `projects/app/src/web/core/dataset/store/searchTest.ts` | 修改 | 历史支持图片摘要、缩略图引用并兼容旧数据 | `imageCount/queryImageRefs/queryImagePreviewRefs` | T11 |
| `packages/global/core/workflow/template/system/datasetSearch.ts` | 修改 | 检索内容 valueType 改 `arrayString` | 保持 key 为 `userChatInput`,改 valueType | T12 |
| `packages/service/core/workflow/dispatch/dataset/search.ts` | 修改 | 增加兼容层读取旧 string 或新 arrayString,并归一化文本和图片,过滤非图片文件链接 | `normalizeDatasetSearchInput()` 返回 `textQueries/queryImageUrls/filteredFileCount`;业务搜索层不扩散 `string | string[]` | T12 |
| `projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/list.tsx` | 修改/核查 | 新建 dataset search 节点默认只连接用户问题,文件链接由用户按需手动添加 | arrayString 默认单引用,支持后续追加文件链接引用 | T12 |
| `projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx` | 修改 | 文件/图片分块弹窗新样式、索引内容可见性和删除入口 | `DataIndexPanel`、`ImageContentPanel`、`IndexDeleteAction` | T13 |
| `projects/app/src/pages/api/core/dataset/data/update.ts` | 修改/核查 | 删除可删除索引时同步更新 `indexes[]`,必要时清理对应向量记录 | 默认索引和多模态图片索引拒绝删除,其他索引允许删除 | T13 |
| `projects/app/src/components/core/dataset/QuoteItem.tsx` | 修改 | 展示图片向量分数类型 | `SearchScoreTypeMap.imageEmbedding` | T14 |
| `document/content/openapi/dataset.mdx` | 修改 | 更新搜索测试 API | 图片上传/纯图片搜索/最多 10 张/图文混合示例 | T14 |
| `document/content/openapi/dataset.en.mdx` | 修改 | 英文同步 | 字段名保持不翻译 | T14 |
## 2.1 关键代码片段(用于规划核对)
### 2.1.1 模型能力字段与 helper
```ts
// packages/global/core/ai/model.schema.ts
export const EmbeddingModelItemSchema = BaseAIModelSchema.extend({
vision: z.boolean().optional()
});
// packages/service/core/ai/model.ts
export const isImageEmbeddingModel = (model?: string) => {
const modelData = getEmbeddingModel(model);
return !!modelData.vision;
};
```
### 2.1.2 embedding 模型配置开关映射
```ts
// projects/app/src/pageComponents/account/model/AddModelBox.tsx
// 注意:embedding 复用 vision 字段,但语义是图片向量化能力。
const supportImageRecognition = !!watch('vision');
<SwitchField
label={t('account:model.vision')}
tip={t('account:model.embedding_vision_tip')}
isChecked={supportImageRecognition}
onChange={(e) => {
setValue('vision', e.target.checked);
}}
/>;
```
实现要求:
1. 开关默认关闭,旧 embedding 模型无 `vision` 时按 text-only 展示。
2. 打开后保存 `vision=true`;关闭后保存/恢复为 `vision=false` 或缺省。
3. UI 样式参考 LLM 模型 `功能配置` 区域,但 helper 必须区分 LLM 的图片理解能力和 embedding 的图片向量化能力,别在业务里裸读字段到处判断。
4. 配置保存成功后,模型配置列表、创建知识库索引模型下拉、图片自动索引矩阵都必须读到同一份 `vision`。
### 2.1.3 图片自动索引状态矩阵
```ts
// projects/app/src/pageComponents/dataset/detail/Form/CollectionChunkForm.tsx
const getImageIndexConfigState = ({
isPlus,
isImageEmbeddingModel,
vlmModel
}: {
isPlus: boolean;
isImageEmbeddingModel: boolean;
vlmModel?: string;
}) => {
if (!isPlus) {
return {
disabled: true,
tooltip: t('common:commercial_function_tip'),
tip: t('dataset:image_auto_parse_tip_commercial')
};
}
if (isImageEmbeddingModel && vlmModel) {
return {
disabled: false,
tooltip: '',
tip: t('dataset:image_auto_parse_tip_multimodal_with_vlm')
};
}
if (isImageEmbeddingModel) {
return {
disabled: false,
tooltip: '',
tip: t('dataset:image_auto_parse_tip_multimodal_without_vlm')
};
}
if (vlmModel) {
return {
disabled: false,
tooltip: '',
tip: t('dataset:image_auto_parse_tip_vlm_only')
};
}
return {
disabled: true,
tooltip: t('dataset:image_auto_parse_tip_no_vlm_or_multimodal'),
tip: t('dataset:image_auto_parse_tip_no_vlm_or_multimodal')
};
};
```
### 2.1.4 搜索测试 schema
```ts
// packages/global/openapi/core/dataset/api.ts
export const SearchDatasetTestBodySchema = z
.object({
datasetId: ObjectIdSchema,
text: z.string().optional(),
queryImageUrls: z.array(z.string()).max(10, '最多支持上传10张图片').optional(),
limit: z.number().optional(),
similarity: z.number().optional(),
searchMode: z.enum(DatasetSearchModeEnum).optional(),
usingReRank: z.boolean().optional(),
datasetSearchUsingExtensionQuery: z.boolean().optional()
})
.refine((data) => !!data.text?.trim() || !!data.queryImageUrls?.length, {
message: 'text or queryImageUrls is required'
});
```
实现要求:
1. `text` 可为空,只要 `queryImageUrls` 有值就允许搜索,支持“仅上传图片,不带文字”。
2. schema 只校验输入形态,不判断知识库是否支持图片搜索;普通 embedding 且无 VLM 的能力限制由前端禁用上传按钮和搜索核心兜底处理。
3. 图片数量上限为 10 张;前端和后端都要校验,前端超出时提示 `最多支持上传10张图片`,后端作为兜底防绕过。
4. 搜索测试上传入口只支持图片,不支持 PDF、docx、xlsx、txt、pptx 等文件。
5. 图片格式和大小限制跟随系统现有上传规则,不在搜索测试里单独定义一套阈值。
6. 非图片文件、系统不支持格式、超出系统大小限制的图片直接过滤,不加入待上传列表,也不生成 `queryImageUrls`。
7. 如果一批选择中同时有合法图片和非法文件,合法图片正常加入,非法项过滤;只有图片数量超过 10 张需要使用本需求指定提示文案。
8. 搜索测试上传图片只用于临时检索,上传对象必须设置 3 小时过期时间:`expiredTime = addHours(new Date(), 3)`,并走现有 S3 TTL 清理链路。
9. 搜索测试历史/store 不保存 base64、完整私有 URL 或长期可访问 URL,只保存图片数量、文本摘要和受控缩略图引用。
### 2.1.5 工作流检索内容归一化
```ts
// packages/service/core/workflow/dispatch/dataset/search.ts
import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
import { parseUrlToFileType } from '@fastgpt/service/core/workflow/utils/context';
const isLikelyFileLinkValue = (value: string) => {
const trimmed = value.trim();
return (
trimmed.startsWith('data:') ||
trimmed.startsWith('chat/') ||
trimmed.startsWith('/') ||
/^https?:\/\//i.test(trimmed)
);
};
const normalizeDatasetSearchInput = (rawInput?: unknown) => {
// 兼容层:旧节点可能存 string,新节点为 arrayString;业务层只使用归一化结果。
const input =
typeof rawInput === 'string' || Array.isArray(rawInput) ? rawInput : undefined;
const values = Array.isArray(input) ? input : input ? [input] : [];
return values.reduce(
(acc, value) => {
const trimmed = value.trim();
if (!trimmed) return acc;
if (isLikelyFileLinkValue(trimmed)) {
const file = parseUrlToFileType(trimmed);
if (file?.type === ChatFileTypeEnum.image) {
acc.queryImageUrls.push(trimmed);
return acc;
}
if (file?.type === ChatFileTypeEnum.file) {
acc.filteredFileCount += 1;
return acc;
}
}
acc.textQueries.push(trimmed);
return acc;
},
{
textQueries: [] as string[],
queryImageUrls: [] as string[],
filteredFileCount: 0
}
);
};
```
过滤口径:
1. 只把 `parseUrlToFileType(value)?.type === ChatFileTypeEnum.image` 的链接放入 `queryImageUrls`。
2. `ChatFileTypeEnum.file` 的链接,包括 PDF、docx、xlsx、txt、pptx、html 等,一律过滤,不进入 `textQueries`。
3. 普通用户问题仍进入 `textQueries`;不要对所有字符串无脑调用 `parseUrlToFileType` 后就当文件处理,因为当前 parser 对无后缀文本也可能返回 file,容易误伤正常问题。
4. 不建议在工作流 dispatch 阶段对 URL 发起 HEAD 请求探测 MIME,成本、权限和 SSRF 风险都不划算;优先使用 `queryUrlTypeMap`、上传时文件类型和后缀白名单判断。
5. 过滤不作为节点错误。若输入数组只有非图片文件链接且无文本,节点按空检索返回空结果,并在 `nodeResponse` 或日志里记录 `filteredFileCount`,不记录完整 URL。
### 2.1.6 搜索核心分流
```ts
// packages/service/core/dataset/search/controller.ts
const supportImageEmbedding = isImageEmbeddingModel(dataset.vectorModel);
const hasVlm = !!dataset.vlmModel;
const imageCaptionQueries =
queryImageUrls.length && hasVlm
? await getQueryImageCaptionsByVlm({
imageUrls: queryImageUrls,
vlmModel: dataset.vlmModel
})
: [];
const recallTasks = [
textQueries.length
? runTextRecall({
textQueries,
searchMode,
usingReRank,
datasetSearchUsingExtensionQuery
})
: Promise.resolve([]),
imageCaptionQueries.length
? runTextRecall({
textQueries: imageCaptionQueries,
searchMode,
usingReRank: false,
datasetSearchUsingExtensionQuery: false,
source: 'imageCaption'
})
: Promise.resolve([]),
queryImageUrls.length && supportImageEmbedding
? runImageRecall({
imageUrls: queryImageUrls,
model: dataset.vectorModel,
datasetIds,
limit
})
: Promise.resolve([])
];
const [textRecallResult, imageCaptionRecallResult, imageRecallResult] =
await Promise.all(recallTasks);
const mergedResult = datasetSearchResultConcat(
[
{ weight: textQueries.length ? embeddingWeight : 0, list: textRecallResult },
{ weight: imageCaptionQueries.length ? embeddingWeight : 0, list: imageCaptionRecallResult },
{ weight: queryImageUrls.length && supportImageEmbedding ? 1 : 0, list: imageRecallResult }
].filter((item) => item.weight > 0)
);
```
分支规则:
1. 多模态 embedding 的知识库:
- 纯文本:文本 query 使用同一个多模态 embedding 的 text modality 检索。
- 纯图片:图片 query 使用 image modality 检索 `imageEmbedding`。
- 图片 + 文字:文本分支和图片向量分支并行召回后 RRF 合并。
2. 多模态 embedding + VLM:
- 除图片向量召回外,查询图片还可以先经 VLM 转成文本,再检索 VLM 文本描述索引。
- 同一数据同时命中 `imageEmbedding` 和 VLM 文本描述索引时,RRF 合并后排序权重自然提升。
3. 普通 embedding + VLM:
- 图片不能直接走图片 embedding。
- 查询图片先经 VLM 转文字,再用普通 embedding 做文本检索。
- 图片 + 文字时,用户文字和图片 caption 作为多路文本 query 合并。
4. 普通 embedding + 无 VLM:
- 不做图片检索。
- 纯图片返回空召回结果;图片 + 文字只使用文字部分,不额外报错。
5. Query Extension 和 ReRank 默认只作用在用户文本分支;图片 caption 分支是否开启扩展/重排应保守处理,避免 VLM 已生成的描述被二次扩写导致语义漂移。
### 2.1.7 模型切换后的训练方式选择
```ts
// 伪代码:dataset vectorModel/vlmModel 更新后统一调用
const resolveImageIndexStrategy = ({
vectorModel,
vlmModel,
imageIndex
}: {
vectorModel: string;
vlmModel?: string;
imageIndex: boolean;
}) => {
const supportImageEmbedding = isImageEmbeddingModel(vectorModel);
return {
enableImageEmbeddingIndex: imageIndex && supportImageEmbedding,
enableVlmTextIndex: imageIndex && !!vlmModel,
normalizedImageIndex: imageIndex && (supportImageEmbedding || !!vlmModel)
};
};
const handleDatasetModelChanged = async (datasetId: string) => {
// 模型切换后全部重建,不能只局部清理旧 imageIndex。
await markDatasetRebuildRequired(datasetId);
};
```
### 2.1.8 重建索引分流
```ts
// projects/app/src/service/core/dataset/queues/generateVector.ts
const textIndexes = indexes.filter((item) => item.type !== DatasetDataIndexTypeEnum.imageEmbedding);
const imageIndexes = indexes.filter((item) => item.type === DatasetDataIndexTypeEnum.imageEmbedding);
await Promise.all([
textIndexes.length
? rebuildTextVectors({ dataId, indexes: textIndexes, model })
: Promise.resolve(),
imageIndexes.length
? rebuildImageVectors({ dataId, indexes: imageIndexes, model })
: Promise.resolve()
]);
```
#### 2.1.8.1 单条数据差量重建策略
目标:单条数据点击“更新索引”时,不能默认全量重建所有索引。需要先基于“内容是否变化”和“当前 VLM 有无变化/是否可用”生成重建计划,只处理缺失、失效或受内容变更影响的索引。
输入状态:
| 状态 | 来源 | 说明 |
|---|---|---|
| `oldQ/oldA` | `MongoDatasetData.q/a` | 已入库内容 |
| `nextQ/nextA` | API 请求入参 | 用户当前编辑后的内容 |
| `oldImageUrls` | 从 `oldQ` 解析 markdown 图片 | 当前已有图片 URL 集合 |
| `nextImageUrls` | 从 `nextQ` 解析 markdown 图片 | 新内容图片 URL 集合 |
| `supportVlm` | 当前知识库 VLM 是否可用 | 只判断当前状态;VLM 下架时不能继续保留 VLM 文本图片索引 |
| `supportImageEmbedding` | 当前向量模型是否多模态 | 单条更新不负责模型切换全量重建,只负责当前模型下缺失的 `imageEmbedding` |
| `autoIndexes` | 当前集合是否开启自动生成补充索引 | 用于判断 `question` / `summary` 是否需要重新进入自动索引队列 |
| `existingIndexes` | `MongoDatasetData.indexes` + 请求索引 | 用于识别已有 `image`、`imageEmbedding`、`custom`、`default`、`question`、`summary` |
核心判断:
```ts
const contentChanged = oldQ !== nextQ || oldA !== nextA;
const imageUrlsChanged = !isEqualSet(oldImageUrls, nextImageUrls);
const needRebuildAutoIndex =
autoIndexes && (contentChanged || !hasQuestionIndex || !hasSummaryIndex);
```
差量规划输出:
```ts
type DatasetDataIndexRebuildPlan = {
indexes: DatasetDataIndexItemType[];
contentChanged: boolean;
imageUrlsChanged: boolean;
hasMarkdownImages: boolean;
needRebuildVlmImageIndex: boolean;
needRebuildAutoIndex: boolean;
};
```
规则矩阵:
| 场景 | `default` 文本索引 | VLM `image` 文本索引 | 多模态 `imageEmbedding` | 自动索引 `question/summary` | `custom` |
|---|---|---|---|---|---|
| 内容没变,当前无 VLM | 不动 | 删除旧 `image` | 多模态下补缺;非多模态下删除旧 `imageEmbedding` | 开启且缺失则进 `auto` 队列;关闭则删除旧自动索引 | 保留 |
| 内容没变,当前有 VLM | 不动 | 缺失则补;已有不动 | 多模态下补缺;非多模态下删除旧 `imageEmbedding` | 开启且缺失则进 `auto` 队列;关闭则删除旧自动索引 | 保留 |
| 内容变,图片 URL 没变,当前有 VLM | 重建 | 重建,因为 VLM 输入包含上下文文本 | 保留已有 `imageEmbedding`,不重新生成图片向量 | 开启则进 `auto` 队列重建;关闭则删除旧自动索引 | 保留 |
| 内容变,图片 URL 没变,当前无 VLM | 重建 | 删除旧 `image` | 多模态下保留已有 `imageEmbedding`;非多模态下删除 | 开启则进 `auto` 队列重建;关闭则删除旧自动索引 | 保留 |
| 内容变,图片 URL 变化,当前有 VLM | 重建 | 重建 | 按图片 URL diff:保留未变、删除移除、只给新增 URL 创建 | 开启则进 `auto` 队列重建;关闭则删除旧自动索引 | 保留 |
| 内容变,图片 URL 变化,当前无 VLM | 重建 | 删除旧 `image` | 按当前多模态能力和图片 URL diff 增删 | 开启则进 `auto` 队列重建;关闭则删除旧自动索引 | 保留 |
必须遵守:
1. 内容没变时,只补当前训练参数下没有创建出来的索引,并清理当前能力不再支持的旧索引。
2. 内容变了时,文本索引必须重建;VLM `image` 也必须重建,因为 VLM 描述受上下文文本影响。
3. 内容变了但图片链接没变时,不重建已有 `imageEmbedding`,因为图片本体没变,图片向量不需要重新生成。
4. 无论内容是否变化,都必须检查当前 VLM 状态:无 VLM 删除 `image`;有 VLM 且缺失则补。
5. 单条“更新索引”不负责处理多模态模型切换带来的全库重建;多模态模型切换由知识库配置侧触发全量重建。单条更新只检查当前模型参数下是否缺少 `imageEmbedding`,缺才补。
6. `custom` 索引永远保留,除非用户显式删除该自定义索引。
7. `summary` / `question` 属于自动生成索引,不是用户自定义索引:`autoIndexes=false` 时删除;`autoIndexes=true` 且内容变化或任一缺失时删除旧自动索引并创建 `TrainingModeEnum.auto` 任务重建。
8. 删除旧派生索引时只删除能力派生索引和自动生成索引,不得误删 `custom`。
推荐执行顺序:
```mermaid
flowchart TD
A["读取旧数据和当前模型能力"] --> B["解析 old/new 图片 URL"]
B --> C["生成 DatasetDataIndexRebuildPlan"]
C --> D["同步删除失效派生索引"]
D --> E{"需要 VLM image 索引?"}
E -- 是 --> F["进入 image 队列,仅重建 VLM 文本图片索引"]
E -- 否 --> G["跳过 VLM 队列"]
F --> H{"需要自动索引?"}
G --> H
H -- 是 --> I["进入 auto 队列生成 question/summary"]
H -- 否 --> J["跳过 auto 队列"]
I --> K["进入 vector 写入阶段"]
J --> K
K --> L["只写入缺失 imageEmbedding 和需重建文本索引"]
L --> M["保留 custom 和未变化 imageEmbedding"]
```
落地建议:
1. 在 service 层新增/抽取 `buildDatasetDataIndexRebuildPlan()`,供 `data/update.ts`、`rebuildEmbedding.ts`、`generateVector.ts` 复用。
2. `imageIndex.ts` 只负责 VLM 文本图片索引,不负责图片向量。
3. `generateVector.ts` 按 plan 追加缺失 `imageEmbedding`,已有同 URL 的图片向量索引直接保留。
4. 前端“更新索引”按钮需要等异步重建结果写回后再刷新数据索引列表,不能把“训练任务创建成功”当成索引完成。
5. `data/update.ts` 在 `needRebuildVlmImageIndex=true` 时创建 `TrainingModeEnum.image`;在 `needRebuildAutoIndex=true` 时创建 `TrainingModeEnum.auto`,由 pro/admin 的 `generateAutoTraining` 生成 `question` / `summary` 后再转 `chunk` 写向量。
6. 如果本地或部署环境没有启动 pro/admin 训练进程,`TrainingModeEnum.auto` 任务只会停留在队列中,不会产出自动索引。
### 2.1.9 文档图文分块的多模态图片索引补齐
目标:把 DOCX、PDF 解析服务、HTML、Markdown、网页等来源统一成同一种处理方式。只要最终训练分块里保留 markdown 图片引用,并且当前知识库启用了图片自动索引、索引模型支持图片向量化,就在建索引阶段补齐 `imageEmbedding`。不要按文件扩展名分别写补丁。
职责边界:
1. `pro/admin/src/service/core/dataset/training/imageIndex.ts` 只负责 VLM 识别图片并生成 `DatasetDataIndexTypeEnum.image` 文本索引。
2. `projects/app/src/service/core/dataset/queues/generateVector.ts` 负责在真正建索引前追加 `DatasetDataIndexTypeEnum.imageEmbedding`。
3. `insertData2Dataset` / `updateData2Dataset` 继续负责按索引类型分流:文本索引走文本 embedding,`imageEmbedding` 走 `getVectorsByImage`。
图片来源收敛:
| 来源 | 是否作为补齐来源 | 原因 |
|---|---:|---|
| `trainingData.q` | 是 | 初次导入和 VLM/auto 处理后的训练记录仍应保留原始分块文本,是文档图文分块的主来源 |
| `trainingData.data.q` | 是,仅重建兜底 | 重建时可从已入库原始数据恢复 markdown 图片,避免 VLM 后续改写 `q` 时漏图 |
| 已有 `indexes` 中的 `imageEmbedding` | 否,仅用于去重 | 不能当来源重复生成,只用于避免重复追加同一图片 |
| `trainingData.imageId` / `trainingData.data.imageId` | 否,本方案不处理 | 图片数据集/单图数据已有 `insertData2Dataset` 的 `imageId` 链路,和文档 markdown 图片补齐分开 |
| `imageDescMap` 的 key | 否 | 它是 VLM 描述映射结果,不作为图片来源;避免依赖 VLM 产物来驱动图片向量索引 |
推荐伪代码:
```ts
const getMarkdownImageUrlsFromTrainingData = (trainingData: TrainingDataType) => {
const texts = [trainingData.q, trainingData.data?.q].filter(Boolean) as string[];
return unique(texts.flatMap(matchMarkdownImageUrls));
};
const appendMarkdownImageEmbeddingIndexes = ({
indexes,
trainingData,
embModel
}: {
indexes: DatasetDataIndexItemType[];
trainingData: TrainingDataType;
embModel: ReturnType<typeof getEmbeddingModel>;
}) => {
if (!trainingData.collection.imageIndex) return indexes;
if (!isImageEmbeddingModel(embModel)) return indexes;
const existedImageUrls = new Set(
indexes
.filter((item) => item.type === DatasetDataIndexTypeEnum.imageEmbedding)
.map((item) => item.text)
);
const appendIndexes = getMarkdownImageUrlsFromTrainingData(trainingData)
.filter((url) => !existedImageUrls.has(url))
.map((url) => ({
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: url,
dataId: ''
}));
return indexes.concat(appendIndexes);
};
```
接入要求:
1. `rebuildData()` 调用该 helper,保证存量数据重建时能从 `trainingData.data.q` 补齐图片向量索引。
2. `insertData()` 调用该 helper,保证 DOCX/PDF/HTML/Markdown/网页等初次导入时就生成 `imageEmbedding`。
3. 不在 `imageIndex.ts` 中调用 `getVectorsByImage`,避免 VLM 队列承担建向量职责。
4. 普通 embedding 模型或 `collection.imageIndex=false` 时不追加 `imageEmbedding`,避免出现没有实际向量的假索引卡片。
5. 同一分块里相同图片 URL 只追加一次 `imageEmbedding`。
## 3. 后端实施说明
### 3.1 API 改动
| 路由 | 方法 | 请求参数 | 响应结构 | 鉴权 | 错误处理 |
|---|---|---|---|---|---|
| `/api/core/ai/model/update` | PUT | embedding 模型配置中的 `vision?` | 更新后的模型配置或成功状态 | `authSystemAdmin` | 非法字段类型、模型配置解析失败 |
| `/api/core/ai/model/list` | GET | 原查询参数 | embedding 模型返回 `vision` | `authSystemAdmin` | 模型配置解析失败 |
| `/api/core/dataset/searchTest` | POST | `datasetId`、`text?`、`queryImageUrls?`、原搜索配置 | 复用 `SearchDatasetTestResponse`,可扩展参数摘要 | `authDataset` Read + AI points check | 空输入、图片超过 10 张、图片读取失败 |
| 搜索测试图片上传接口 | POST | multipart 图片文件,不支持普通文件 | 图片 URL/S3 key/文件信息,上传对象 3 小时过期 | 登录团队权限 | 非图片文件直接过滤;图片格式/大小跟随系统限制,超出直接过滤;上传失败;未写入 TTL |
请求示例:
```json
{
"datasetId": "68ad85a7463006c963799a05",
"text": "找一下类似图片",
"queryImageUrls": ["dataset/tmp/search-test/flower.png"],
"limit": 5000,
"similarity": 0.4,
"searchMode": "mixedRecall",
"usingReRank": false
}
```
响应示例:
```json
{
"list": [],
"duration": "0.523s",
"limit": 5000,
"searchMode": "mixedRecall",
"usingReRank": false,
"similarity": 0.4,
"queryExtensionModel": ""
}
```
### 3.2 Service/Core 改动
| 模块 | 函数/类型 | 具体改动 | 依赖关系 |
|---|---|---|---|
| AI 模型 | `EmbeddingModelItemSchema` | 增加/复用 `vision` | 前后端模型判断 |
| AI 模型 helper | `isImageEmbeddingModel` | 缺省 text-only | 图片自动索引、训练、搜索 |
| AI 模型配置 API | `update.ts`/`list.ts` | 保存和返回 embedding `vision` | 模型配置页、模型下拉 |
| Embedding | `getVectorsByImage` | 图片 URL/S3 key 生成向量 | 图片索引、图片 query |
| VectorDB | `insertDatasetVectors` | 支持预计算向量写入 | 避免图片被文本化 |
| Dataset data | `insertData2Dataset`/`updateData2Dataset` | 文本和图片索引分流 | 数据新增/编辑 |
| Dataset update | `projects/app/src/pages/api/core/dataset/update.ts` | 切换 `vectorModel` 或 `vlmModel` 时标记/触发全库重建,并按新模型组合重算图片自动索引策略 | 防止新模型配置继续沿用旧索引生成方式 |
| Training | `imageParse`/`imageIndex` | 只生成 VLM 文本描述索引,保留 markdown 图片引用给后续建索引阶段使用 | Pro 队列 |
| Vector build/Rebuild | `generateVector.ts` | 初次导入和重建统一从 `trainingData.q` / `trainingData.data.q` 补齐 markdown 图片的 `imageEmbedding`,再根据 `DatasetDataIndexTypeEnum` 分流建向量 | 避免索引错路;避免按文件格式补丁化 |
| Search | `searchDatasetData` | 图片召回与文本召回合并 | 搜索测试、工作流 |
| Workflow | `dispatch/dataset/search.ts` | 兼容层读取旧 string 或新 arrayString,图片链接入检索,非图片文件链接过滤 | 新旧工作流兼容,业务层只吃归一化结果 |
### 3.3 数据层改动
| 集合/表 | 字段 | 类型 | 必填 | 默认值 | 索引 | 迁移策略 |
|---|---|---|---|---|---|---|
| 模型配置 | `vision` | boolean | 否 | helper 中视为 `false` | 无 | 不迁移旧配置;embedding 场景语义为图片向量化 |
| `dataset_datas.indexes.type` | `imageEmbedding` | enum | 否 | N/A | 复用现有 index | 仅新数据或重建后生成 |
| `dataset_datas.indexes.text` | 图片引用 | string | 是 | N/A | 复用现有 index | 存可控 URL/S3 key,不存 base64 |
| 向量库 | 无新增字段 | N/A | N/A | N/A | 复用 | 图片 embedding 维度与现有向量写入链路保持一致,维度不兼容时沿用现有校验/报错 |
### 3.4 计费与用量
| 场景 | 现有能力 | 新增/调整 |
|---|---|---|
| 文本 embedding | 已有 | 保持 |
| 图片 embedding 索引 | 无明确图片 embedding 统计 | 记录模型、图片数量、返回 usage;若模型无 token usage,按图片数量计入可观测字段 |
| 搜索测试图片 query | `pushDatasetTestUsage` 只统计文本 embedding/rerank/extension | 增加图片 embedding usage 汇总 |
| 工作流图片 query | workflow nodeResponse 记录模型使用 | 增加图片 embedding 用量,避免账单对不上 |
| VLM 文本描述索引 | 已有 VLM 训练用量 | 多模态 + VLM 时仍记录 VLM 用量 |
### 3.5 搜索策略
| 知识库配置 | 输入 | 文本 embedding/全文检索 | 图片 embedding | VLM 查询图片转文字 | RRF |
|---|---|---|---|---|---|
| 多模态 embedding,无 VLM | 纯文本 | 是,使用 text modality | 否 | 否 | 单路文本结果 |
| 多模态 embedding,无 VLM | 纯图 | 否 | 是,检索 `imageEmbedding` | 否 | 多图图片召回 RRF |
| 多模态 embedding,无 VLM | 图文混合 | 是,用户文本分支 | 是,图片向量分支 | 否 | 文本 + 图片 RRF |
| 多模态 embedding,有 VLM | 纯文本 | 是,可命中 VLM 文本描述索引 | 否 | 否 | 文本索引结果合并 |
| 多模态 embedding,有 VLM | 纯图 | 是,来自查询图片 caption | 是,检索 `imageEmbedding` | 是 | 图片向量 + VLM caption RRF,同数据多路命中权重提升 |
| 多模态 embedding,有 VLM | 图文混合 | 是,用户文本 + 查询图片 caption | 是 | 是 | 用户文本 + 图片向量 + VLM caption 多路 RRF |
| 普通 embedding,有 VLM | 纯文本 | 是 | 否 | 否 | 文本结果 |
| 普通 embedding,有 VLM | 纯图 | 是,来自查询图片 caption | 否 | 是 | caption 文本召回,多图 caption RRF |
| 普通 embedding,有 VLM | 图文混合 | 是,用户文本 + 查询图片 caption | 否 | 是 | 多路文本 RRF |
| 普通 embedding,无 VLM | 纯文本 | 和现在一样 | 否 | 否 | 和现在一样 |
| 普通 embedding,无 VLM | 纯图 | 否 | 否 | 否 | 空召回,不额外报错 |
| 普通 embedding,无 VLM | 图文混合 | 只使用文字部分 | 否 | 否 | 文本结果 |
实现注意:
1. 不能把所有 `queryImageUrls` 都直接丢给图片 embedding。只有 `isImageEmbeddingModel(dataset.vectorModel)` 为 true 时才允许。
2. 有 VLM 时,查询图片也要转成文本,才能检索入库阶段生成的 VLM 文本描述索引。
3. 同一数据同时被图片向量索引和 VLM 文本描述索引召回时,不要去重到只保留一条召回源;应进入 RRF 合并,让排序权重自然变大。
4. 普通 embedding 无 VLM 的纯图片输入返回空列表即可,不作为接口错误;图文混合时忽略图片分支。
5. 日志中可记录 `textQueryCount/imageQueryCount/imageCaptionQueryCount/supportImageEmbedding/hasVlm`,不要记录完整图片 URL 和完整 caption。
## 4. 前端实施说明
| 页面/组件 | 文件路径 | 交互变化 | i18n 改动 | 状态覆盖 |
|---|---|---|---|---|
| 模型配置表单 | `projects/app/src/pageComponents/account/model/AddModelBox.tsx` | embedding 模型设置里新增 `功能配置` 区域和 `支持图片识别` 开关,默认关闭 | `account:model.vision`、建议新增 `account:model.embedding_vision_tip` | 初始值、保存中、保存失败、打开/关闭 |
| 模型配置列表 | `projects/app/src/pageComponents/account/model/ModelConfigTable.tsx` | 根据 embedding `vision=true` 展示多模态标签,`Beta` 在前 | `core.ai.model.multimodal`、`core.ai.model.multimodal_tip` | 长名称、标签拥挤、无 vision、多模态 hover |
| 创建知识库弹窗 | `projects/app/src/pageComponents/dataset/list/CreateModal.tsx` | 宽弹窗;名称、索引模型、文本理解模型、图片理解模型按新稿;索引模型和图片理解模型问号提示使用固定文案 | `core.dataset.embedding_model_tip` 或旧 key 替换、`vlm_model_tip`、创建文案 | 加载/空/错误/创建中/QuestionTip hover |
| 模型下拉 | `projects/app/src/components/Select/AIModelSelector.tsx` | 模型名后展示 `Beta`、`多模态`,Beta 在前;hover 多模态标签展示能力说明 | `core.ai.model.multimodal`、`core.ai.model.multimodal_tip` | 长名称、标签拥挤、选中态、多模态 hover |
| 图片自动索引 | `projects/app/src/pageComponents/dataset/detail/Form/CollectionChunkForm.tsx` | 按 5 场景矩阵动态 disabled 和 tips | 5 个 tips key | 商业版/多模态/VLM 组合 |
| 文件分块弹窗 | `projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx` | 左内容 textarea + 生成索引按钮;右索引卡片 | 多模态图片索引、生成索引 | loading/edit/save/error |
| 图片分块弹窗 | `projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx` | 左图片预览 + 图片内容 textarea;右索引卡片 | 图片内容、多模态图片索引 | 图片加载失败、编辑、保存 |
| 搜索测试页 | `projects/app/src/pageComponents/dataset/detail/Test.tsx` | `搜索配置` 按钮、输入框左下角图片上传按钮、测试按钮放框下、历史无 icon、图片历史 hover 预览、支持本地上传图片;无图片搜索能力时图片按钮 disabled | 上传图片、删除图片、输入测试内容、搜索配置、历史图片预览、图片数量超限提示、无图片能力提示 | 空/纯图片/图片上传中/图片过滤/图片按钮禁用/测试中/失败/结果/历史 hover |
| 搜索历史 store | `projects/app/src/web/core/dataset/store/searchTest.ts` | 保存文本摘要、图片数量、受控缩略图引用 | N/A | 兼容旧历史,不保存 base64/完整私有 URL |
| 工作流知识库搜索节点 | `packages/global/core/workflow/template/system/datasetSearch.ts` | “检索内容”改 `Array<string>`,可多引用;运行时只接受图片文件链接参与检索 | `workflow:content_to_search` | 旧节点 string 值、新节点 array 值;非图片文件链接过滤 |
### 4.1 UI 参考图全集
说明:下面这些图用于开发实现时对齐 UI,图片来源为 `/Users/xxyyh/Desktop/figme` 中导出的真实 Figma 图片。原先临时手绘的 SVG 已移除,后续实现以这些 PNG/JPEG 为准。
#### 4.1.1 创建通用知识库弹窗
![创建通用知识库弹窗](./assets/create-dataset-modal-ui.png)
#### 4.1.2 索引模型下拉样式
![索引模型下拉样式](./assets/model-selector-dropdown-ui.png)
实现注意:截图里展示顺序看起来是 `多模态` 在前,但用户已确认最终实现为 `Beta` 放在 `多模态` 前。hover `多模态` 标签时展示 `多模态索引模型可以给图片生成向量。` 开发时按文字口径实现,别被旧截图带偏。
#### 4.1.3 文件分块点击后弹窗
![文件分块点击后弹窗](./assets/file-chunk-modal-ui.png)
#### 4.1.4 QA 模式文件分块弹窗
![QA 模式文件分块弹窗](./assets/file-chunk-qa-modal-ui.png)
#### 4.1.5 图片分块点击后弹窗
![图片分块点击后弹窗](./assets/image-chunk-modal-ui.png)
#### 4.1.6 工作流知识库搜索节点
![工作流知识库搜索节点](./assets/workflow-dataset-search-node-ui.png)
#### 4.1.7 搜索测试页基础态
![搜索测试页基础态](./assets/search-test-base-ui.png)
#### 4.1.8 搜索测试页上传图片与历史 hover 态
![搜索测试页上传图片与历史 hover 态](./assets/search-test-upload-ui.png)
#### 4.1.9 模型配置页参考
![模型配置页参考](./assets/model-config-reference-ui.jpeg)
实现注意:该图只表达 embedding 模型设置里新增 `支持图片识别` 开关的视觉样式。开发时参考 LLM 模型已有 `功能配置` 区域,保存字段为 embedding 模型的 `vision`。
### 4.2 创建知识库弹窗与模型下拉 UI 细节
1. `索引模型` label 后保留问号提示,hover/click 后展示:`索引模型可以将知识库内容转成向量,用于进行语义检索。注意,不同索引模型的知识库无法同时查询,切换索引模型需重建全量向量索引,请慎重选择。`
2. `图片理解模型` label 后保留问号提示,hover/click 后展示:`自动标注文档里的图片并生成文本描述,辅助文本检索`
3. 索引模型下拉中,支持图片的 embedding 模型展示 `Beta` 和 `多模态` 标签时,顺序必须是 `Beta` 在前、`多模态` 在后。
4. hover `多模态` 标签时展示:`多模态索引模型可以给图片生成向量。`
5. `多模态` hover 只绑定在 `多模态` 标签上,不绑定整行模型;`Beta` 标签不展示该说明。
6. 长模型名仍按现有省略规则处理,不能把 `Beta`、`多模态` 标签挤出可视区域。
7. 以上三段文案必须走 i18n,覆盖 zh-CN/en/zh-Hant;中文文案以本文档为准。
8. 现有 `common.json` 中旧 `索引模型可以将自然语言转成向量...选择完索引模型后将无法修改` 口径需要替换或停用,别让新旧文案在不同入口同时出现,用户看完容易怀疑人生。
### 4.3 模型配置页 UI 细节
1. 入口仍是现有模型配置页,针对 embedding 模型点击设置后进入 `AddModelBox` 表单。
2. 在 embedding 模型表单中新增 `功能配置` 区域,位置和样式参考 LLM 模型已有的 `功能配置`。
3. 区域内新增 `支持图片识别` 开关,默认关闭。
4. 开关关闭时,保存/恢复为 `vision=false` 或缺省,模型按 text-only 处理。
5. 开关打开时,保存 `vision=true`。
6. 开关再次关闭时必须关闭 `vision`,并让模型配置列表、模型下拉、图片自动索引矩阵立即回到普通 embedding 表现。
7. 文案可复用 `支持图片识别` label;tip 建议新增 embedding 专用 key,说明“开启后该 embedding 模型可接收图片输入并用于图片向量索引/图搜图”。
8. `vision` 在 LLM 和 embedding 上语义不同:LLM 是图片理解,embedding 是图片向量化。必须通过模型类型和 helper 判断,别一看字段名一样就到处裸用,后面排障基本就是开盲盒。
### 4.4 分块弹窗数据索引 UI 细节
1. 右侧标题仍为 `数据索引(n)`,数量按当前可展示索引数量计算。
2. `默认索引` 是基础索引,不展示删除入口,不允许被删除。
3. `默认索引` 和 `多模态图片索引` 都不展示删除入口,不允许被删除。
4. 其他索引展示删除入口并允许删除,包括 `推测问题索引`、`摘要索引`、自定义索引等。
5. `多模态图片索引` 的索引内容不可见,不展示向量、图片 URL、S3 key、原始索引文本或任何内部字段。
6. `多模态图片索引` 展开后只展示 UI 默认说明文案:`已通过多模态模型生成图片向量,支持以图搜图`。
7. 删除可删除索引时应有确认或防误触处理;删除成功后右侧索引数量和卡片列表立即刷新。
8. 删除动作必须同步后端索引数据,不能只在前端隐藏卡片,否则搜索仍可能命中已删除索引,这种“假删除”后面排查要命。
### 4.5 搜索测试页 UI 细节
1. 左侧标题为 `输入测试内容`。
2. 标题右侧为 `搜索配置` 按钮,使用 gear icon,点击打开现有搜索参数弹窗。
3. 输入区域是一个大 textarea 容器,placeholder 为 `输入需要测试的内容`。
4. 图片上传按钮放在输入框左下角,使用图片图标按钮,不使用文字按钮。
5. 若当前知识库 `!isImageEmbeddingModel(dataset.vectorModel) && !dataset.vlmModel`,图片上传按钮 disabled;hover 提示 `请配置图片理解模型或多模态索引模型`。
6. 图片上传只支持图片,不支持文件;文件选择器优先限制为图片类型,拖拽/粘贴/选择到非图片文件时直接过滤。
7. 支持仅上传图片不输入文字直接测试;只要待搜索图片列表非空,`测试` 按钮可用。
8. 图片数量上限为 10 张;选择后超过 10 张时提示 `最多支持上传10张图片`,超出的图片不加入列表。
9. 图片大小和格式限制跟随系统现有上传规则;不符合系统规则的图片直接过滤,不进入上传中状态。
10. 搜索测试上传图片必须设置 3 小时过期时间,前端不暴露配置项;后端上传时写入 TTL。
11. 已上传图片在输入框顶部横向排列,缩略图尺寸固定,避免 textarea 高度被图片加载状态反复撑开。
12. 单张图片 hover 或选中态展示右上角删除按钮,点击后从待搜索图片列表移除。
13. 图片上传中展示独立的上传中卡片,卡片尺寸与缩略图一致,中间显示 loading 圆环。
14. 输入文字区域位于图片缩略图下方;没有图片时,placeholder 仍从输入框上方自然显示。
15. `测试` 按钮放在输入框下方,撑满左侧区域。
16. `测试历史` 标题前不展示 icon。
17. 历史项不展示搜索模式 icon/title,只展示内容摘要、图片 token、时间或删除。
18. 图片检索历史用 `[图片]` token 表示图片输入;多张图片显示多个 `[图片]` token,超出宽度按现有文本省略规则处理。
19. 鼠标 hover 到含图片的历史项时,在历史项下方或右下方弹出图片缩略图浮层。
20. 缩略图浮层展示该次检索对应的图片缩略图,横向排列,尺寸固定,浮层有白底、圆角、阴影和边框。
21. 鼠标移出历史项和浮层后关闭缩略图浮层;hover 删除按钮时仍应优先展示删除操作,不要被浮层挡住。
22. 纯文本历史不展示图片缩略图浮层。
23. 中间区域保留 `测试参数` 和 `测试结果`。
24. 右侧知识库信息栏保持现有能力,不在本期做大改。
### 4.6 工作流节点 UI 细节
1. `知识库搜索` 节点的 `检索内容` valueType 改为 `WorkflowIOValueTypeEnum.arrayString`。
2. 同一个输入框允许同时引用:
- `流程开始 > 用户问题`
- `流程开始 > 文件链接`
3. 默认新建节点时只自动带上 `流程开始 > 用户问题`;不默认带 `流程开始 > 文件链接`。
4. 旧工作流若仍存 string 值,前端展示和后端运行都要兼容。
5. 后端归一化时将图片文件链接拆到 `queryImageUrls`,普通文本拆到 `textQueries`。
6. 文件链接变量里可能包含 PDF、docx、xlsx、txt、音视频等非图片文件;这些链接必须在后端过滤,不参与图搜图,也不要降级塞进文本检索。
7. 过滤非图片文件链接时不要弹前端错误。节点响应可记录 `filteredFileCount`,日志只记录数量和类型,不记录完整 URL。
### 4.7 图片自动索引配置逻辑
该部分不是 UI 图,不需要放截图。实现时必须按下面的配置矩阵控制 `imageIndex` 复选框、tooltip、QuestionTip 和最终提交值。
| 场景 | 条件判断 | Checkbox | Tooltip | QuestionTip | 提交值处理 |
|---|---|---|---|---|---|
| 非商业版 | `!feConfigs?.isPlus` | disabled | 商业版提示 | `请升级商业版后使用该功能` | 强制 `imageIndex=false` |
| 多模态索引 + 有 VLM | `isImageEmbeddingModel(dataset.vectorModel) && dataset.vlmModel` | enabled | 空 | `为文档中的图片生成图片向量索引和文本描述索引,支持以图搜图` | 尊重用户勾选值 |
| 多模态索引 + 无 VLM | `isImageEmbeddingModel(dataset.vectorModel) && !dataset.vlmModel` | enabled | 空 | `使用多模态模型为图片生成向量索引,支持以图搜图` | 尊重用户勾选值 |
| 普通索引 + 有 VLM | `!isImageEmbeddingModel(dataset.vectorModel) && dataset.vlmModel` | enabled | 空 | `调用 VLM 自动标注文档里的图片,并生成文本描述索引` | 尊重用户勾选值 |
| 普通索引 + 无 VLM | `!isImageEmbeddingModel(dataset.vectorModel) && !dataset.vlmModel` | disabled | 同 QuestionTip | `需配置图片理解模型,或切换多模态向量模型后,方可启用` | 强制 `imageIndex=false` |
实现要求:
1. 前端禁用时如果当前表单里 `imageIndex=true`,必须立即 `setValue('imageIndex', false)`,别出现“灰了但提交还是 true”的离谱状态。
2. 后端接收 `chunkSettings.imageIndex` 时也要复核同一套条件,前端禁用不是安全边界。
3. 多模态索引 + 无 VLM 时允许开启图片自动索引,但只生成图片向量索引,不生成 VLM 文本描述索引。
4. 普通索引 + 有 VLM 时允许开启图片自动索引,但只生成 VLM 文本描述索引,不生成图片向量索引。
5. 搜索阶段不额外报错:普通索引 + 无 VLM 的行为和现有逻辑一致,配置阶段负责禁止用户创建“以为有图片索引但实际没有”的状态。
6. 当知识库 `vectorModel` 或 `vlmModel` 发生切换时,前端和后端都要重新执行本矩阵,更新 `imageIndex` 可用性和提示文案。
7. 从普通 embedding 切到多模态 embedding 后,后续训练/重建应切到“图片向量索引 + 可选 VLM 文本描述索引”的生成方式。
8. 从多模态 embedding 切到普通 embedding 后,后续训练/重建不得继续生成 `imageEmbedding` 图片向量索引;如果没有 VLM,则必须强制 `imageIndex=false`。
9. 模型切换后必须全库重建或明确标记待重建,否则存量向量仍由旧 embedding/VLM 模型组合生成,搜索质量和配置会对不上。
## 5. 日志与可观测性
| 触发点 | 日志级别 | category | 字段 | 备注 |
|---|---|---|---|---|
| 图片 embedding 生成失败 | error | dataset embedding | `teamId/datasetId/collectionId/dataId/model/indexType/error` | 不记录图片内容 |
| 图片上传失败 | warn/error | dataset upload | `teamId/datasetId/fileCount/mimeType/size/error` | 文件名脱敏 |
| 工作流输入归一化异常/文件过滤 | warn/info | dataset search | `teamId/datasetIds/inputCount/textCount/imageCount/filteredFileCount/error` | 不记录完整输入和完整 URL |
| 模型不支持图片索引 | warn | dataset training | `teamId/datasetId/model/vision` | 用于排查配置 |
| 向量维度不匹配 | error | vector | `model/vectorLength/expectedLength/datasetId` | 不记录向量数组;维度约束沿用现有向量写入链路 |
注意事项:
- 统一使用 `@fastgpt/service/common/logger`。
- 不记录 token、密码、密钥、base64、完整私有 URL、完整用户问题。
- 搜索历史只保存摘要、图片数量和受控缩略图引用,不保存 base64 或完整私有预签名 URL。
## 6. 文档更新提醒(必填)
| 文档路径 | 文档类型 | 更新原因 | 计划更新内容 | 负责人 | 截止时间 | 状态 |
|---|---|---|---|---|---|---|
| `document/content/openapi/dataset.mdx` | OpenAPI 中文 | 搜索测试 API 增加图片输入 | `queryImageUrls`、本地上传说明、纯图片搜索、最多 10 张、图文混合示例 | 开发实现者 | 实现完成前 | 已更新 |
| `document/content/openapi/dataset.en.mdx` | OpenAPI 英文 | 中文同步 | 英文参数说明和示例,包含纯图片搜索和 10 张限制 | 开发实现者 | 实现完成前 | 已更新 |
| `document/content/introduction/guide/knowledge_base/dataset_engine.mdx` | 功能中文 | 新增图搜图与多模态图片索引 | 创建知识库、图片自动索引、搜索测试说明 | 开发实现者 | 实现完成前 | 已更新 |
| `document/content/introduction/guide/knowledge_base/dataset_engine.en.mdx` | 功能英文 | 中文同步 | 同步 image-to-image search、多模态图片索引和搜索测试说明 | 开发实现者 | 实现完成前 | 已更新 |
| `document/content/introduction/guide/dashboard/workflow/dataset_search.mdx` | 功能中文 | 检索内容改 `Array<string>` | 说明同时接用户问题和文件链接,并明确只有图片文件链接参与检索,其他文件链接会被过滤 | 开发实现者 | 实现完成前 | 已更新 |
| `document/content/introduction/guide/dashboard/workflow/dataset_search.en.mdx` | 功能英文 | 中文同步 | 同步 `Array<string>`、图片链接参与检索和非图片文件过滤说明 | 开发实现者 | 实现完成前 | 已更新 |
## 7. 文档 i18n 实施说明(命中时必填)
### 7.1 翻译范围识别
- 自动检测命令:
- `git diff --name-only`
- `git diff --cached --name-only`
- 手动指定路径:
- `document/content/openapi/dataset.mdx`
- `document/content/openapi/dataset.en.mdx`
- 知识库功能文档中文/英文对应文件
- 工作流知识库搜索节点中文/英文对应文件
### 7.2 文件映射与动作
| 中文文件 | 英文文件 | 类型 | 动作 | 状态 |
|---|---|---|---|---|
| `document/content/openapi/dataset.mdx` | `document/content/openapi/dataset.en.mdx` | mdx | 更新 | 已完成 |
| `document/content/introduction/guide/knowledge_base/dataset_engine.mdx` | `document/content/introduction/guide/knowledge_base/dataset_engine.en.mdx` | mdx | 更新 | 已完成 |
| `document/content/introduction/guide/dashboard/workflow/dataset_search.mdx` | `document/content/introduction/guide/dashboard/workflow/dataset_search.en.mdx` | mdx | 更新 | 已完成 |
### 7.3 翻译约束清单
- 保持不变:import、图片路径、URL、HTML/JSX 结构、表格结构、代码块字段名。
- 必须翻译:frontmatter、正文、表格文字、中文注释。
- 术语建议:
- 图搜图:`image-to-image search`
- 多模态图片索引:`multimodal image index`
- 图片自动索引:`automatic image indexing`
- 搜索配置:`Search configuration`
### 7.4 缺失文件与提醒
| 缺失英文文件 | 对应中文文件 | 处理建议 |
|---|---|---|
| 无 | 知识库功能文档 | 已复用现有中英文对应文件 |
| 无 | 工作流知识库搜索节点文档 | 已复用现有中英文对应文件 |
## 8. 测试与验证
测试规范来源:`references/testing-standards.md`。
### 8.1 测试文件映射(必填)
| 源文件路径 | 文件类型 | 目标测试文件路径 | 是否跳过 | 跳过理由 |
|---|---|---|---|---|
| `packages/global/core/ai/model.schema.ts` | packages | `test/cases/global/core/ai/model.schema.test.ts` | 否 | schema 兼容性需测 |
| `packages/service/core/ai/model.ts` | packages | `test/cases/service/core/ai/model.test.ts` | 否 | helper 需测 |
| `projects/app/src/pages/api/core/ai/model/update.ts` | projects | `projects/app/test/pages/api/core/ai/model/update.test.ts` | 否 | embedding `vision` 保存需测 |
| `projects/app/src/pages/api/core/ai/model/list.ts` | projects | `projects/app/test/pages/api/core/ai/model/list.test.ts` | 否 | embedding `vision` 返回需测 |
| `projects/app/src/pageComponents/account/model/AddModelBox.tsx` | projects | `projects/app/test/pageComponents/account/model/AddModelBox.test.tsx` | 否 | 支持图片识别开关需测 |
| `projects/app/src/pageComponents/account/model/ModelConfigTable.tsx` | projects | `projects/app/test/pageComponents/account/model/ModelConfigTable.test.tsx` | 否 | 模型配置列表标签需测 |
| `projects/app/src/pageComponents/dataset/list/CreateModal.tsx` | projects | `projects/app/test/pageComponents/dataset/list/CreateModal.test.tsx` | 否 | 创建知识库字段提示文案需测 |
| `projects/app/src/pageComponents/dataset/detail/Form/CollectionChunkForm.tsx` | projects | `projects/app/test/pageComponents/dataset/detail/Form/CollectionChunkForm.test.tsx` | 否 | 图片自动索引矩阵需测 |
| `packages/service/core/ai/embedding/index.ts` | packages | `test/cases/service/core/ai/embedding/index.test.ts` | 否 | 图片 embedding 入参和错误分支 |
| `projects/app/src/service/core/dataset/data/controller.ts` | projects | `projects/app/test/service/core/dataset/data/controller.test.ts` | 否 | 图片/文本索引分流 |
| `projects/app/src/service/core/dataset/queues/generateVector.ts` | projects | `projects/app/test/service/core/dataset/queues/generateVector.test.ts` | 否 | 重建分流 |
| `packages/service/core/dataset/search/controller.ts` | packages | `test/cases/service/core/dataset/search/controller.test.ts` | 否 | 图文搜索核心 |
| `projects/app/src/pages/api/core/dataset/searchTest.ts` | projects | `projects/app/test/pages/api/core/dataset/searchTest.test.ts` | 否 | API schema 与空输入 |
| `packages/service/core/workflow/dispatch/dataset/search.ts` | packages | `test/cases/service/core/workflow/dispatch/dataset/search.test.ts` | 否 | `Array<string>` 归一化 |
| `projects/app/src/pageComponents/dataset/detail/Test.tsx` | projects | `projects/app/test/pageComponents/dataset/detail/Test.test.tsx` | 否 | 搜索测试页 UI |
| `projects/app/src/web/core/dataset/store/searchTest.ts` | projects | `projects/app/test/web/core/dataset/store/searchTest.test.ts` | 否 | 搜索历史兼容 |
| `projects/app/src/components/Select/AIModelSelector.tsx` | projects | `projects/app/test/components/Select/AIModelSelector.test.tsx` | 否 | 标签顺序 |
| `projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx` | projects | `projects/app/test/pageComponents/dataset/detail/InputDataModal.test.tsx` | 否 | 分块弹窗样式、索引可见性和删除交互 |
| `packages/global/core/dataset/constants.ts` | packages | N/A | 是 | 纯 enum/map,随引用测试覆盖 |
| `packages/global/core/dataset/data/constants.ts` | packages | N/A | 是 | 纯 enum/map,随引用测试覆盖 |
### 8.2 自动化测试设计
| 类型 | 用例 | 预期结果 |
|---|---|---|
| 单元测试 | 老 embedding 模型无 `vision` | helper 返回 text-only |
| 单元测试 | 多模态模型 `vision=true` | 支持 image |
| 单元测试 | 模型更新 API 保存 embedding `vision=true` | 列表 API 返回相同能力,模型下拉可识别为多模态 |
| 单元测试 | 模型更新 API 关闭支持图片识别 | `vision=false` 或缺省,模型按 text-only 判断 |
| 单元测试 | LLM 与 embedding 均存在 `vision` | helper 按模型类型隔离语义,embedding 场景只判断图片向量化能力 |
| 单元/i18n 检查 | 创建知识库索引模型 tip key | 中文文案为 `索引模型可以将知识库内容转成向量,用于进行语义检索。注意,不同索引模型的知识库无法同时查询,切换索引模型需重建全量向量索引,请慎重选择。`,en/zh-Hant key 不缺失 |
| 单元/i18n 检查 | 多模态 hover tip key | 中文文案为 `多模态索引模型可以给图片生成向量。`,en/zh-Hant key 不缺失 |
| 单元/i18n 检查 | 图片理解模型 tip key | 中文文案为 `自动标注文档里的图片并生成文本描述,辅助文本检索`,en/zh-Hant key 不缺失 |
| 单元测试 | 图片自动索引非商业版 | disabled,商业版提示 |
| 单元测试 | 多模态 + 有 VLM | enabled,提示图片向量 + 文本描述 |
| 单元测试 | 多模态 + 无 VLM | enabled,提示图片向量 |
| 单元测试 | 普通索引 + 有 VLM | enabled,提示 VLM 文本描述 |
| 单元测试 | 普通索引 + 无 VLM | disabled,提示配置 VLM 或切换模型 |
| 单元测试 | 普通 embedding 切多模态 embedding | 知识库进入全库重建/待重建状态,后续训练策略切换为图片向量索引 |
| 单元测试 | 多模态 embedding 切普通 embedding 且无 VLM | 强制 `imageIndex=false`,知识库进入全库重建/待重建状态,后续训练策略不再生成图片向量索引 |
| 单元测试 | 图文文档初次导入,`trainingData.q` 含 markdown 图片且模型 `vision=true` | `insertData()` 建索引前追加 `DatasetDataIndexTypeEnum.imageEmbedding`,图片走 `getVectorsByImage` |
| 单元测试 | 图文文档重建,`trainingData.q` 不含图片但 `trainingData.data.q` 含 markdown 图片 | `rebuildData()` 仍追加 `imageEmbedding`,避免 VLM 后续改写训练文本导致漏图 |
| 单元测试 | 图文文档已有相同 `imageEmbedding` | 不重复追加同一图片 URL |
| 单元测试 | 普通 embedding 或 `collection.imageIndex=false` 的图文文档 | 不追加 `imageEmbedding`,只保留文本类索引 |
| 单元测试 | 搜索测试空输入 | text 和 queryImageUrls 都空时报错 |
| 单元测试 | 搜索测试仅图片输入 | text 为空、queryImageUrls 有值时允许搜索 |
| 单元测试 | 搜索测试图片超过 10 张 | API 校验失败,前端提示 `最多支持上传10张图片` |
| 单元测试 | 搜索测试上传非图片文件 | 非图片文件被过滤,不生成 queryImageUrls |
| 单元测试 | 搜索测试上传超出系统格式/大小限制的图片 | 直接过滤,不进入待上传/待搜索列表 |
| 单元测试 | 搜索测试上传图片过期时间 | 上传成功后写入 S3 TTL,`expiredTime` 为当前时间后 3 小时 |
| 单元/组件测试 | 搜索测试无图片搜索能力 | 普通 embedding 且无 VLM 时图片上传按钮 disabled,hover 展示 `请配置图片理解模型或多模态索引模型` |
| 单元测试 | 多模态 embedding 纯图片搜索 | 走 image modality,检索 `imageEmbedding` |
| 单元测试 | 多模态 embedding + VLM 纯图片搜索 | 同时走图片向量召回和查询图片 VLM caption 文本召回 |
| 单元测试 | 多模态 embedding + VLM 图文混合搜索 | 用户文本、图片向量、图片 caption 三路召回并 RRF 合并 |
| 单元测试 | 普通 embedding + VLM 纯图片搜索 | 查询图片先转 caption,再走普通文本检索 |
| 单元测试 | 普通 embedding + VLM 图文混合搜索 | 用户文本和图片 caption 作为多路文本 query 合并 |
| 单元测试 | 普通 embedding + 无 VLM 纯图片搜索 | 不做图片检索,返回空召回结果,不抛错 |
| 单元测试 | 普通 embedding + 无 VLM 图文混合搜索 | 忽略图片分支,只使用用户文本检索 |
| 单元测试 | 工作流 `userChatInput` 为 string | 归一化为文本 query |
| 单元测试 | 工作流 `userChatInput` 为 array,含用户问题和图片链接 | 拆成 textQueries 和 queryImageUrls |
| 单元测试 | 工作流 `userChatInput` 为 array,含 PDF/docx/xlsx 等非图片文件链接 | 非图片文件链接被过滤,`filteredFileCount` 增加,不进入 textQueries/queryImageUrls |
| 单元测试 | 工作流 `userChatInput` 只有非图片文件链接 | 返回空检索结果,不抛错,nodeResponse/log 记录过滤数量 |
| 单元测试 | 多图搜索 | 每张图单独召回,RRF 合并 |
| 单元测试 | 重建图片索引 | 图片索引走图片 embedding |
| 组件测试 | 模型下拉标签 | `Beta` 在 `多模态` 前 |
| 组件测试 | 模型下拉多模态 hover | hover `多模态` 标签展示 `多模态索引模型可以给图片生成向量。`,hover `Beta` 不展示该说明 |
| 组件测试 | 创建知识库弹窗 QuestionTip | `索引模型` 和 `图片理解模型` 问号分别展示固定文案 |
| 组件测试 | embedding 模型设置表单 | `支持图片识别` 默认关闭;打开后提交 `vision=true`;关闭后提交 `vision=false` 或缺省 |
| 组件测试 | 模型配置列表标签 | `vision=true` 的 embedding 模型展示 `Beta`、`多模态`,普通 embedding 不展示多模态 |
| 组件测试 | 搜索测试图片上传 UI | 图片按钮位于输入框左下角;缩略图、删除按钮、上传中卡片按设计展示 |
| 组件测试 | 搜索测试纯图片 | 只上传图片不输入文字时,测试按钮可用并提交 queryImageUrls |
| 组件测试 | 搜索测试图片按钮禁用 | 普通 embedding 且无 VLM 时图片按钮不可点击,hover 展示 `请配置图片理解模型或多模态索引模型` |
| 组件测试 | 搜索测试图片数量限制 | 选择第 11 张图片时提示 `最多支持上传10张图片`,列表最多保留 10 张 |
| 组件测试 | 搜索测试历史项 | 不显示搜索模式 icon/title |
| 组件测试 | 搜索测试图片历史 hover | 含图片历史显示 `[图片]` token;hover 后展示缩略图浮层;纯文本历史不展示浮层 |
| 组件测试 | 搜索配置按钮 | 点击打开参数弹窗 |
| 组件测试 | 图片分块弹窗 | 展示图片预览和多模态图片索引卡 |
| 组件测试 | 多模态图片索引展开 | 不展示内部索引内容,只展示默认说明文案 |
| 组件测试 | 数据索引删除入口 | 默认索引和多模态图片索引不展示删除入口;推测问题索引、摘要索引、自定义索引展示删除入口 |
| 单元/组件测试 | 删除可删除索引 | 删除成功后更新 `indexes[]` 和向量索引状态,右侧数量刷新;默认索引和多模态图片索引拒绝删除 |
### 8.3 场景覆盖核对
| 场景 | 是否覆盖 | 对应用例/describe |
|---|---|---|
| 基础场景 | 是 | 纯文本、纯图、图文混合 |
| 复杂场景 | 是 | 多图、多路 RRF、工作流同槽位多引用 |
| 边界值 | 是 | 空输入、纯图片输入、图片超过 10 张、非图片文件过滤、系统格式/大小限制过滤、模型不支持 image、无 VLM |
| 安全边界 | 是 | 不记录 base64/完整私有 URL;历史仅摘要 |
| 异常场景 | 是 | 上传失败、图片读取失败、embedding API 异常、向量维度不匹配 |
| 兼容场景 | 是 | 旧模型无 `vision`、旧工作流 string、旧搜索历史 |
### 8.4 执行命令与结果
开发中优先局部测试:
```shell
pnpm test test/cases/service/core/ai/model.test.ts
pnpm test projects/app/test/pages/api/core/ai/model/update.test.ts
pnpm test projects/app/test/pages/api/core/ai/model/list.test.ts
pnpm test projects/app/test/pageComponents/account/model/AddModelBox.test.tsx
pnpm test projects/app/test/pageComponents/account/model/ModelConfigTable.test.tsx
pnpm test test/cases/service/core/ai/embedding/index.test.ts
pnpm test test/cases/service/core/dataset/search/controller.test.ts
pnpm test test/cases/service/core/workflow/dispatch/dataset/search.test.ts
pnpm test projects/app/test/pages/api/core/dataset/searchTest.test.ts
pnpm test projects/app/test/pageComponents/dataset/detail/Test.test.tsx
pnpm test projects/app/test/pageComponents/dataset/detail/InputDataModal.test.tsx
```
最终合并前:
```shell
pnpm test
pnpm lint
```
| 命令 | 结果 | 覆盖率 | 备注 |
|---|---|---|---|
| `pnpm run build:sdks` | 通过 | N/A | SDK 构建完成;Node 20 有 deprecated 提示,不影响结果 |
| `pnpm exec tsc --noEmit --pretty false --incremental false --project projects/app/tsconfig.json` | 通过 | N/A | app TypeScript 检查通过 |
| `pnpm exec prettier --config ./.prettierrc.js --check <changed files>` | 通过 | N/A | 已覆盖本次改动的 TS/TSX/JSON/MDX/Markdown 文件 |
| `pnpm exec eslint --ignore-path .eslintignore <changed TS/TSX files>` | 通过 | N/A | 仅有 monorepo root 下执行导致的 Pages directory/React version 环境警告 |
| `git diff --check` | 通过 | N/A | 无 whitespace error |
| i18n JSON parse | 通过 | N/A | `common/dataset/file/account/account_model` 三语言 JSON 均可解析 |
| `pnpm --filter @fastgpt/service test -- test/core/ai/embedding/index.test.ts` | 通过 | Statements 32.31% | 实际执行了 `@fastgpt/service` 测试包:86 个测试文件通过、1 个跳过;2109 个测试通过、26 个跳过 |
| `pnpm exec tsc --noEmit --pretty false --incremental false --project projects/app/tsconfig.json` | 通过 | N/A | 反向核对补齐重建链路、搜索测试页 UI 和工作流归一化后再次通过 |
| `pnpm --filter @fastgpt/admin typecheck` | 通过 | N/A | `pro/admin` 图片索引分流改动后再次通过 |
| 搜索测试页 UI 反向核对 | 已补齐 | N/A | `搜索配置` 按钮、输入标题、图片缩略图顶部、上传按钮、测试按钮下移、历史标题去 icon 已按文档修正 |
| 工作流输入归一化反向核对 | 已补齐 | N/A | 避免无后缀普通 URL 被 parser 误判为非图片文件后过滤 |
| 模型切换后图片自动索引反向核对 | 已补齐 | N/A | 切到普通 embedding 且无 VLM 时,后端清理 dataset/collection 的 `imageIndex=false`,避免后续重训继续按图片索引模式入队 |
pro/admin 补充验证:
| 范围 | 状态 | 说明 |
|---|---|---|
| `pro/admin/src/service/core/dataset/training/imageParse.ts` | 已核查复用 | 图片数据集有 VLM 时继续走 VLM 文本描述,再由 chunk 入库链路按多模态模型自动补 `imageEmbedding` |
| `pro/admin/src/service/core/dataset/training/imageIndex.ts` | 已补齐职责边界 | 文档 Markdown 图片仅由 VLM 生成文本描述索引,并保留原始 `q` 中的 markdown 图片引用;`imageEmbedding` 由后续 `generateVector` 建索引阶段统一补齐 |
| `pnpm --filter @fastgpt/admin typecheck` | 通过 | pro/admin TypeScript 检查通过 |
### 8.5 手工验证
| 场景 | 操作步骤 | 预期结果 |
|---|---|---|
| embedding 模型支持图片开关 | 进入模型配置页,打开某个 embedding 模型设置 | `支持图片识别` 默认关闭;打开保存后该模型配置写入 `vision=true`;关闭保存后 `vision=false` 或缺省 |
| 模型配置列表标签 | 保存打开图片识别的 embedding 模型后返回模型列表 | 该模型展示 `Beta`、`多模态` 标签;关闭后 `多模态` 标签消失 |
| 创建知识库模型提示 | 打开创建通用知识库弹窗,hover `索引模型` 问号和 `图片理解模型` 问号 | 分别展示本文档固定文案 |
| 创建知识库模型下拉 | 打开创建通用知识库弹窗,展开索引模型 | 多模态模型展示 `Beta`、`多模态`,顺序正确;hover `多模态` 标签展示固定说明 |
| 图片自动索引非商业版 | 模拟 `feConfigs.isPlus=false` | 复选框禁用,提示升级商业版 |
| 图片自动索引多模态无 VLM | 选择多模态索引模型且不配 VLM | 复选框可用,提示生成图片向量索引 |
| 图片自动索引普通无 VLM | 选择普通索引模型且不配 VLM | 复选框禁用,提示配置 VLM 或切换多模态 |
| 索引模型切换 | 将知识库索引模型从普通 embedding 切到多模态 embedding,再切回普通 embedding | 图片自动索引状态和提示实时变化;切回普通且无 VLM 时 `imageIndex` 被清理为 false;知识库进入全库重建/待重建状态 |
| DOCX 图文分块初次导入 | 使用多模态 embedding + VLM,导入包含内嵌图片的 DOCX 并开启图片自动索引 | 同一分块同时存在 VLM 文本索引和 `多模态图片索引`;图片本体可参与图搜图 |
| PDF/HTML/Markdown 图文分块导入 | 使用能产出 markdown 图片的 PDF 解析服务、HTML 或 Markdown 导入并开启图片自动索引 | 不按文件格式分支,只要分块 `q` 含 markdown 图片,就生成 `imageEmbedding` |
| 图片分块弹窗 | 打开图片数据分块 | 左侧图片预览和图片内容,右侧数据索引卡 |
| 多模态图片索引内容 | 展开右侧 `多模态图片索引` 卡片 | 不展示向量、图片 URL、S3 key 或原始索引内容,只展示 `已通过多模态模型生成图片向量,支持以图搜图` |
| 数据索引删除 | 在分块弹窗右侧查看默认索引、多模态图片索引和其他索引 | 默认索引和多模态图片索引无删除入口;其他索引可删除,删除后卡片和数量刷新 |
| 搜索测试纯图 | 点击输入框左下角图片按钮上传本地图片后点击测试 | 图片缩略图展示正常,能以图片参与检索 |
| 搜索测试纯图片无文字 | 在支持图片搜索的知识库中只上传图片,不输入文字,点击测试 | 前端允许提交,后端接收 `queryImageUrls` 并执行图片检索 |
| 搜索测试无图片搜索能力 | 使用普通 embedding 且无 VLM 的知识库打开搜索测试页 | 图片上传按钮 disabled;hover 展示 `请配置图片理解模型或多模态索引模型`;文本搜索仍可用 |
| 搜索测试图片数量限制 | 连续选择超过 10 张图片 | 前端提示 `最多支持上传10张图片`,待搜索图片列表最多 10 张 |
| 搜索测试非图片文件 | 通过选择/拖拽/粘贴尝试加入 PDF、docx、xlsx 等文件 | 非图片文件直接过滤,不出现在待搜索图片列表 |
| 搜索测试图片过期时间 | 上传一张搜索测试图片后检查 TTL 记录 | 上传对象过期时间为上传后 3 小时,历史/store 不保存 base64 或完整私有 URL |
| 搜索测试系统限制过滤 | 上传系统不支持格式或超出系统大小限制的图片 | 图片直接过滤,不进入上传中卡片和 queryImageUrls |
| 搜索测试图文混合 | 输入文本并上传图片 | 文本和图片召回合并 |
| 多模态 + VLM 图文混合召回 | 使用多模态 embedding 且配置 VLM 的知识库,输入文字和图片 | 用户文本、图片向量、图片 caption 三路召回;同时命中图片索引和 VLM 文本索引的数据排序更靠前 |
| 普通 embedding + VLM 图片召回 | 使用普通 embedding 且配置 VLM 的知识库,只输入图片 | 图片先转文字,再走文本检索 |
| 普通 embedding 无 VLM 图片召回 | 使用普通 embedding 且无 VLM 的知识库,只输入图片或图文混合 | 纯图片返回空结果;图文混合只按文字检索 |
| 测试历史 | 连续测试后查看历史 | 历史标题和历史项无前置模式 icon/title |
| 图片历史 hover | 鼠标移到包含 `[图片]` 的历史项上 | 历史项下方弹出对应图片缩略图浮层,鼠标移出后消失 |
| 工作流节点默认值 | 新建知识库搜索节点 | 检索内容默认只引用 `流程开始 > 用户问题`,不默认引用文件链接 |
| 工作流节点手动加文件链接 | 在知识库搜索节点检索内容中手动追加文件链接引用 | 运行时拆出文本和图片,返回 quoteQA |
| 工作流非图片文件链接 | 检索内容接入用户问题、图片链接、PDF 链接、docx 链接 | 用户问题进入文本检索,图片链接进入图搜图,PDF/docx 链接被过滤且不报错 |
## 9. 质量自检清单
- [x] 旧文档未覆盖。
- [x] embedding 模型复用 `vision` 作为图片向量化能力字段,不新增 `modalities`。
- [x] embedding 模型 `支持图片识别` 开关默认关闭,打开才保存 `vision=true`。
- [x] `vision` 在 LLM 和 embedding 上通过模型类型/helper 隔离语义,没有在业务里裸读导致混用。
- [x] 模型下拉 `Beta` 在 `多模态` 前。
- [x] 创建知识库 `索引模型` 问号、`图片理解模型` 问号和 `多模态` 标签 hover 使用固定 i18n 文案。
- [x] “检索内容”本身为 `Array<string>`,不是新增外露 `fileUrlList` 字段替代。
- [x] 工作流知识库搜索只把图片文件链接放入 `queryImageUrls`,PDF/docx/xlsx 等非图片文件链接被后端过滤,且普通无后缀 URL 不被误过滤。
- [x] 搜索召回按知识库模型能力分支:多模态 embedding 直接图/文检索,普通 embedding 只能通过 VLM caption 处理图片。
- [x] 多模态 embedding + VLM 时,图片向量召回和 VLM caption 文本召回都参与 RRF;同一数据多路命中排序权重提升。
- [x] 普通 embedding + 无 VLM 时不做图片检索,纯图片返回空召回,图文混合只用文字。
- [x] 搜索测试支持本地上传图片,且图片按钮、缩略图、删除按钮、上传中卡片符合最新 UI。
- [x] 搜索测试只支持上传图片,不支持文件;允许纯图片无文字搜索。
- [x] 普通 embedding 且无 VLM 时搜索测试图片上传按钮禁用,hover 提示 `请配置图片理解模型或多模态索引模型`。
- [x] 搜索测试最多 10 张图片,超过提示 `最多支持上传10张图片`。
- [x] 搜索测试图片大小和格式跟随系统上传规则,不符合规则的输入直接过滤。
- [x] 搜索测试上传图片对象设置 3 小时过期时间,并写入 S3 TTL 清理链路。
- [x] 图片检索历史显示 `[图片]` token,hover 后展示图片缩略图浮层,纯文本历史不展示浮层。
- [x] 多模态图片索引内容不可见,只展示默认说明文案。
- [x] 默认索引和多模态图片索引不可删除;其他索引可以删除。
- [x] 图片自动索引 5 场景矩阵全部实现。
- [x] 普通索引 + 无 VLM 时图片自动索引禁用且提交值为 false。
- [x] 多模态 + 无 VLM 时仍可生成图片向量索引。
- [x] embedding/索引模型或 VLM 切换后,图片自动索引可用性、后续训练方式和全库重建/待重建状态同步更新;切到普通 embedding 且无 VLM 时后端清理 `imageIndex=false`。
- [x] 图片向量索引和 VLM 图片文本索引不会混淆。
- [x] 重建索引按索引类型分流。
- [x] API 使用 zod schema parse。
- [x] 工作流旧 string 输入兼容。
- [x] 日志不记录 base64、完整私有 URL、完整用户输入。
- [x] i18n 覆盖 zh-CN/en/zh-Hant。
- [x] OpenAPI 和功能文档中英文同步。
- [x] 局部测试和最终检查通过。
## 10. 发布与回滚
### 10.1 发布步骤
1. 合并模型配置 `vision`,先确认现网 embedding 模型缺省字段可解析。
2. 发布后端搜索核心和训练分流,默认不影响 text-only 模型。
3. 发布前端 UI,开启多模态标签、图片自动索引矩阵、搜索测试图片上传。
4. 更新文档和 OpenAPI。
5. 灰度验证多模态模型数据集的图片导入、搜索测试、工作流搜索。
### 10.2 回滚触发条件
- 图片 embedding 大面积失败或向量维度不兼容。
- 工作流 `Array<string>` 兼容旧节点出现运行异常。
- 搜索测试图片上传存在权限或隐私风险。
- 图片自动索引误启用导致普通索引无 VLM 的知识库生成异常任务。
### 10.3 回滚动作
1. 前端隐藏搜索测试图片上传和多模态图片索引入口。
2. 模型配置关闭 embedding `vision`,前端自动回到普通模型表现。
3. 后端保留输入兼容层,但关闭图片 query 分支。
4. 停止新生成 `imageEmbedding` 索引,保留已有索引不影响文本检索。
5. 若工作流新模板有问题,回退模板但保留运行时兼容,避免已有应用崩溃。
# 需求设计文档
# 需求设计文档
## 0. 文档标识
- 任务前缀:`图搜图-当前需求`
- 文档文件名:`图搜图-当前需求-需求设计文档.md`
- 更新时间:2026-05-01
- 文档状态:`v2.0 反向核对完成,补齐重建链路与搜索测试页 UI`
- 文档定位:基于用户提供的两份 PDF、UI 截图、最新补充确认口径,以及当前 FastGPT 仓库事实,重新定义图搜图功能的产品边界、前后端影响域和实现策略。
## 1. 需求背景与目标
### 1.1 背景
当前 FastGPT 知识库已支持文本向量检索、全文检索、混合检索、VLM 图片解析、图片文本索引和知识库搜索节点,但还没有完整的“图片作为查询输入,检索相似图片/图文分块”的链路。
用户提供的资料包括:
- 产品设计稿:`/Users/xxyyh/Downloads/图搜图产品设计.pdf`
- 团队讨论稿:`/Users/xxyyh/Downloads/图片检索图片的技术方案.pdf`
- 用户补充 UI 图:创建通用知识库弹窗、索引模型下拉、文件分块弹窗、图片分块弹窗、知识库搜索节点、搜索测试页、搜索测试图片上传态;图片自动索引为逻辑矩阵,不作为 UI 图放入。
注意:团队讨论稿里已有方案只作为参考,不能机械照搬。本文档以用户最新确认口径为准。
### 1.2 最新确认口径
| 编号 | 维度 | 最新确认结果 | 设计结论 |
|---|---|---|---|
| C1 | 模型能力字段 | embedding 模型复用 `vision?: boolean` | 老 embedding 模型缺省按 `vision=false` 兼容;`vision=true` 表示该 embedding 模型支持图片向量化 |
| C2 | 模型标签顺序 | `Beta` 放在 `多模态` 前面 | 模型下拉展示顺序为 `模型名`、`Beta`、`多模态` |
| C3 | 工作流检索内容 | 把“检索内容”本身改成 `Array<string>`,同时接“用户问题”和“文件链接” | 不再新增一个单独外露的文件链接字段;后端归一化时从数组中拆文本和图片链接,非图片文件链接过滤掉 |
| C4 | 搜索测试图片来源 | 允许本地上传图片,不支持上传文件,支持仅上传图片不带文字 | 搜索测试页输入框左下角增加图片上传按钮;已上传图片在输入框顶部以缩略图排列,上传中展示独立 loading 卡片;图片检索历史 hover 时展示图片缩略图浮层 |
| C5 | 普通索引 + 无 VLM 搜索逻辑 | 搜索阶段和现在逻辑一样;创建/索引增强阶段控制不可选 | “图片自动索引”在不满足条件时禁用,提示文字根据索引模型和 VLM 配置动态变化 |
| C6 | embedding 模型是否支持图片 | 在模型配置页里由用户手动打开“支持图片识别”开关,默认关闭 | 开关打开后保存 `vision=true`;未打开或老模型无 `vision` 时按 text-only 处理 |
| C7 | 创建知识库提示文案 | 索引模型问号、多模态标签 hover、图片理解模型问号需要使用用户最新指定文案 | 创建弹窗和索引模型下拉必须接入固定 i18n 文案,不能复用旧文案或临时写死 |
| C8 | 搜索测试图片限制 | 最多 10 张;超出提示 `最多支持上传10张图片`;图片大小和格式跟随系统,超出的直接过滤 | 前端选择和后端上传都只接受图片;非图片文件、系统不支持格式、超出大小的图片直接过滤,不进入待搜索列表 |
| C9 | 图文混合检索召回分支 | 召回方式必须按知识库 embedding 是否多模态、是否配置 VLM 分情况处理 | 多模态 embedding 直接走 text/image modality 检索;有 VLM 时额外走图片转文字召回并合并;普通 embedding 有 VLM 时先将查询图片转文字再检索;普通 embedding 无 VLM 时不做图片检索 |
| C10 | 模型字段版本讨论 | 曾讨论过给 embedding 加 `version` 或新增 `modalities`,最终决定不采用 | 直接复用现有 `vision` 能力字段,减少配置字段扩散 |
| C11 | 索引类型命名 | 现有 `image` 先不改名,新多模态图片向量索引命名为 `imageEmbedding` | `image` 继续表示现有 VLM 图片文本索引,`imageEmbedding` 表示新增图片向量索引 |
| C12 | 搜索测试无图片能力时上传入口 | 普通 embedding 且无 VLM 时,不允许在搜索测试页上传图片 | 图片上传按钮 disabled,hover 提示 `请配置图片理解模型或多模态索引模型`;后端仍兜底兼容绕过前端的 `queryImageUrls` |
| C13 | 搜索测试图片过期 | 本地上传到搜索测试的图片只作临时检索输入,过期时间固定 3 小时 | 上传对象必须写入 TTL/过期记录,`expiredTime = addHours(new Date(), 3)`;搜索历史只保存受控缩略图引用和数量,不保存 base64 或完整私有预签名 URL |
### 1.3 业务目标
- 创建知识库时,用户能直观看到哪些索引模型支持多模态能力。
- 创建知识库时,用户能通过 `索引模型` 问号提示理解向量索引用途、跨模型查询限制和切换模型需重建全量向量索引的风险。
- 用户 hover `多模态` 标签时,能看到“多模态索引模型可以给图片生成向量。”的能力解释。
- 创建知识库时,用户能通过 `图片理解模型` 问号提示理解 VLM 会自动标注文档图片并生成文本描述,辅助文本检索。
- 知识库导入图片或含图片文档时,可生成“多模态图片索引”,支持以图搜图。
- 搜索测试页支持本地上传图片,支持纯文本、纯图、图文混合测试;本地上传只支持图片,不支持文件;最多 10 张,超出提示 `最多支持上传10张图片`;搜索测试上传图片只作为临时检索输入,上传对象过期时间固定 3 小时。若当前知识库既没有多模态索引模型也没有图片理解模型,则禁用图片上传按钮并提示 `请配置图片理解模型或多模态索引模型`。
- 图文混合检索按知识库能力分支召回:多模态 embedding 直接检索图片向量和文本向量;有 VLM 时合并 VLM 文本描述索引;普通 embedding 仅在配置 VLM 时把查询图片转成文字后参与文本检索。
- 工作流知识库搜索节点的“检索内容”改为 `Array<string>`,可同时接入用户问题和文件链接;文件链接里只有图片链接参与图搜图,PDF、docx、表格等非图片文件链接由后端过滤。
- 文件分块与图片分块弹窗按新 UI 展示右侧数据索引卡片,图片分块展示图片预览和图片内容。
- 索引增强里的“图片自动索引”根据商业版、多模态索引模型、VLM 配置动态决定可用性和提示文案。
### 1.4 技术目标
- 不新增知识库类型。
- 不新增搜索模式大类。
- 不改变现有训练状态展示口径。
- 复用现有向量库、RRF 合并、知识库权限、工作流变量引用体系。
- 将“VLM 图片文本索引”和“多模态图片向量索引”明确分开,避免重建和检索走错链路。
## 2. 当前项目事实基线(基于代码)
| 能力项 | 现有实现位置(文件路径/符号) | 现状说明 | 结论 |
|---|---|---|---|
| 创建知识库 API | `projects/app/src/pages/api/core/dataset/create.ts` | 接收 `vectorModel/agentModel/vlmModel`,没有多模态能力判断 | 修改 |
| 创建知识库 schema | `packages/global/openapi/core/dataset/api.ts` `CreateDatasetBodySchema` | 已有 `vectorModel/agentModel/vlmModel` | 复用 |
| Dataset schema | `packages/service/core/dataset/schema.ts`、`packages/global/core/dataset/type.ts` | 知识库存 `vectorModel/agentModel/vlmModel/chunkSettings`,不冗余保存模型能力 | 不新增 Dataset 字段,能力从模型配置读取 |
| Embedding 模型 schema | `packages/global/core/ai/model.schema.ts` `EmbeddingModelItemSchema` | 当前无 `vision` 字段;LLM 已使用 `vision` 表示图片能力 | 修改,embedding 复用 `vision?: boolean` |
| 模型读取 | `packages/service/core/ai/model.ts` `getEmbeddingModel` | 可按模型名读取 embedding 配置 | 增加 helper |
| 模型配置页 | `projects/app/src/pageComponents/account/model/ModelConfigTable.tsx`、`projects/app/src/pageComponents/account/model/AddModelBox.tsx` | 已有模型配置列表;LLM 模型已有 `功能配置` 区域和 `支持图片识别` 开关,embedding 模型暂无 `vision` 配置 UI | 修改,embedding 模型设置页新增同风格功能配置 |
| 模型配置 API | `projects/app/src/pages/api/core/ai/model/update.ts`、`projects/app/src/pages/api/core/ai/model/list.ts` | 更新/列表返回模型配置,当前仅 LLM 暴露 `vision` 等能力 | 修改,支持 embedding `vision` 保存和返回 |
| 模型选择器 | `projects/app/src/components/Select/AIModelSelector.tsx` | 只显示 provider icon、模型名、Beta 标签 | 修改,增加多模态标签并调整顺序 |
| 创建知识库弹窗 | `projects/app/src/pageComponents/dataset/list/CreateModal.tsx` | 已有索引模型、文本理解模型、图片理解模型字段,但布局与新稿不一致 | 修改 |
| 图片上传导入 | `projects/app/src/pageComponents/dataset/detail/Import/diffSource/ImageDataset.tsx` | 支持本地图片导入知识库 | 复用并扩展训练后索引生成 |
| 追加图片数据 | `projects/app/src/pages/api/core/dataset/data/insertImages.ts` | 图片上传后进入 `TrainingModeEnum.imageParse` | 修改,按模型能力补图片向量索引 |
| 图片集合创建 | `projects/app/src/pages/api/core/dataset/collection/create/images.ts` | 当前要求 `dataset.vlmModel`,否则报错 | 需调整:多模态索引模型可无 VLM 生成图片向量索引 |
| 图片解析队列 | `pro/admin/src/service/core/dataset/training/imageParse.ts` | VLM 解析图片为文本后转 chunk | 复用为“普通索引 + 有 VLM”的文本描述索引链路 |
| 图片文本索引队列 | `pro/admin/src/service/core/dataset/training/imageIndex.ts` | 为 Markdown 图片生成 VLM 文本索引,类型偏文本语义 | 与图片向量索引区分 |
| 数据索引类型 | `packages/global/core/dataset/data/constants.ts` `DatasetDataIndexTypeEnum` | 当前 `image` 表示现有 VLM 图片文本索引,虽然命名有歧义但先不改,避免牵扯历史数据 | 保留旧 `image`,新增多模态图片向量索引 `imageEmbedding` |
| 分块弹窗 | `projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx` | 文本和图片分块编辑都在此处,右侧已有 indexes 列表 | 修改成新卡片样式;补充索引内容可见性和删除规则 |
| 数据更新 API | `projects/app/src/pages/api/core/dataset/data/update.ts` | 更新 `q/a/indexes`,文本索引走 `updateData2Dataset` | 图片向量索引需分流 |
| Embedding 服务 | `packages/service/core/ai/embedding/index.ts` `getVectorsByText` | 只支持文本 input | 增加图片 embedding 能力 |
| 向量写入 | `packages/service/common/vectorDB/controller.ts` | 写向量时内部调用文本 embedding | 需要支持预计算向量/图片向量 |
| 搜索测试 schema | `packages/global/openapi/core/dataset/api.ts` `SearchDatasetTestBodySchema` | `text` 必填,无图片字段 | 修改 |
| 搜索测试 API | `projects/app/src/pages/api/core/dataset/searchTest.ts` | 只构造 `queries: [text]` | 修改 |
| 搜索核心 | `packages/service/core/dataset/search/controller.ts` `searchDatasetData` | 只支持文本 queries,向量召回调用 `getVectorsByText` | 修改 |
| 搜索分数 | `packages/global/core/dataset/constants.ts` `SearchScoreTypeEnum` | 无图片向量分数类型 | 推荐新增 `imageEmbedding` |
| RRF 合并 | `packages/global/core/dataset/search/utils.ts` `datasetSearchResultConcat` | 已支持多路列表按权重合并去重 | 复用 |
| 搜索测试页 | `projects/app/src/pageComponents/dataset/detail/Test.tsx` | 当前“语义检索”按钮在输入卡片内,测试按钮也在卡片内,历史项带模式 icon/title | 按新稿大改 |
| 搜索历史 store | `projects/app/src/web/core/dataset/store/searchTest.ts` | 只保存文本和搜索模式 | 修改,支持图片上传摘要与 hover 缩略图引用 |
| 工作流模板 | `packages/global/core/workflow/template/system/datasetSearch.ts` | “检索内容”复用 `Input_Template_UserChatInput`,类型为 string | 改为 `Array<string>` |
| 工作流类型 | `packages/global/core/workflow/runtime/type.ts` | `fileUrlList?: string[]` 已存在,但 dataset search 当前未使用 | 本期不单独外露,检索内容数组内部可含文件链接 |
| 工作流变量兼容 | `packages/global/core/workflow/constants.ts` | `arrayString` 可接收 string 和 arrayString 引用 | 可支撑“用户问题 + 文件链接”同槽位 |
| 文件解析工具 | `packages/service/core/workflow/dispatch/ai/chat.ts` `getInputFiles` | 当前内部用 `parseUrlToFileType` 将链接转文件信息 | 建议抽出公共 helper 给 dataset search 复用 |
| 图片自动索引配置 | `projects/app/src/pageComponents/dataset/detail/Form/CollectionChunkForm.tsx` | 当前只按商业版和 `datasetDetail?.vlmModel` 禁用 | 修改为按商业版、多模态索引、VLM 动态判断 |
| 图片自动索引默认值 | `projects/app/src/pageComponents/dataset/detail/Import/Context.tsx` `defaultFormData.imageIndex` | 默认 `false` | 保持默认不勾选 |
| 图片自动索引配置字段 | `packages/global/core/dataset/type.ts` `ChunkSettingsSchema.imageIndex` | 已存在 `imageIndex` | 复用字段,调整语义和提示 |
| i18n | `packages/web/i18n/zh-CN/dataset.json`、`packages/web/i18n/en/dataset.json`、`packages/web/i18n/zh-Hant/dataset.json` | 已有 `image_auto_parse`、`image_auto_parse_tips` | 需扩展动态文案 |
## 3. 需求澄清记录
| 维度 | 已确认内容 | 待确认内容 | 备注 |
|---|---|---|---|
| 业务目标 | 支持以图搜图、图文混合检索、搜索测试本地上传图片 | 无 | 已确认 |
| 模型能力 | embedding 复用 `vision?: boolean` | 无 | 已确认字段;不再新增 `version` 或 `modalities` |
| 标签顺序 | `Beta` 在 `多模态` 前 | 无 | 已确认 |
| 工作流输入 | “检索内容”本身改为 `Array<string>` | 非图片文件链接过滤策略需实现时按本文档落地 | 已确认方向;兼容旧 string 通过归一化层处理,不把 `string | string[]` 扩散到业务层 |
| 搜索测试图片 | 允许本地上传图片,上传对象 3 小时过期 | 上传接口复用还是新增临时上传接口由实现阶段评估 | 已确认能力;无论复用还是新增,都必须写入 3 小时 TTL |
| 搜索降级 | 搜索阶段保持现有逻辑;创建/索引增强阶段控制不可用 | 无 | 已确认 |
| 图片自动索引 | 非商业版、普通索引无 VLM 时禁用;提示随配置变化 | 无 | 已确认 |
| 文档更新 | 需要更新 OpenAPI、知识库、工作流文档 | 具体文档路径实现时再核对现有目录 | 命中 DocUpdate/DocI18n |
## 3.1 影响域判定(先判定,再核对规范)
| 维度 | 是否命中 | 证据(需求/代码锚点) | 核对规范 | 结论 |
|---|---|---|---|---|
| API | Yes | 搜索测试需支持图片,本地上传图片需后端可读 URL,工作流 dispatch 入参类型变化 | `style/api.md` | OpenAPI schema 必须用 zod parse |
| DB | Yes | 需新增/区分图片向量索引类型,或至少扩展索引语义 | `style/db.md` | 尽量不改 Mongo schema,仅扩展 enum/索引语义 |
| Front | Yes | 创建知识库、模型下拉、分块弹窗、搜索测试页、工作流节点、图片自动索引配置均变化 | `style/front.md` | 大量 UI 调整,需 i18n |
| Logger | Yes | 图片向量化、图片读取、模型不支持图片、向量维度不匹配需观测 | `style/logger.md` | 结构化日志且脱敏 |
| Package | Yes | 涉及 `packages/global`、`packages/service`、`packages/web`、`projects/app`、`pro/admin` | `style/package.md` | 类型放 global,服务放 service/app |
| BugFix | No | 新功能,不是线上 bug 修复 | `bug-fix-workflow.md` | N/A |
| DocUpdate | Yes | OpenAPI 与用户使用说明变化 | `doc-update-reminder.md` | 必须列文档更新提醒 |
| DocI18n | Yes | 中文文档若更新需同步英文 | `doc-i18n-standards.md` | 必须中英文同步 |
## 4. 范围定义
### 4.1 In Scope(本期必须)
1. `EmbeddingModelItemSchema` 增加 `vision?: boolean`,语义为该 embedding 模型是否支持图片向量化。
2. 模型配置页中 embedding 模型点击设置后,新增 `功能配置` 区域,包含 `支持图片识别` 开关;样式参考 LLM 模型参数设置/功能配置,默认关闭。
3. embedding 模型 `支持图片识别` 打开后,保存 `vision=true`;关闭时保存/恢复为 `vision=false` 或缺省,按 text-only 模型处理。
4. 创建知识库索引模型下拉展示 `Beta`、`多模态` 标签,且 `Beta` 在前。
5. 创建通用知识库弹窗按新稿调整宽度和字段布局,并更新字段提示:`索引模型` 问号、`图片理解模型` 问号和索引模型下拉中的 `多模态` 标签 hover 文案必须使用本文档固定口径。
6. 索引增强里的“图片自动索引”根据商业版、多模态索引模型、VLM 配置动态启用/禁用和展示提示。
7. 多模态索引模型导入图片时,即使未配置 VLM,也可生成图片向量索引。
8. 多模态索引模型 + VLM 时,同时生成图片向量索引和文本描述索引。
9. 普通索引模型 + VLM 时,沿用 VLM 自动标注图片并生成文本描述索引。
10. 普通索引模型 + 无 VLM 时,“图片自动索引”禁用,提示配置图片理解模型或切换多模态向量模型。
11. 文件分块点击弹窗按第三张图改样式:左侧内容,右侧数据索引卡片。
12. 图片分块点击弹窗按第四张图改样式:左侧图片预览 + 图片内容,右侧数据索引卡片。
13. 右侧数据索引卡片展示“多模态图片索引”卡片时,索引内容不可见,仅展示 UI 默认说明文案:`已通过多模态模型生成图片向量,支持以图搜图`。
14. 数据索引删除规则:`默认索引` 和 `多模态图片索引` 不可删除;其他索引可以删除,包括推测问题索引、摘要索引、自定义索引等。
15. 知识库 embedding/索引模型切换时,必须按切换后的 `vectorModel + vlmModel` 组合重新决定索引生成策略:多模态模型走图片向量索引能力,普通模型只走 VLM 文本描述索引能力。
16. embedding/索引模型或 VLM 模型切换后,知识库必须进入全库重建流程或明确待重建状态,所有索引按切换后的模型组合重新生成;不能继续混用旧模型生成的向量。
17. 搜索测试页按最新上传图片 UI 改版:`搜索配置`、测试按钮下移、历史标题和历史项去掉前置模式 icon/title。
18. 搜索测试支持本地上传图片并参与搜索;输入框左下角放图片上传按钮,顶部横向展示图片缩略图、删除按钮和上传中 loading 卡片。
19. 搜索测试图片上传只支持图片,不支持文件;支持仅上传图片、不输入文字直接测试;最多支持 10 张图片,超过时提示 `最多支持上传10张图片`。
20. 搜索测试图片大小和格式限制跟随系统现有上传规则;不符合系统规则的图片、非图片文件直接过滤,不进入待上传/待搜索列表。
21. 搜索测试图片上传对象必须设置 3 小时过期时间,建议复用已有 S3 TTL 机制,写入 `expiredTime = addHours(new Date(), 3)`;不能复用正式图片集导入的长过期策略。
22. 若当前知识库 `!isImageEmbeddingModel(dataset.vectorModel) && !dataset.vlmModel`,搜索测试图片上传按钮 disabled,hover 提示 `请配置图片理解模型或多模态索引模型`;`SearchDatasetTestBodySchema` 仍保留 `queryImageUrls` 兼容能力,后端搜索核心兜底处理绕过前端的请求。
23. 搜索测试历史如果包含图片检索记录,列表中用 `[图片]` token 表示图片输入,鼠标 hover 到该历史项时弹出图片缩略图浮层。
24. 工作流知识库搜索节点的“检索内容”改为 `Array<string>`,可同时引用用户问题和文件链接。
25. 后端统一归一化检索输入:从 `Array<string>` 中拆出文本内容和图片文件链接;文件链接中只有 `ChatFileTypeEnum.image` 进入 `queryImageUrls`,非图片文件链接进入 filtered 统计并被忽略。
26. 搜索核心支持纯文本、纯图、图文混合、多图并行召回,并复用现有 RRF 合并排序。
27. 更新 OpenAPI、文档、i18n、测试。
### 4.2 Out of Scope(本期不做)
1. 不新增知识库类型。
2. 不新增搜索模式大类。
3. 不重做模型配置页整体框架,仅在 embedding 模型设置表单中新增图片能力开关。
4. 不做存量知识库自动迁移补图片向量。
5. 不重构所有工作流文件变量体系。
6. 不把图片 base64 写入搜索历史、日志或持久化 query。
7. 不做向量库多维度动态建表。图片 embedding 的向量维度与现有向量写入链路保持一致,维度不匹配沿用现有校验/报错路径处理。
## 5. 方案对比
| 方案 | 核心思路 | 优点 | 风险 | 实施成本 | 结论 |
|---|---|---|---|---|---|
| 方案A:只做 VLM 图转文检索 | 查询图片先 VLM caption,再走现有文本检索 | 改动较小 | 不是真正图搜图,且多模态索引模型能力无法体现 | 中 | 不采用为主方案 |
| 方案C:工作流新增单独文件链接输入 | 保留 `userChatInput: string`,额外展示 `fileUrlList: string[]` | 兼容性最好 | 不符合用户已确认“检索内容本身改 Array<string>” | 中 | 不采用 |
| 方案D:检索内容改 `Array<string>` | 一个“检索内容”输入同时接用户问题和文件链接,后端做输入归一化 | 符合最新 UI 和用户确认 | 需要兼容旧节点 string 值,后端要区分文本和文件链接 | 中 | 推荐 |
推荐组合:方案B + 方案D。
选型原则:
- 同等可行时优先最少新增字段、最少新增 UI、复用现有训练和搜索能力。
- 对用户已确认口径不再另起方案绕开,否则设计就是自嗨,开发还得返工。
## 6. 推荐方案详细设计
### 6.1 API 设计
| 路由/入口 | 方法 | 鉴权 | 请求 | 响应 | 错误分支 | 相关文件 |
|---|---|---|---|---|---|---|
| `/api/core/dataset/searchTest` | POST | `authDataset` Read | `text?: string`、`queryImageUrls?: string[]`、原搜索参数 | 复用现有 `SearchDatasetTestResponseSchema`,可扩展返回图片数量/归一化参数 | 文本和图片同时为空、图片超过 10 张、上传图片读取失败、模型不支持图片向量 | `packages/global/openapi/core/dataset/api.ts`、`projects/app/src/pages/api/core/dataset/searchTest.ts` |
| 搜索测试图片上传入口 | POST | 团队/知识库读权限或临时上传权限 | `multipart/form-data` 图片文件,不支持普通文件 | 可被搜索测试读取的图片 URL/S3 key/文件信息,上传对象 3 小时过期 | 非图片文件直接过滤;图片格式/大小跟随系统限制,超出直接过滤;上传失败;未写入 TTL | 可复用现有文件上传能力或新增临时接口 |
| 工作流 dataset search dispatch | internal | 工作流运行权限 | `userChatInput?: string \| string[]`,其中数组可含文本和文件链接 | `quoteQA`、`nodeResponse`、`toolResponses`,可附带过滤统计 | 检索内容为空、图片文件解析失败、非图片文件链接被过滤 | `packages/service/core/workflow/dispatch/dataset/search.ts` |
搜索测试请求示例:
```json
{
"datasetId": "68ad85a7463006c963799a05",
"text": "找一下类似这张图的内容",
"queryImageUrls": ["dataset/tmp/search-test/xxx.png"],
"limit": 5000,
"similarity": 0.4,
"searchMode": "mixedRecall",
"usingReRank": false
}
```
工作流节点运行时归一化前示例:
```json
{
"userChatInput": [
"用户想找红色花朵相关图片",
"https://example.com/files/query-flower.png"
]
}
```
工作流节点运行时归一化后示例:
```json
{
"textQueries": ["用户想找红色花朵相关图片"],
"queryImageUrls": ["https://example.com/files/query-flower.png"]
}
```
### 6.2 数据设计
| 实体/集合 | 字段 | 类型 | 必填 | 默认值 | 索引/约束 | 兼容策略 |
|---|---|---|---|---|---|---|
| `EmbeddingModelItemSchema` | `vision` | boolean | 否 | 缺省按 `false` 判断 | 无 | 老模型无需迁移;仅 embedding 场景语义为“支持图片向量化” |
| `DatasetDataIndexTypeEnum` | `imageEmbedding` | enum | 是,新增枚举 | N/A | 复用 `indexes.dataId` | 与现有 `image` 文本索引分开 |
| `MongoDatasetData.indexes[].text` | 图片索引引用 | string | 是 | N/A | 现有 schema | 图片索引存受控图片引用,不存 base64 |
| 向量库 | `vector` | number[] | 是 | N/A | 现有向量索引 | 多模态模型维度必须符合当前向量库约束 |
| `ChunkSettingsSchema.imageIndex` | 图片自动索引开关 | boolean | 否 | `false` | 现有字段 | 复用字段,不新增开关 |
### 6.3 图片自动索引可用性矩阵
| 场景 | 提示文案 | 按钮/复选框状态 | 设计结论 |
|---|---|---|---|
| 非商业版 | `请升级商业版后使用该功能` | 禁用,复选框灰置 | 沿用现有商业版限制 |
| 多模态索引 + 有 VLM | `为文档中的图片生成图片向量索引和文本描述索引,支持以图搜图` | 可用 | 同时走图片向量索引和 VLM 文本描述索引 |
| 多模态索引 + 无 VLM | `使用多模态模型为图片生成向量索引,支持以图搜图` | 可用 | 只生成图片向量索引 |
| 普通索引 + 有 VLM | `调用 VLM 自动标注文档里的图片,并生成文本描述索引` | 可用 | 保持现有图片自动索引逻辑 |
| 普通索引 + 无 VLM | `需配置图片理解模型,或切换多模态向量模型后,方可启用` | 禁用,复选框灰置 | 不允许勾选 |
实现注意:
- 这个矩阵作用在创建/导入/索引增强配置阶段,不改变搜索阶段“和现在的逻辑一样”的口径。
- 如果禁用时当前表单值为 `true`,前端需要自动置回 `false`,避免灰掉但提交 `true`,这种坑线上最爱冒烟。
- 多模态能力来自模型配置页 embedding 模型的“支持图片识别”开关,而不是模型名或模型供应商写死判断。开关默认关闭,只有用户主动打开后才把 `vision=true` 写入 embedding 模型配置。
- 当知识库 `vectorModel` 或 `vlmModel` 发生切换,整个知识库必须按新的模型组合重新生成索引,不能只局部清理 `imageIndex` 或继续沿用旧向量。
- 切换后的重建策略由 `vectorModel.vision` 和 `vlmModel` 共同决定:多模态 embedding + VLM 生成 `imageEmbedding` 与 VLM 文本索引;多模态 embedding + 无 VLM 只生成 `imageEmbedding`;普通 embedding + VLM 只生成 VLM 文本索引;普通 embedding + 无 VLM 不生成图片相关索引。
- 存量数据不会因为模型字段变化自动拥有新索引;必须通过全库重建把旧索引替换成切换后模型组合对应的新索引。
### 6.4 核心代码设计
| 模块 | 关键函数/类型 | 变更说明 | 上下游影响 |
|---|---|---|---|
| 模型能力 | `EmbeddingModelItemSchema`、`isImageEmbeddingModel` | 增加/复用 `vision?: boolean`,缺省 text-only | 前后端统一判断多模态 |
| 模型配置页 | `AddModelBox`、`ModelConfigTable`、`model/update`、`model/list` | embedding 模型新增 `支持图片识别` 开关,默认关闭,打开才写入 `vision=true` | 多模态标签、图片自动索引矩阵、训练分流的能力来源 |
| 模型下拉 | `AIModelSelector` | 展示 `Beta`、`多模态` 标签,`Beta` 前置;`多模态` 标签 hover 展示固定说明 | 创建知识库和其他使用处需避免误伤 |
| 图片自动索引 | `CollectionChunkForm.tsx` | 按矩阵计算 disabled、tooltip、tips | 导入、重训、网站配置等共用表单都受益 |
| 图片 embedding | `getVectorsByImage` | 图片 URL/S3 key 转模型输入,生成图片向量 | 训练和搜索共用 |
| 向量写入 | `insertDatasetDataVector` 或新增底层 helper | 支持外部传入预计算 vectors | 避免图片引用被文本 embedding |
| 图片训练 | `insertImages.ts`、`imageParse.ts`、`imageIndex.ts` | 按多模态/VLM 配置生成图片向量索引和/或文本描述索引 | 新数据可图搜图 |
| 重建索引 | `generateVector.ts` | 模型切换后全库重建;`imageEmbedding` 走图片 embedding,其他索引走文本 embedding | 重建不破坏图片索引,也不混用旧模型向量 |
| 索引卡片删除 | `InputDataModal.tsx`、`data/update.ts`、`data/controller.ts` | 默认索引和多模态图片索引保护;其他索引允许删除;多模态图片索引内容隐藏 | UI 和后端状态一致,避免假删除 |
| 搜索核心 | `searchDatasetData` | 增加 `imageQueries/queryImageUrls`,图片向量并行召回 | 搜索测试和工作流复用 |
| 工作流输入 | `datasetSearch.ts` | “检索内容”从 string 改 `Array<string>` | 前端变量引用可同时接用户问题和文件链接 |
| 工作流归一化 | `dispatch/dataset/search.ts` | 通过兼容层读取旧 string 或新 arrayString,并统一产出 `textQueries + queryImageUrls` | 兼容旧节点,业务层不扩散 `string | string[]` |
| 搜索测试 UI | `Test.tsx` | 输入框内图片上传按钮、顶部缩略图/删除/上传中卡片、搜索配置按钮、历史图片 hover 缩略图浮层 | 前端主要改动 |
### 6.5 技术实现流程图(必填)
```mermaid
flowchart TD
A["模型 schema<br/>EmbeddingModelItem.vision"] --> B["模型配置页<br/>支持图片识别开关"]
B --> C["创建知识库<br/>索引模型下拉显示 Beta + 多模态"]
C --> D["索引增强配置<br/>按商业版/多模态/VLM 决定图片自动索引可用性"]
D --> E["导入图片或含图片文档"]
E --> F["多模态图片索引<br/>getVectorsByImage 写向量库"]
E --> G["VLM 文本描述索引<br/>有 VLM 时生成文本索引"]
F --> H["搜索入口<br/>搜索测试 / 工作流知识库搜索"]
G --> H
H --> I["输入归一化<br/>文本 + 图片 URL"]
I --> J["文本召回<br/>文本向量/全文检索"]
I --> K["图片召回<br/>每张图单独生成 query vector"]
J --> L["RRF 合并排序"]
K --> L
L --> M["返回引用结果<br/>测试参数/测试结果/quoteQA"]
```
实现说明:
- embedding 模型图片能力复用 `vision` 字段,不再新增 `modalities` 或 `multiModal` 字段。
- `vision=true` 在 LLM 场景表示图片理解能力,在 embedding 场景表示图片向量化能力;必须通过 `isImageEmbeddingModel` 这类 helper 隔离语义,别在业务里到处裸读字段。
- `图片自动索引` 用现有 `imageIndex` 字段承载,但提示和启用条件要按矩阵变化。
- `工作流检索内容` 是一个 `Array<string>` 槽位,后端不能偷懒当纯文本 join 完事,否则图片链接会被当普通文本,图搜图直接歇菜。
- 工作流文件链接过滤只在后端兼容层做最终判断:复用 `packages/service/core/workflow/utils/context.ts` 的 `parseUrlToFileType`,只有解析为 `ChatFileTypeEnum.image` 的链接进入 `queryImageUrls`;解析为 `ChatFileTypeEnum.file` 的 PDF、docx、xlsx 等链接过滤掉,不进入文本检索。
- `多模态图片索引` 和 `VLM 文本描述索引` 可以同时存在,但索引类型必须区分。
### 6.6 前端设计
| 页面/组件 | 入口文件 | 交互状态 | i18n key | 变更说明 |
|---|---|---|---|---|
| 模型配置页 | `projects/app/src/pageComponents/account/model/AddModelBox.tsx`、`ModelConfigTable.tsx` | 初始值、保存中、保存失败、开关打开/关闭 | `account:model.vision`、建议新增 `account:model.embedding_vision_tip`、`common:core.ai.model.multimodal` | embedding 模型设置表单新增 `功能配置/支持图片识别`;打开才保存 `vision=true` |
| 创建知识库弹窗 | `projects/app/src/pageComponents/dataset/list/CreateModal.tsx` | 加载模型、无模型、创建中、创建失败、QuestionTip hover | 建议新增/替换 `common:core.dataset.embedding_model_tip`、`account_model:vlm_model_tip` 或对应 dataset namespace key | 宽弹窗;名称、索引模型、文本理解模型、图片理解模型按新稿布局;索引模型和图片理解模型问号提示使用固定文案 |
| 索引模型下拉 | `projects/app/src/components/Select/AIModelSelector.tsx` | 长名称省略、标签展示、禁用模型、`多模态` 标签 hover | `common:core.ai.model.beta`、`common:core.ai.model.multimodal`、建议新增 `common:core.ai.model.multimodal_tip` | 标签顺序 `Beta` 在 `多模态` 前;hover `多模态` 标签展示固定说明 |
| 图片自动索引 | `projects/app/src/pageComponents/dataset/detail/Form/CollectionChunkForm.tsx` | 可用、禁用、tooltip、动态 tips | `dataset:image_auto_parse_*` | 按矩阵改禁用条件和提示 |
| 文件分块弹窗 | `projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx` | 加载、编辑、保存中、索引展开/折叠 | `dataset:data_index`、新增多模态图片索引 key | 左内容右索引卡片 |
| 图片分块弹窗 | `projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx` | 图片加载失败、编辑、保存中 | 新增图片内容/多模态图片索引 key | 左图片预览 + 图片内容,右索引卡片 |
| 数据索引卡片 | `projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx`、`projects/app/src/pages/api/core/dataset/data/update.ts` | 默认索引和多模态图片索引保护、其他索引删除、多模态图片索引内容隐藏 | 新增多模态图片索引默认说明和删除确认文案 | 默认索引和多模态图片索引不展示删除入口;其他索引展示删除入口,删除需同步后端索引数据 |
| 工作流知识库搜索节点 | `packages/global/core/workflow/template/system/datasetSearch.ts` | 多变量引用、空值、旧值兼容、非图片文件链接过滤 | `workflow:content_to_search` | 检索内容 valueType 改 `arrayString`;文件链接只有图片参与检索 |
| 搜索测试页 | `projects/app/src/pageComponents/dataset/detail/Test.tsx` | 空、图片上传中、测试中、失败、历史选中、历史 hover | 新增图片上传按钮、缩略图、上传中卡片、搜索配置、测试内容文案、历史图片缩略图浮层 | 按最新搜索测试上传图片图改造 |
#### 6.6.1 创建知识库模型提示文案
该组文案是用户最新明确指定的产品文案,开发时必须接 i18n,不允许沿用旧文案或在组件内写死。
| 位置 | 触发方式 | 固定文案 | 实现说明 |
|---|---|---|---|
| `索引模型` label 后的问号 | hover/click QuestionTip | `索引模型可以将知识库内容转成向量,用于进行语义检索。注意,不同索引模型的知识库无法同时查询,切换索引模型需重建全量向量索引,请慎重选择。` | 替换旧 `索引模型可以将自然语言转成向量...选择完索引模型后将无法修改` 口径 |
| 索引模型下拉中的 `多模态` 标签 | hover `多模态` tag | `多模态索引模型可以给图片生成向量。` | hover 只绑定在 `多模态` 标签上;`Beta` 标签不展示该说明 |
| `图片理解模型` label 后的问号 | hover/click QuestionTip | `自动标注文档里的图片并生成文本描述,辅助文本检索` | 替换旧 “对文档中的图片进行额外的索引生成” 一类泛化文案 |
建议 i18n 落点:
- `packages/web/i18n/*/common.json`:更新 `core.dataset.embedding model tip` 或新增语义更明确的 `core.dataset.embedding_model_tip`。
- `packages/web/i18n/*/common.json`:新增 `core.ai.model.multimodal_tip`,用于模型下拉 `多模态` 标签 hover。
- `packages/web/i18n/*/account_model.json` 或 dataset 相关 namespace:更新/新增 `vlm_model_tip`,用于创建知识库 `图片理解模型` 问号提示。
#### 6.6.2 搜索测试图片上传约束
| 约束 | 规则 | 前端表现 | 后端/API 要求 |
|---|---|---|---|
| 上传类型 | 只支持图片,不支持文件 | 图片按钮打开系统图片选择;拖拽/粘贴/选择到非图片文件时直接过滤 | 上传接口只接收图片,非图片文件不入库、不返回 URL |
| 纯图片搜索 | 支持仅上传图片,不输入文字 | `text` 为空但存在图片时,`测试` 按钮可用 | `SearchDatasetTestBodySchema` 允许 `text` 为空,只要求 `text` 或 `queryImageUrls` 至少一个存在 |
| 无图片搜索能力 | 普通 embedding 且无 VLM 时不允许上传图片 | 图片上传按钮 disabled;hover 提示 `请配置图片理解模型或多模态索引模型` | schema 仍允许 `queryImageUrls`;搜索核心对绕过前端的纯图片请求返回空召回,图文混合只用文本 |
| 图片数量 | 最多 10 张 | 超过 10 张时提示 `最多支持上传10张图片`,超出的图片不加入列表 | `queryImageUrls` 服务端校验 `max(10)`,防止绕过前端 |
| 图片格式 | 跟随系统图片上传规则 | 系统不支持的格式直接过滤,不展示上传中卡片 | 复用系统现有图片 MIME/后缀白名单 |
| 图片大小 | 跟随系统图片上传规则 | 超出系统大小限制的图片直接过滤,不展示上传中卡片 | 复用系统现有大小限制,不为搜索测试单独开新阈值 |
| 图片过期 | 上传对象 3 小时后自动清理 | 前端不展示过期配置项;历史只保存受控缩略图引用和图片数量 | 上传时写入 `expiredTime = addHours(new Date(), 3)`,清理机制沿用 S3 TTL |
说明:
- “直接过滤”表示该文件不会进入待搜索图片列表,也不会生成 `queryImageUrls`。若本批次同时有合法图片,合法图片仍正常加入。
- 只有数量超过 10 张需要明确弹出 `最多支持上传10张图片`;格式和大小超限沿用系统已有过滤/提示行为,不在本需求里新增另一套提示文案。
- 无图片搜索能力时禁用上传入口属于前端体验优化,不改变 API schema 的兼容性;后端仍按搜索核心策略兜底,避免旧客户端或绕过前端的请求直接报错。
### 6.7 后端搜索策略
| 知识库配置 | 输入形态 | 召回分支 | VLM 查询图片转文字 | 结果合并 |
|---|---|---|---|---|
| 多模态 embedding,无 VLM | 纯文本 | 文本 query 使用同一个多模态 embedding 的 text modality,走现有文本向量/全文/混合检索 | 否 | 单路文本结果 |
| 多模态 embedding,无 VLM | 纯图片 | 图片 query 使用 image modality 生成 query vector,检索 `imageEmbedding` 图片向量索引 | 否 | 多图时按图片召回结果 RRF 合并 |
| 多模态 embedding,无 VLM | 图片 + 文字 | 文本分支 + 图片向量分支并行召回 | 否 | 文本结果和图片结果 RRF 合并 |
| 多模态 embedding,有 VLM | 纯文本 | 文本分支检索默认文本索引、VLM 文本描述索引等文本类索引 | 否 | 单路或多文本索引结果合并 |
| 多模态 embedding,有 VLM | 纯图片 | 图片向量分支检索 `imageEmbedding`;同时可用 VLM 将查询图片转成文本,检索 VLM 文本描述索引 | 是 | 图片向量结果 + VLM 文本结果 RRF 合并;同一数据多路命中时权重自然变大 |
| 多模态 embedding,有 VLM | 图片 + 文字 | 用户文字分支 + 图片向量分支 + 查询图片 VLM 文本分支 | 是 | 多路 RRF 合并;同时命中图片索引和 VLM 文本索引的结果排序更靠前 |
| 普通 embedding,有 VLM | 纯文本 | 沿用现有文本向量/全文/混合检索,可命中 VLM 文本描述索引 | 否 | 单路文本结果 |
| 普通 embedding,有 VLM | 纯图片 | 不能直接图片 embedding;先用 VLM 将查询图片转文字,再用普通 embedding 做文本检索 | 是 | VLM caption 文本结果合并;多图可多 caption RRF |
| 普通 embedding,有 VLM | 图片 + 文字 | 用户文字分支 + 查询图片 VLM 文本分支 | 是 | 多路文本结果 RRF 合并 |
| 普通 embedding,无 VLM | 纯文本 | 和现在一致 | 否 | 和现在一致 |
| 普通 embedding,无 VLM | 纯图片 | 不做图片检索 | 否 | 返回空召回结果,不额外报错 |
| 普通 embedding,无 VLM | 图片 + 文字 | 图片被忽略,文字按现有文本检索 | 否 | 返回文本检索结果 |
说明:
- 多模态 embedding 的“直接检索”指使用同一个 embedding 模型的不同 modality:文本走 text modality,图片走 image modality。
- 有 VLM 时,入库图片可能同时存在 `imageEmbedding` 图片向量索引和 VLM 文本描述索引;查询图片也需要额外经 VLM 转文字后,才有机会命中 VLM 文本描述索引。
- RRF 合并按多路召回列表进行;同一数据同时被图片向量索引和 VLM 文本索引命中,排序权重应自然提升。
- 普通 embedding 无 VLM 时不做图片检索。纯图片输入返回空召回结果;图文混合输入只使用文字部分,搜索阶段不额外报错。
- 创建/索引增强阶段仍必须按矩阵禁用非法“图片自动索引”,避免用户以为能生成图片索引。
### 6.8 日志与观测设计
| 场景 | 日志级别 | category | 结构化字段 | 脱敏策略 |
|---|---|---|---|---|
| 图片 embedding 失败 | error | `LogCategories.MODULE.DATASET.EMBEDDING` | `teamId/datasetId/collectionId/dataId/model/indexType/error` | 不记录 base64、完整 URL、向量数组 |
| 图片上传失败 | warn/error | dataset upload category | `teamId/datasetId/fileCount/mimeType/size/error` | 文件名可脱敏,URL 不落日志 |
| 工作流检索内容归一化失败/文件过滤 | warn/info | dataset search category | `teamId/datasetIds/inputCount/imageCount/textCount/filteredFileCount/error` | 不记录完整用户问题和完整文件 URL |
| 模型不支持图片却尝试生成图片向量 | warn | dataset training category | `teamId/datasetId/model/vision` | 不记录图片内容 |
| 向量维度不匹配 | error | vector category | `model/vectorLength/expectedLength/datasetId` | 不记录向量值 |
### 6.9 文档 i18n 设计(命中时必填)
| 中文文件 | 英文文件 | 类型(内容/导航) | 处理动作(新增/更新) | 翻译注意项 |
|---|---|---|---|---|
| `packages/web/i18n/zh-CN/common.json` | `packages/web/i18n/en/common.json` | UI 文案 | 更新/新增 | 更新索引模型 QuestionTip;新增 `多模态` hover tip |
| `packages/web/i18n/zh-CN/account_model.json` 或 dataset namespace | `packages/web/i18n/en/account_model.json` 或对应 namespace | UI 文案 | 更新/新增 | 更新图片理解模型 QuestionTip |
| `document/content/openapi/dataset.mdx` | `document/content/openapi/dataset.en.mdx` | 内容 | 更新 | 同步 `queryImageUrls`、本地上传说明、纯图片搜索、最多 10 张限制 |
| 知识库功能文档目录,具体路径实现时核对 | 对应 `.en.mdx` | 内容 | 更新或新增 | `图搜图` 建议译为 `image-to-image search` |
| 工作流知识库搜索节点文档,具体路径实现时核对 | 对应 `.en.mdx` | 内容 | 更新 | 说明“检索内容”为 `Array<string>`,可接文本和文件链接 |
补充要求:
- 若新增中文文档,必须同步英文文档和导航文件。
- 保持代码块字段名不翻译,例如 `vision`、`queryImageUrls`、`imageEmbedding`。
### 6.10 文档更新提醒(必填)
| 文档路径 | 文档类型 | 更新原因 | 计划更新内容 | 负责人 | 截止时间 | 状态 |
|---|---|---|---|---|---|---|
| `document/content/openapi/dataset.mdx` | OpenAPI 中文文档 | 搜索测试 API 支持图片输入 | 增加本地上传、`queryImageUrls`、纯图片搜索、最多 10 张、图文混合示例 | 开发实现者 | 实现完成前 | 已更新 |
| `document/content/openapi/dataset.en.mdx` | OpenAPI 英文文档 | 中文同步 | 同步英文参数说明与示例,包含纯图片搜索和 10 张限制 | 开发实现者 | 实现完成前 | 已更新 |
| `document/content/introduction/guide/knowledge_base/dataset_engine.mdx` | 功能文档 | 新增图搜图能力 | 说明多模态索引、图片自动索引配置、限制 | 开发实现者 | 实现完成前 | 已更新 |
| `document/content/introduction/guide/knowledge_base/dataset_engine.en.mdx` | 功能文档 | 中文同步 | 同步 image-to-image search 说明 | 开发实现者 | 实现完成前 | 已更新 |
| `document/content/introduction/guide/dashboard/workflow/dataset_search.mdx` | 功能文档 | 检索内容改为 `Array<string>` | 说明同时接用户问题和文件链接 | 开发实现者 | 实现完成前 | 已更新 |
| `document/content/introduction/guide/dashboard/workflow/dataset_search.en.mdx` | 功能文档 | 中文同步 | 同步说明 `Array<string>`、图片链接参与检索、非图片文件过滤 | 开发实现者 | 实现完成前 | 已更新 |
## 7. 风险、迁移与回滚
### 7.1 风险清单
1. 工作流兼容风险:现有 dataset search 节点的 `userChatInput` 是 string,新版是 `Array<string>`,需要兼容旧运行数据和旧模板。
2. 图片链接误判风险:`Array<string>` 同时含文本和文件链接,后端必须用可靠的文件解析逻辑区分;不能简单按 URL 字符串粗暴判断,也不能把 PDF、docx 等非图片文件链接当文本 query。
3. 索引类型混淆风险:现有 `image` 更偏 VLM 图片文本索引,必须和 `imageEmbedding` 分开。
4. 向量维度风险:图片 embedding 维度必须和现有向量写入链路保持一致;若模型输出维度不匹配,沿用现有校验/报错路径处理,不在本期引入多维度向量库策略。
5. 搜索成本风险:多图查询会多次图片 embedding,并行召回会增加模型调用和向量检索成本。
6. 隐私风险:本地上传图片、私有 S3 URL、预签名 URL 不能进入日志和长期历史明文;搜索测试上传图片必须 3 小时过期,避免测试图长期留存。
7. UI 误导风险:普通索引 + 无 VLM 时如果“图片自动索引”不禁用,用户会以为图片能被索引,最后搜不到就是纯纯给自己挖坑。
### 7.2 迁移策略
- 老 embedding 模型缺省无 `vision`,运行时按 `vision=false` 处理,无需迁移。
- 旧工作流 `userChatInput: string` 由兼容层归一化为单元素文本数组,搜索业务层只接收规范化后的 `textQueries/queryImageUrls`。
- 新工作流模板将“检索内容”展示为 `Array<string>`,默认只引用用户问题;允许用户按需手动追加文件链接。
- 存量知识库不自动补图片向量索引;当 `vectorModel` 或 `vlmModel` 切换时,必须进入全库重建流程或明确待重建状态。
- 旧搜索历史无图片字段时按纯文本历史展示。
- 新搜索历史若含图片,只保存可用于缩略图展示的受控图片引用、图片数量和摘要,不保存 base64 或完整私有预签名 URL。
### 7.3 回滚策略
1. 前端隐藏搜索测试图片上传入口和多模态图片索引展示。
2. 工作流模板回滚为 string 前,保留后端输入兼容层,避免旧流程直接崩。
3. 停止生成新的 `imageEmbedding` 索引,保留已生成索引不影响文本检索。
4. 搜索核心关闭图片 query 分支,纯文本链路保持现有行为。
5. 配置层关闭 embedding 模型 `vision` 后,前端自动回到普通模型表现。
## 8. 验收标准
| 验收项 | 验收方式 | 通过标准 |
|---|---|---|
| embedding 模型配置开关 | 手工验证/组件测试/API 测试 | `支持图片识别` 默认关闭;打开后保存 `vision=true`;关闭后保存/恢复为 `vision=false` 或缺省 |
| 创建知识库模型下拉 | 手工验证/组件测试 | 支持多模态的模型展示 `Beta`、`多模态`,且 `Beta` 在前 |
| 创建知识库模型提示 | 手工验证/组件测试/i18n 检查 | `索引模型` 问号、`图片理解模型` 问号、`多模态` 标签 hover 分别展示本文档固定文案;zh-CN/en/zh-Hant key 齐全 |
| 图片自动索引矩阵 | 单元测试/手工验证 | 5 种场景的禁用状态和提示文案符合矩阵 |
| 多模态无 VLM 导入图片 | 集成测试/手工验证 | 可生成图片向量索引,不要求 VLM |
| 多模态有 VLM 导入图片 | 集成测试/手工验证 | 同时生成图片向量索引和文本描述索引 |
| 普通索引无 VLM | 手工验证/组件测试 | 图片自动索引禁用,提示要求配置 VLM 或切换多模态模型 |
| 文件分块弹窗 | 手工验证/组件测试 | 左内容右索引卡片,符合第三张图 |
| 图片分块弹窗 | 手工验证/组件测试 | 左图片预览和图片内容,右索引卡片,符合第四张图 |
| 数据索引可见性与删除 | 手工验证/组件测试 | 多模态图片索引内容不可见,只显示默认说明;默认索引和多模态图片索引不可删除;其他索引可以删除 |
| 搜索测试页 | 手工验证/组件测试 | `搜索配置` 按钮、测试按钮位置、输入框内图片上传按钮、历史项无前置 icon/title |
| 搜索测试本地上传图片 | 集成测试/手工验证 | 支持图片能力时,上传图片后在输入框顶部展示缩略图;删除按钮可移除;上传中展示 loading 卡片;图片可作为 queryImageUrls 参与检索;不输入文字仅上传图片也可测试 |
| 搜索测试无图片能力 | 手工验证/组件测试 | 普通 embedding 且无 VLM 时图片上传按钮 disabled;hover 展示 `请配置图片理解模型或多模态索引模型` |
| 搜索测试图片限制 | 组件测试/API 测试/手工验证 | 只能上传图片,不支持文件;最多 10 张,超过提示 `最多支持上传10张图片`;图片格式和大小跟随系统规则,超出的直接过滤 |
| 搜索测试图片历史 | 手工验证/组件测试 | 图片检索历史显示 `[图片]` token;鼠标 hover 历史项时展示对应图片缩略图浮层;移出后浮层消失 |
| 工作流检索内容默认值 | 集成测试/手工验证 | 新建知识库搜索节点时,检索内容默认只引用用户问题,不默认引用文件链接 |
| 工作流检索内容手动多引用 | 集成测试 | 用户手动追加文件链接后,一个 `Array<string>` 输入能同时接用户问题和文件链接 |
| 工作流非图片文件过滤 | 单元测试/集成测试 | `Array<string>` 中的 PDF、docx、xlsx 等文件链接被过滤,图片链接进入 `queryImageUrls`,普通文本进入 `textQueries` |
| 多模态 embedding 纯图搜索 | 集成测试 | 每张图片通过 image modality 生成 query vector 并召回 `imageEmbedding` |
| 多模态 embedding + VLM 搜索 | 集成测试 | 图片向量召回和查询图片 VLM 转文字召回同时参与 RRF;同一数据多路命中时排序提升 |
| 普通 embedding + VLM 图片搜索 | 集成测试 | 查询图片先经 VLM 转文字,再走普通文本检索 |
| 普通 embedding 无 VLM 图片搜索 | 集成测试 | 纯图片返回空召回结果;图文混合时忽略图片,仅使用文字检索 |
| 图文混合搜索 | 集成测试 | 按知识库能力选择文本、图片向量、VLM caption 分支,并进入 RRF 合并 |
| 文档与 i18n | 文档检查 | 中英文文档和 UI 文案同步 |
## 9. MECE 核查结论
### 9.1 相互独立检查结果
- 模型能力字段和知识库字段独立:`vision` 属于模型配置,不写入 Dataset。
- 图片自动索引配置和搜索阶段行为独立:配置阶段负责能否生成索引,搜索阶段保持现有逻辑并使用已存在索引。
- VLM 文本描述索引和多模态图片向量索引独立:一个图转文,一个图转向量,索引类型不能混。
- 搜索测试和工作流入口独立:前者本地上传图片,后者从 `Array<string>` 中解析文件链接,但后端搜索核心复用。
- UI 分块弹窗和训练索引链路独立:弹窗展示索引状态,不应自己承担训练逻辑。
### 9.2 完全穷尽检查结果
| 链路 | 是否覆盖 | 说明 |
|---|---|---|
| 模型配置 | 是 | embedding `vision` |
| 创建知识库 | 是 | 模型下拉和三模型字段 |
| 索引增强 | 是 | 图片自动索引矩阵 |
| 图片导入 | 是 | 多模态/VLM 组合 |
| 分块展示 | 是 | 文件分块和图片分块弹窗 |
| 搜索测试 | 是 | 本地上传图片、纯图测试、最多 10 张、仅图片不支持文件、搜索配置、历史图片 hover 缩略图浮层 |
| 工作流节点 | 是 | 检索内容 `Array<string>` |
| 搜索核心 | 是 | 纯文本、纯图、图文混合、多图 |
| 计费与日志 | 是 | 图片 embedding、VLM、脱敏日志 |
| 文档/i18n | 是 | OpenAPI、知识库、工作流文档 |
### 9.3 修订动作与最终边界
- 旧文档 `图搜图-接入-*` 不覆盖,保留历史讨论痕迹。
- 本文档按最新确认口径作为后续实现依据。
- 后续若需要再改“工作流检索内容是否保留 string 兼容展示”,只能在实现文档中做兼容策略,不能推翻本期“Array<string>”产品口径。
## 10. TODO
- [x] 按本文档同步更新 `图搜图-当前需求-功能开发文档.md`。
- [x] 开发前确认创建知识库弹窗的三处提示文案对应 i18n key:索引模型问号、多模态 hover、图片理解模型问号。
- [x] 开发前核对模型配置中实际可用的多模态 embedding 模型。
- [x] 开发前确认搜索测试本地图片上传复用接口还是新增临时接口,并复用系统图片格式/大小限制;本次采用新增搜索测试临时上传接口,过期时间固定 3 小时。
- [x] 实现后补齐 OpenAPI、知识库、工作流文档与英文版。
- [x] 实现后按测试矩阵跑局部测试和最终检查。
- [x] 恢复 `pro` 子模块后,补齐 `pro/admin/src/service/core/dataset/training/imageParse.ts` 和 `pro/admin/src/service/core/dataset/training/imageIndex.ts` 的图片向量/VLM 文本索引分流实现。
- [x] 反向核对后补齐模型切换重建链路:切到普通 embedding 且无 VLM 时同步清理 dataset/collection 的 `imageIndex=false`。
- [x] 反向核对后补齐工作流输入归一化:普通无后缀 URL 不再被误判为非图片文件并过滤。
- [x] 反向核对后补齐搜索测试页 UI:输入标题、搜索配置按钮、图片缩略图顶部、上传按钮、测试按钮下移、历史标题去 icon。
...@@ -21,6 +21,7 @@ description: 'FastGPT V4.15.0-beta2 更新说明' ...@@ -21,6 +21,7 @@ description: 'FastGPT V4.15.0-beta2 更新说明'
1. 工作流,单节点调试,存在异常默认值。 1. 工作流,单节点调试,存在异常默认值。
2. 模型配置,defaultConfig 覆盖异常。 2. 模型配置,defaultConfig 覆盖异常。
3. 切换团队时,清除本地 chat 缓存。
## 代码优化 ## 代码优化
......
...@@ -253,8 +253,10 @@ ...@@ -253,8 +253,10 @@
"content/self-host/upgrading/4-14/41419.mdx": "2026-05-09T15:25:23+08:00", "content/self-host/upgrading/4-14/41419.mdx": "2026-05-09T15:25:23+08:00",
"content/self-host/upgrading/4-14/4142.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/4142.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4142.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/4142.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/41420.en.mdx": "2026-05-14T17:58:58+08:00", "content/self-host/upgrading/4-14/41420.en.mdx": "2026-05-21T15:39:08+08:00",
"content/self-host/upgrading/4-14/41420.mdx": "2026-05-19T14:44:21+08:00", "content/self-host/upgrading/4-14/41420.mdx": "2026-05-19T14:44:21+08:00",
"content/self-host/upgrading/4-14/41421.en.mdx": "2026-05-21T15:39:08+08:00",
"content/self-host/upgrading/4-14/41421.mdx": "2026-05-21T15:39:08+08:00",
"content/self-host/upgrading/4-14/4143.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/4143.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4143.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/4143.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4144.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/4144.en.mdx": "2026-04-26T21:08:47+08:00",
...@@ -415,6 +417,6 @@ ...@@ -415,6 +417,6 @@
"content/self-host/upgrading/outdated/499.mdx": "2026-05-07T15:06:40+08:00", "content/self-host/upgrading/outdated/499.mdx": "2026-05-07T15:06:40+08:00",
"content/self-host/upgrading/upgrade-intruction.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/upgrade-intruction.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/upgrade-intruction.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/upgrade-intruction.mdx": "2026-04-26T21:08:47+08:00",
"content/toc.en.mdx": "2026-05-18T17:26:16+08:00", "content/toc.en.mdx": "2026-05-21T15:39:08+08:00",
"content/toc.mdx": "2026-05-18T17:26:16+08:00" "content/toc.mdx": "2026-05-21T15:39:08+08:00"
} }
\ No newline at end of file
import { i18nT } from '../../../common/i18n/utils'; import { i18nT } from '../../../common/i18n/utils';
export enum DatasetDataIndexTypeEnum { export enum DatasetDataIndexTypeEnum {
default = 'default', // 默认的 default = 'default', // 默认文本索引
imageEmbedding = 'imageEmbedding', // 默认图片向量
summary = 'summary', // 摘要,系统生成 summary = 'summary', // 摘要,系统生成
question = 'question', // 补全问题,系统生成 question = 'question', // 补全问题,系统生成
image = 'image', // 图片描述,系统生成 image = 'image', // 图片描述,系统生成
...@@ -34,6 +36,10 @@ export const DatasetDataIndexMap: Record< ...@@ -34,6 +36,10 @@ export const DatasetDataIndexMap: Record<
[DatasetDataIndexTypeEnum.image]: { [DatasetDataIndexTypeEnum.image]: {
label: i18nT('dataset:data_index_image'), label: i18nT('dataset:data_index_image'),
color: 'purple' color: 'purple'
},
[DatasetDataIndexTypeEnum.imageEmbedding]: {
label: i18nT('dataset:data_index_image_embedding'),
color: 'purple'
} }
}; };
export const defaultDatasetIndexData = DatasetDataIndexMap[DatasetDataIndexTypeEnum.custom]; export const defaultDatasetIndexData = DatasetDataIndexMap[DatasetDataIndexTypeEnum.custom];
......
import { DatasetDataIndexTypeEnum } from './constants';
export const datasetDataSystemIndexTypes = [
DatasetDataIndexTypeEnum.default,
DatasetDataIndexTypeEnum.imageEmbedding
] as const;
const datasetDataSystemIndexTypeSet = new Set<DatasetDataIndexTypeEnum>(
datasetDataSystemIndexTypes
);
/**
* 判断索引类型是否由数据内容自动生成和维护。
*
* 系统索引会随 data 的 q/a/imageId/markdown 图片重新生成,前端和后端都不应把它当作
* 用户可手动编辑的外部索引处理。
*/
export const isDatasetDataSystemIndexType = (type?: DatasetDataIndexTypeEnum) =>
datasetDataSystemIndexTypeSet.has(type || DatasetDataIndexTypeEnum.custom);
...@@ -94,7 +94,7 @@ export const DatasetSchema = z ...@@ -94,7 +94,7 @@ export const DatasetSchema = z
deleteTime: z.coerce.date().nullish().meta({ description: '删除时间' }), deleteTime: z.coerce.date().nullish().meta({ description: '删除时间' }),
autoSync: z.boolean().optional().meta({ description: '自动同步', deprecated: true }), autoSync: z.boolean().optional().meta({ description: '自动同步' }),
externalReadUrl: z.string().optional().meta({ description: '外部读取 URL', deprecated: true }), externalReadUrl: z.string().optional().meta({ description: '外部读取 URL', deprecated: true }),
defaultPermission: z.number().optional().meta({ description: '默认权限', deprecated: true }), defaultPermission: z.number().optional().meta({ description: '默认权限', deprecated: true }),
apiServer: APIFileServerSchema.optional().meta({ apiServer: APIFileServerSchema.optional().meta({
...@@ -171,7 +171,10 @@ export const DatasetDataIndexItemSchema = z.object({ ...@@ -171,7 +171,10 @@ export const DatasetDataIndexItemSchema = z.object({
.default(DatasetDataIndexTypeEnum.custom) .default(DatasetDataIndexTypeEnum.custom)
.meta({ description: '索引类型' }), .meta({ description: '索引类型' }),
dataId: z.string().meta({ description: 'vectorDB ID' }), dataId: z.string().meta({ description: 'vectorDB ID' }),
text: z.string().meta({ description: '索引文本' }) text: z.string().meta({
description: `默认就是索引的文本内容,特殊的:
imageEmbedding - 图片的 objectKey/url`
})
}); });
const DatasetDataIndexOptionalSchema = DatasetDataIndexItemSchema.omit({ dataId: true }).extend({ const DatasetDataIndexOptionalSchema = DatasetDataIndexItemSchema.omit({ dataId: true }).extend({
dataId: z.string().optional().meta({ dataId: z.string().optional().meta({
......
...@@ -126,11 +126,6 @@ export const DatasetSearchModule: FlowNodeTemplateType = { ...@@ -126,11 +126,6 @@ export const DatasetSearchModule: FlowNodeTemplateType = {
}, },
{ {
...Input_Template_UserChatInput, ...Input_Template_UserChatInput,
toolDescription: i18nT('workflow:content_to_search'),
deprecated: true
},
{
...Input_Template_UserChatInput,
label: i18nT('workflow:search_query'), label: i18nT('workflow:search_query'),
key: NodeInputKeyEnum.datasetSearchInput, key: NodeInputKeyEnum.datasetSearchInput,
valueType: WorkflowIOValueTypeEnum.arrayString, valueType: WorkflowIOValueTypeEnum.arrayString,
......
...@@ -53,7 +53,16 @@ export type GetDatasetDataDetailResponse = z.infer<typeof GetDatasetDataDetailRe ...@@ -53,7 +53,16 @@ export type GetDatasetDataDetailResponse = z.infer<typeof GetDatasetDataDetailRe
* ============================================================================ */ * ============================================================================ */
export const UpdateDatasetDataBodySchema = UpdateDatasetDataPropsSchema; export const UpdateDatasetDataBodySchema = UpdateDatasetDataPropsSchema;
export type UpdateDatasetDataBody = z.infer<typeof UpdateDatasetDataBodySchema>; export type UpdateDatasetDataBody = z.infer<typeof UpdateDatasetDataBodySchema>;
export const UpdateDatasetDataResponseSchema = z.object({}); export const UpdateDatasetDataResponseSchema = z.object({
q: z.string().optional().meta({
example: '![image.png](/api/system/file/download/xxx?filename=image.png)',
description: '展示态问题/主文本,内部 S3 图片会替换为签名访问地址'
}),
a: z.string().optional().meta({
example: 'FastGPT 是一个 AI Agent 构建平台',
description: '展示态回答/补充文本,内部 S3 图片会替换为签名访问地址'
})
});
export type UpdateDatasetDataResponse = z.infer<typeof UpdateDatasetDataResponseSchema>; export type UpdateDatasetDataResponse = z.infer<typeof UpdateDatasetDataResponseSchema>;
const DatasetDataIndexContentSchema = z.object({ const DatasetDataIndexContentSchema = z.object({
......
...@@ -3,7 +3,7 @@ import { NodeInputKeyEnum, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/ ...@@ -3,7 +3,7 @@ import { NodeInputKeyEnum, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/
import { DatasetSearchModule } from '@fastgpt/global/core/workflow/template/system/datasetSearch'; import { DatasetSearchModule } from '@fastgpt/global/core/workflow/template/system/datasetSearch';
describe('DatasetSearchModule', () => { describe('DatasetSearchModule', () => {
it('should deprecate legacy user question input and add array search input', () => { it('should use array search input without legacy user question input', () => {
const legacyUserQuestionInput = DatasetSearchModule.inputs.find( const legacyUserQuestionInput = DatasetSearchModule.inputs.find(
(input) => input.key === NodeInputKeyEnum.userChatInput (input) => input.key === NodeInputKeyEnum.userChatInput
); );
...@@ -11,8 +11,7 @@ describe('DatasetSearchModule', () => { ...@@ -11,8 +11,7 @@ describe('DatasetSearchModule', () => {
(input) => input.key === NodeInputKeyEnum.datasetSearchInput (input) => input.key === NodeInputKeyEnum.datasetSearchInput
); );
expect(legacyUserQuestionInput?.valueType).toBe(WorkflowIOValueTypeEnum.string); expect(legacyUserQuestionInput).toBeUndefined();
expect(legacyUserQuestionInput?.deprecated).toBe(true);
expect(searchInput?.valueType).toBe(WorkflowIOValueTypeEnum.arrayString); expect(searchInput?.valueType).toBe(WorkflowIOValueTypeEnum.arrayString);
}); });
}); });
...@@ -72,7 +72,8 @@ export const LogCategories = { ...@@ -72,7 +72,8 @@ export const LogCategories = {
IMAGE_PARSE: ['dataset', 'training', 'image-parse'], IMAGE_PARSE: ['dataset', 'training', 'image-parse'],
IMAGE_INDEX: ['dataset', 'training', 'image-index'], IMAGE_INDEX: ['dataset', 'training', 'image-index'],
INDEX_EXTEND: ['dataset', 'training', 'index-extend'], INDEX_EXTEND: ['dataset', 'training', 'index-extend'],
LLM_PARGRAPH: ['dataset', 'training', 'llm-pargraph'] LLM_PARGRAPH: ['dataset', 'training', 'llm-pargraph'],
WEB_SYNC: ['dataset', 'webSync']
}), }),
AI: Object.assign(['ai'], { AI: Object.assign(['ai'], {
AGENT: ['ai', 'agent'], AGENT: ['ai', 'agent'],
......
import { serviceEnv } from '../../env'; import { serviceEnv } from '../../env';
import { WorkerNameEnum, getWorkerController } from '../../worker/utils'; import { WorkerNameEnum, getWorkerController } from '../../worker/utils';
import { type ImageType } from '../../worker/readFile/type'; import { type ImageType } from '../../worker/readFile/type';
import { getLogger, LogCategories } from '../logger';
const logger = getLogger(LogCategories.INFRA.WORKER);
const HTML_TO_MARKDOWN_TIMEOUT_MS = 300000;
/**
* 将 HTML 转为 Markdown。实际转换在 worker 中执行,并在派发前记录任务大小,
* 便于定位站点同步中是否卡在 HTML 转换阶段。
*/
export const htmlToMarkdown = async (html?: string | null) => { export const htmlToMarkdown = async (html?: string | null) => {
const htmlContent = html || '';
const workerController = getWorkerController< const workerController = getWorkerController<
{ html: string }, { html: string },
{ {
...@@ -12,11 +21,18 @@ export const htmlToMarkdown = async (html?: string | null) => { ...@@ -12,11 +21,18 @@ export const htmlToMarkdown = async (html?: string | null) => {
>({ >({
name: WorkerNameEnum.htmlStr2Md, name: WorkerNameEnum.htmlStr2Md,
maxReservedThreads: serviceEnv.HTML_TO_MARKDOWN_WORKERS, maxReservedThreads: serviceEnv.HTML_TO_MARKDOWN_WORKERS,
taskTimeoutMs: 300000, taskTimeoutMs: HTML_TO_MARKDOWN_TIMEOUT_MS,
maxTasksPerWorker: 100 maxTasksPerWorker: 100
}); });
const md = await workerController.run({ html: html || '' }); logger.info('HTML to markdown worker task started', {
htmlLength: htmlContent.length,
workerName: WorkerNameEnum.htmlStr2Md,
maxReservedThreads: serviceEnv.HTML_TO_MARKDOWN_WORKERS,
timeoutMs: HTML_TO_MARKDOWN_TIMEOUT_MS
});
const md = await workerController.run({ html: htmlContent });
return md.rawText; return md.rawText;
}; };
...@@ -13,6 +13,7 @@ export enum TimerIdEnum { ...@@ -13,6 +13,7 @@ export enum TimerIdEnum {
recordTeamQPM = 'recordTeamQPM', recordTeamQPM = 'recordTeamQPM',
auditLogCleanup = 'auditLogCleanup', auditLogCleanup = 'auditLogCleanup',
chatHistoryCleanup = 'chatHistoryCleanup', chatHistoryCleanup = 'chatHistoryCleanup',
datasetSyncSchedulerReconcile = 'datasetSyncSchedulerReconcile',
/** 纠正长时间卡在 generating 的会话状态 */ /** 纠正长时间卡在 generating 的会话状态 */
cleanStaleGeneratingChat = 'cleanStaleGeneratingChat' cleanStaleGeneratingChat = 'cleanStaleGeneratingChat'
} }
......
...@@ -4,6 +4,7 @@ import { ObVectorCtrl } from './oceanbase'; ...@@ -4,6 +4,7 @@ import { ObVectorCtrl } from './oceanbase';
import { SeekVectorCtrl } from './seekdb'; import { SeekVectorCtrl } from './seekdb';
import { OpenGaussVectorCtrl } from './opengauss'; import { OpenGaussVectorCtrl } from './opengauss';
import { getVectors } from '../../core/ai/embedding'; import { getVectors } from '../../core/ai/embedding';
import type { GetVectorsProps } from '../../core/ai/embedding';
import type { VectorControllerType, InsertVectorControllerPropsType } from './type'; import type { VectorControllerType, InsertVectorControllerPropsType } from './type';
import { type EmbeddingModelItemType } from '@fastgpt/global/core/ai/model.schema'; import { type EmbeddingModelItemType } from '@fastgpt/global/core/ai/model.schema';
import { import {
...@@ -103,12 +104,21 @@ export const initVectorStore = Vector.init; ...@@ -103,12 +104,21 @@ export const initVectorStore = Vector.init;
export const recallFromVectorStore: VectorControllerType['embRecall'] = (props) => export const recallFromVectorStore: VectorControllerType['embRecall'] = (props) =>
retryFn(() => Vector.embRecall(props)); retryFn(() => Vector.embRecall(props));
type DatasetVectorInput = string | GetVectorsProps['inputs'][number];
/**
* 统一写入知识库索引向量。
*
* `inputs` 的 text/image 类型只用于告诉 embedding 模型如何生成向量;
* 进入向量库时已经统一成 number[][],向量库本身不区分文本向量或图片向量。
* 传入 string 时保持旧行为,默认按文本生成 embedding。
*/
export const insertDatasetDataVector = async ({ export const insertDatasetDataVector = async ({
model, model,
inputs, inputs,
...props ...props
}: Omit<InsertVectorControllerPropsType, 'vectors'> & { }: Omit<InsertVectorControllerPropsType, 'vectors'> & {
inputs: string[]; inputs: DatasetVectorInput[];
model: EmbeddingModelItemType; model: EmbeddingModelItemType;
}) => { }) => {
if (inputs.length === 0) { if (inputs.length === 0) {
...@@ -118,12 +128,17 @@ export const insertDatasetDataVector = async ({ ...@@ -118,12 +128,17 @@ export const insertDatasetDataVector = async ({
}; };
} }
const embeddingInputs = inputs.map((input) =>
typeof input === 'string'
? {
type: 'text' as const,
input
}
: input
);
const { vectors, tokens } = await getVectors({ const { vectors, tokens } = await getVectors({
model, model,
inputs: inputs.map((text) => ({ inputs: embeddingInputs,
type: 'text',
input: text
})),
type: 'db' type: 'db'
}); });
const { insertIds } = await retryFn(() => const { insertIds } = await retryFn(() =>
......
...@@ -25,6 +25,7 @@ import { hashStr } from '@fastgpt/global/common/string/tools'; ...@@ -25,6 +25,7 @@ import { hashStr } from '@fastgpt/global/common/string/tools';
import { MongoDatasetDataText } from '../data/dataTextSchema'; import { MongoDatasetDataText } from '../data/dataTextSchema';
import { retryFn } from '@fastgpt/global/common/system/utils'; import { retryFn } from '@fastgpt/global/common/system/utils';
import { getTrainingModeByCollection } from './utils'; import { getTrainingModeByCollection } from './utils';
import { getDatasetImageIndexCapability } from '../utils';
import { import {
computedCollectionChunkSettings, computedCollectionChunkSettings,
getLLMMaxChunkSize getLLMMaxChunkSize
...@@ -77,7 +78,11 @@ export const createCollectionAndInsertData = async ({ ...@@ -77,7 +78,11 @@ export const createCollectionAndInsertData = async ({
const trainingMode = getTrainingModeByCollection({ const trainingMode = getTrainingModeByCollection({
trainingType: trainingType, trainingType: trainingType,
autoIndexes: formatCreateCollectionParams.autoIndexes, autoIndexes: formatCreateCollectionParams.autoIndexes,
imageIndex: formatCreateCollectionParams.imageIndex imageIndex: formatCreateCollectionParams.imageIndex,
supportImageIndex: getDatasetImageIndexCapability({
vectorModel: dataset.vectorModel,
vlmModel: dataset.vlmModel
}).supportImageIndex
}); });
if ( if (
......
...@@ -3,7 +3,6 @@ import type { ClientSession } from '../../../common/mongo'; ...@@ -3,7 +3,6 @@ import type { ClientSession } from '../../../common/mongo';
import { MongoDatasetCollectionTags } from '../tag/schema'; import { MongoDatasetCollectionTags } from '../tag/schema';
import { readFromSecondary } from '../../../common/mongo/utils'; import { readFromSecondary } from '../../../common/mongo/utils';
import type { CollectionWithDatasetType } from '@fastgpt/global/core/dataset/type'; import type { CollectionWithDatasetType } from '@fastgpt/global/core/dataset/type';
import { DatasetCollectionSchemaType } from '@fastgpt/global/core/dataset/type';
import { import {
DatasetCollectionDataProcessModeEnum, DatasetCollectionDataProcessModeEnum,
DatasetCollectionSyncResultEnum, DatasetCollectionSyncResultEnum,
...@@ -216,11 +215,13 @@ export const syncCollection = async (collection: CollectionWithDatasetType) => { ...@@ -216,11 +215,13 @@ export const syncCollection = async (collection: CollectionWithDatasetType) => {
export const getTrainingModeByCollection = ({ export const getTrainingModeByCollection = ({
trainingType, trainingType,
autoIndexes, autoIndexes,
imageIndex imageIndex,
supportImageIndex = false
}: { }: {
trainingType?: DatasetCollectionDataProcessModeEnum; trainingType?: DatasetCollectionDataProcessModeEnum;
autoIndexes?: boolean; autoIndexes?: boolean;
imageIndex?: boolean; imageIndex?: boolean;
supportImageIndex?: boolean;
}) => { }) => {
if ( if (
trainingType === DatasetCollectionDataProcessModeEnum.imageParse && trainingType === DatasetCollectionDataProcessModeEnum.imageParse &&
...@@ -235,6 +236,7 @@ export const getTrainingModeByCollection = ({ ...@@ -235,6 +236,7 @@ export const getTrainingModeByCollection = ({
if ( if (
trainingType === DatasetCollectionDataProcessModeEnum.chunk && trainingType === DatasetCollectionDataProcessModeEnum.chunk &&
imageIndex && imageIndex &&
supportImageIndex &&
global.feConfigs?.isPlus global.feConfigs?.isPlus
) { ) {
return TrainingModeEnum.image; return TrainingModeEnum.image;
......
...@@ -4,6 +4,7 @@ import type { DatasetDataSchemaType } from '@fastgpt/global/core/dataset/type'; ...@@ -4,6 +4,7 @@ import type { DatasetDataSchemaType } from '@fastgpt/global/core/dataset/type';
import { addDays } from 'date-fns'; import { addDays } from 'date-fns';
import { isS3ObjectKey, jwtSignS3DownloadToken } from '../../../common/s3/utils'; import { isS3ObjectKey, jwtSignS3DownloadToken } from '../../../common/s3/utils';
import { S3Buckets } from '../../../common/s3/config/constants'; import { S3Buckets } from '../../../common/s3/config/constants';
import { matchDatasetDataMarkdownImages } from './utils';
export const formatDatasetDataValue = ({ export const formatDatasetDataValue = ({
q, q,
...@@ -24,15 +25,23 @@ export const formatDatasetDataValue = ({ ...@@ -24,15 +25,23 @@ export const formatDatasetDataValue = ({
if (imageDescMap) { if (imageDescMap) {
// Helper function to replace image markdown with description // Helper function to replace image markdown with description
const replaceImageMarkdown = (text: string): string => { const replaceImageMarkdown = (text: string): string => {
return text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, altText, url) => { const matches = matchDatasetDataMarkdownImages(text);
const description = imageDescMap[url]; let content = text;
for (const item of matches.slice().reverse()) {
const description = imageDescMap[item.url];
if (description) { if (description) {
// Add description to alt text, keeping original if exists // Add description to alt text, keeping original if exists
const newAltText = altText ? `${altText} - ${description}` : description; const newAltText = item.alt ? `${item.alt} - ${description}` : description;
return `![${newAltText.replace(/\n/g, '')}](${url})`; const replacement = `![${newAltText.replace(/\n/g, '')}](${item.url})`;
content =
content.slice(0, item.index) +
replacement +
content.slice(item.index + item.raw.length);
}
} }
return match; // Return original if no description found
}); return content;
}; };
// Apply replacement to both q and a // Apply replacement to both q and a
......
...@@ -33,10 +33,7 @@ const DatasetDataSchema = new Schema({ ...@@ -33,10 +33,7 @@ const DatasetDataSchema = new Schema({
ref: DatasetColCollectionName, ref: DatasetColCollectionName,
required: true required: true
}, },
q: { q: String,
type: String,
required: true
},
a: { a: {
type: String type: String
}, },
......
export type DatasetDataMarkdownImageItem = {
raw: string;
alt: string;
url: string;
index: number;
};
/**
* 从 dataset data 的 markdown 内容中提取图片节点。
*
* 这里只负责识别 `![alt](url)`,用于 VLM 图片描述索引、imageEmbedding 图片向量索引、
* 展示态描述回填等链路共用同一套图片提取语义。图片来源合法性校验、S3/base64 转换、
* 向量生成都在后续链路处理。
*/
export const matchDatasetDataMarkdownImages = (text = ''): DatasetDataMarkdownImageItem[] => {
if (typeof text !== 'string' || !text) return [];
const regex = /!\[([\s\S]*?)\]\((.*?)\)/g;
return Array.from(text.matchAll(regex))
.map((match) => ({
raw: match[0],
alt: match[1] || '',
url: match[2]?.trim() || '',
index: match.index ?? 0
}))
.filter((item) => !!item.url);
};
/**
* 提取 dataset data markdown 图片 URL。
*
* 这是图片描述索引和图片向量索引共同使用的 URL 入口,避免不同训练/重建链路
* 分别维护 markdown 图片提取规则。
*/
export const matchDatasetDataMarkdownImageUrls = (text = '') =>
matchDatasetDataMarkdownImages(text).map((item) => item.url);
/**
* 从多个文本字段中提取并按首次出现顺序去重图片 URL。
*/
export const uniqueDatasetDataMarkdownImageUrls = (texts: Array<string | null | undefined>) =>
Array.from(
new Set(
texts.filter((text): text is string => !!text).flatMap(matchDatasetDataMarkdownImageUrls)
)
);
import { type Processor } from 'bullmq'; import { type JobSchedulerJson, type Processor } from 'bullmq';
import { getQueue, getWorker, QueueNames } from '../../../common/bullmq'; import { getQueue, getWorker, QueueNames } from '../../../common/bullmq';
import { DatasetStatusEnum } from '@fastgpt/global/core/dataset/constants'; import { DatasetStatusEnum } from '@fastgpt/global/core/dataset/constants';
import { MongoDataset } from '../schema';
import { getLogger, LogCategories } from '../../../common/logger';
export type DatasetSyncJobData = { export type DatasetSyncJobData = {
datasetId: string; datasetId: string;
}; };
const logger = getLogger(LogCategories.MODULE.DATASET);
export const datasetSyncQueue = getQueue<DatasetSyncJobData>(QueueNames.datasetSync, { export const datasetSyncQueue = getQueue<DatasetSyncJobData>(QueueNames.datasetSync, {
defaultJobOptions: { defaultJobOptions: {
attempts: 3, // retry 3 times attempts: 3, // retry 3 times
...@@ -99,3 +103,55 @@ export const getDatasetSyncJobScheduler = (datasetId: string) => { ...@@ -99,3 +103,55 @@ export const getDatasetSyncJobScheduler = (datasetId: string) => {
export const removeDatasetSyncJobScheduler = (datasetId: string) => { export const removeDatasetSyncJobScheduler = (datasetId: string) => {
return datasetSyncQueue.removeJobScheduler(String(datasetId)); return datasetSyncQueue.removeJobScheduler(String(datasetId));
}; };
export type DatasetSyncSchedulerReconcileResult = {
autoSyncDatasetCount: number;
schedulerCount: number;
createdSchedulerCount: number;
createdDatasetIds: string[];
};
/**
* 以 Mongo `autoSync=true` 作为期望态,补齐缺失的 BullMQ datasetSync scheduler。
*
* 该函数只增加缺失 scheduler,不修改 Mongo `autoSync`,也不移除 Redis 中已有 scheduler/job。
*/
export const reconcileDatasetSyncSchedulers =
async (): Promise<DatasetSyncSchedulerReconcileResult> => {
const autoSyncDatasets = await MongoDataset.find(
{
autoSync: true,
$or: [{ deleteTime: null }, { deleteTime: { $exists: false } }]
},
'_id'
).lean();
const autoSyncDatasetIds = new Set(autoSyncDatasets.map((dataset) => String(dataset._id)));
const schedulers = (await datasetSyncQueue.getJobSchedulers(
0,
-1,
true
)) as JobSchedulerJson<DatasetSyncJobData>[];
const schedulerIds = new Set(
schedulers.map((scheduler) => String(scheduler.key)).filter(Boolean)
);
const createdDatasetIds: string[] = [];
for (const datasetId of autoSyncDatasetIds) {
if (schedulerIds.has(datasetId)) continue;
await upsertDatasetSyncJobScheduler({ datasetId });
createdDatasetIds.push(datasetId);
}
const result = {
autoSyncDatasetCount: autoSyncDatasetIds.size,
schedulerCount: schedulers.length,
createdSchedulerCount: createdDatasetIds.length,
createdDatasetIds
};
logger.info('Dataset sync scheduler reconcile finished', result);
return result;
};
...@@ -138,12 +138,16 @@ const DatasetSchema = new Schema({ ...@@ -138,12 +138,16 @@ const DatasetSchema = new Schema({
default: null // null表示未删除,有值表示删除时间 default: null // null表示未删除,有值表示删除时间
}, },
// abandoned
autoSync: Boolean, autoSync: Boolean,
/** @deprecated */
externalReadUrl: String, externalReadUrl: String,
/** @deprecated */
defaultPermission: Number, defaultPermission: Number,
/** @deprecated */
apiServer: Object, apiServer: Object,
/** @deprecated */
feishuServer: Object, feishuServer: Object,
/** @deprecated */
yuqueServer: Object yuqueServer: Object
}); });
......
...@@ -5,6 +5,7 @@ import { getLogger, LogCategories } from '../../../common/logger'; ...@@ -5,6 +5,7 @@ import { getLogger, LogCategories } from '../../../common/logger';
import type { OpenaiAccountType } from '@fastgpt/global/support/user/team/type'; import type { OpenaiAccountType } from '@fastgpt/global/support/user/team/type';
import { getImageBase64 } from '../../../common/file/image/utils'; import { getImageBase64 } from '../../../common/file/image/utils';
import { serviceEnv } from '../../../env'; import { serviceEnv } from '../../../env';
import { isS3ObjectKey } from '../../../common/s3/utils';
const logger = getLogger(LogCategories.MODULE.DATASET.DATA); const logger = getLogger(LogCategories.MODULE.DATASET.DATA);
...@@ -25,6 +26,19 @@ export const computeFilterIntersection = (lists: (string[] | undefined)[]) => { ...@@ -25,6 +26,19 @@ export const computeFilterIntersection = (lists: (string[] | undefined)[]) => {
}); });
}; };
export const isValidImageEmbeddingSource = (imageUrl?: string) => {
const url = imageUrl?.trim();
if (!url) return false;
if (url.startsWith('data:image/')) return true;
if (isS3ObjectKey(url, 'dataset')) return true;
if (isS3ObjectKey(url, 'temp')) return true;
if (isS3ObjectKey(url, 'chat')) return true;
if (/^https?:\/\//i.test(url)) return true;
return false;
};
/** /**
* 按环境开关规范化图片输入。 * 按环境开关规范化图片输入。
* data URL 已经是模型可读内容,始终原样返回;普通图片 URL 只有 * data URL 已经是模型可读内容,始终原样返回;普通图片 URL 只有
......
...@@ -5,7 +5,7 @@ import type { ...@@ -5,7 +5,7 @@ import type {
} from '@fastgpt/global/openapi/core/dataset/data/api'; } from '@fastgpt/global/openapi/core/dataset/data/api';
import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants'; import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants';
import { type ClientSession } from '../../../common/mongo'; import { type ClientSession } from '../../../common/mongo';
import { getLLMModel, getEmbeddingModel, getVlmModel } from '../../ai/model'; import { getLLMModel, getEmbeddingModel, getVlmModel, isImageEmbeddingModel } from '../../ai/model';
import { mongoSessionRun } from '../../../common/mongo/sessionRun'; import { mongoSessionRun } from '../../../common/mongo/sessionRun';
import { i18nT } from '@fastgpt/global/common/i18n/utils'; import { i18nT } from '@fastgpt/global/common/i18n/utils';
import { getLLMMaxChunkSize } from '../../../../global/core/dataset/training/utils'; import { getLLMMaxChunkSize } from '../../../../global/core/dataset/training/utils';
...@@ -79,7 +79,7 @@ export const pushDataListToTrainingQueue = async ({ ...@@ -79,7 +79,7 @@ export const pushDataListToTrainingQueue = async ({
return Promise.reject(i18nT('common:error_llm_not_config')); return Promise.reject(i18nT('common:error_llm_not_config'));
} }
const { model, maxToken, weight } = await (async () => { const { maxToken, weight } = await (async () => {
if (mode === TrainingModeEnum.chunk) { if (mode === TrainingModeEnum.chunk) {
return { return {
maxToken: Infinity, maxToken: Infinity,
...@@ -97,6 +97,13 @@ export const pushDataListToTrainingQueue = async ({ ...@@ -97,6 +97,13 @@ export const pushDataListToTrainingQueue = async ({
if (mode === TrainingModeEnum.image || mode === TrainingModeEnum.imageParse) { if (mode === TrainingModeEnum.image || mode === TrainingModeEnum.imageParse) {
const vllmModelData = getVlmModel(vlmModel); const vllmModelData = getVlmModel(vlmModel);
if (!vllmModelData) { if (!vllmModelData) {
if (mode === TrainingModeEnum.image && isImageEmbeddingModel(vectorModelData)) {
return {
maxToken: Infinity,
model: vectorModelData.model,
weight: vectorModelData.weight
};
}
return Promise.reject(i18nT('common:error_vlm_not_config')); return Promise.reject(i18nT('common:error_vlm_not_config'));
} }
return { return {
......
...@@ -4,6 +4,8 @@ import { S3Sources } from '../../common/s3/contracts/type'; ...@@ -4,6 +4,8 @@ import { S3Sources } from '../../common/s3/contracts/type';
import { jwtSignS3DownloadToken, isS3ObjectKey } from '../../common/s3/utils'; import { jwtSignS3DownloadToken, isS3ObjectKey } from '../../common/s3/utils';
import { getLogger, LogCategories } from '../../common/logger'; import { getLogger, LogCategories } from '../../common/logger';
import { S3Buckets } from '../../common/s3/config/constants'; import { S3Buckets } from '../../common/s3/config/constants';
import { getVlmModelList, isImageEmbeddingModel } from '../ai/model';
import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants';
const logger = getLogger(LogCategories.MODULE.DATASET.FILE); const logger = getLogger(LogCategories.MODULE.DATASET.FILE);
...@@ -82,3 +84,46 @@ export function replaceS3KeyToPreviewUrl(documentQuoteText: string, expiredTime: ...@@ -82,3 +84,46 @@ export function replaceS3KeyToPreviewUrl(documentQuoteText: string, expiredTime:
return content; return content;
} }
const getAvailableDatasetVlmModel = (vlmModel?: string) => {
if (!vlmModel) return;
const vlmModelList = getVlmModelList();
return vlmModelList.find((item) => item.model === vlmModel || item.name === vlmModel);
};
export const getDatasetImageIndexCapability = ({
vectorModel,
vlmModel
}: {
vectorModel?: string;
vlmModel?: string;
}) => {
const availableVlmModel = getAvailableDatasetVlmModel(vlmModel);
const supportVlm = !!availableVlmModel;
const supportImageEmbedding = isImageEmbeddingModel(vectorModel);
return {
availableVlmModel,
supportVlm,
supportImageEmbedding,
supportImageIndex: supportVlm || supportImageEmbedding
};
};
export const getDatasetImageTrainingMode = ({
supportVlm,
supportImageIndex,
imageId,
hasMarkdownImages
}: {
supportVlm: boolean;
supportImageIndex: boolean;
imageId?: string;
hasMarkdownImages: boolean;
}) => {
if (supportVlm && imageId) return TrainingModeEnum.imageParse;
if (supportImageIndex && hasMarkdownImages) return TrainingModeEnum.image;
return TrainingModeEnum.chunk;
};
...@@ -289,6 +289,51 @@ describe('VectorDB Controller', () => { ...@@ -289,6 +289,51 @@ describe('VectorDB Controller', () => {
}); });
}); });
it('should pass explicit image inputs to embedding generation', async () => {
const mockVectors = [[0.1, 0.2]];
mockGetVectors.mockResolvedValue({
tokens: 1,
vectors: mockVectors
});
mockVectorInsert.mockResolvedValue({
insertIds: ['image_id']
});
const result = await insertDatasetDataVector({
teamId: 'team_123',
datasetId: 'dataset_456',
collectionId: 'col_789',
inputs: [
{
type: 'image',
input: 'data:image/png;base64,image'
}
],
model: mockModel as any
});
expect(mockGetVectors).toHaveBeenCalledWith({
model: mockModel,
inputs: [
{
type: 'image',
input: 'data:image/png;base64,image'
}
],
type: 'db'
});
expect(mockVectorInsert).toHaveBeenCalledWith({
teamId: 'team_123',
datasetId: 'dataset_456',
collectionId: 'col_789',
vectors: mockVectors
});
expect(result).toEqual({
tokens: 1,
insertIds: ['image_id']
});
});
it('should invalidate team vector cache after insert', async () => { it('should invalidate team vector cache after insert', async () => {
mockGetVectors.mockResolvedValue({ mockGetVectors.mockResolvedValue({
tokens: 50, tokens: 50,
......
import { describe, expect, it } from 'vitest';
import { formatDatasetDataValue } from '@fastgpt/service/core/dataset/data/controller';
describe('formatDatasetDataValue', () => {
it('should append image descriptions to markdown image alt text in question and answer', () => {
const result = formatDatasetDataValue({
q: 'Question ![cat]( https://example.com/cat.png ) and ![bird](https://example.com/bird.png)',
a: 'Answer ![](https://example.com/dog.png)',
imageDescMap: {
'https://example.com/cat.png': 'cat desc\nline',
'https://example.com/dog.png': 'dog desc'
}
});
expect(result).toEqual({
q: 'Question ![cat - cat descline](https://example.com/cat.png) and ![bird](https://example.com/bird.png)',
a: 'Answer ![dog desc](https://example.com/dog.png)'
});
});
});
...@@ -15,6 +15,7 @@ vi.mock('@fastgpt/service/common/file/image/utils', () => ({ ...@@ -15,6 +15,7 @@ vi.mock('@fastgpt/service/common/file/image/utils', () => ({
import { import {
computeFilterIntersection, computeFilterIntersection,
datasetSearchQueryExtension, datasetSearchQueryExtension,
isValidImageEmbeddingSource,
normalizeImageToBase64 normalizeImageToBase64
} from '../../../../core/dataset/search/utils'; } from '../../../../core/dataset/search/utils';
...@@ -86,6 +87,21 @@ describe('normalizeImageToBase64', () => { ...@@ -86,6 +87,21 @@ describe('normalizeImageToBase64', () => {
}); });
}); });
describe('isValidImageEmbeddingSource', () => {
it('should accept model-readable image sources', () => {
expect(isValidImageEmbeddingSource('data:image/png;base64,input')).toBe(true);
expect(isValidImageEmbeddingSource('dataset/team/file.png')).toBe(true);
expect(isValidImageEmbeddingSource('temp/team/file.png')).toBe(true);
expect(isValidImageEmbeddingSource('chat/app/user/file.png')).toBe(true);
expect(isValidImageEmbeddingSource('https://example.com/file.png')).toBe(true);
});
it('should reject empty or local non-url image sources', () => {
expect(isValidImageEmbeddingSource('')).toBe(false);
expect(isValidImageEmbeddingSource('/local/file.png')).toBe(false);
});
});
describe('computeFilterIntersection', () => { describe('computeFilterIntersection', () => {
it('should return undefined when every filter is absent', () => { it('should return undefined when every filter is absent', () => {
expect(computeFilterIntersection([])).toBeUndefined(); expect(computeFilterIntersection([])).toBeUndefined();
......
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { replaceS3KeyToPreviewUrl } from '@fastgpt/service/core/dataset/utils'; import {
getDatasetImageIndexCapability,
getDatasetImageTrainingMode,
replaceS3KeyToPreviewUrl
} from '@fastgpt/service/core/dataset/utils';
import {
matchDatasetDataMarkdownImages,
matchDatasetDataMarkdownImageUrls,
uniqueDatasetDataMarkdownImageUrls
} from '@fastgpt/service/core/dataset/data/utils';
import { getTrainingModeByCollection } from '@fastgpt/service/core/dataset/collection/utils';
import {
DatasetCollectionDataProcessModeEnum,
TrainingModeEnum
} from '@fastgpt/global/core/dataset/constants';
vi.mock('@fastgpt/service/common/s3/utils', () => ({ vi.mock('@fastgpt/service/common/s3/utils', () => ({
jwtSignS3DownloadToken: vi.fn( jwtSignS3DownloadToken: vi.fn(
...@@ -439,3 +453,144 @@ describe('replaceS3KeyToPreviewUrl', () => { ...@@ -439,3 +453,144 @@ describe('replaceS3KeyToPreviewUrl', () => {
}); });
}); });
}); });
describe('matchDatasetDataMarkdownImageUrls', () => {
it('应提取统一的 markdown 图片节点结构', () => {
const result = matchDatasetDataMarkdownImages(
'文字 ![猫]( dataset/team/cat.png ) 和 ![dog](https://example.com/dog.png)'
);
expect(result).toEqual([
{
raw: '![猫]( dataset/team/cat.png )',
alt: '猫',
url: 'dataset/team/cat.png',
index: expect.any(Number)
},
{
raw: '![dog](https://example.com/dog.png)',
alt: 'dog',
url: 'https://example.com/dog.png',
index: expect.any(Number)
}
]);
});
it('应提取 markdown 图片 URL 并忽略普通链接', () => {
const result = matchDatasetDataMarkdownImageUrls(
'![a](dataset/team/a.png) [普通链接](https://example.com) ![b](https://img.test/b.jpg)'
);
expect(result).toEqual(['dataset/team/a.png', 'https://img.test/b.jpg']);
});
it('应从多个文本字段按首次出现顺序去重图片 URL', () => {
const result = uniqueDatasetDataMarkdownImageUrls([
'new ![a](dataset/team/a.png) ![a again](dataset/team/a.png)',
undefined,
'old ![b](https://example.com/b.jpg)'
]);
expect(result).toEqual(['dataset/team/a.png', 'https://example.com/b.jpg']);
});
});
describe('getDatasetImageTrainingMode', () => {
it('有 VLM 且是图片数据时应走 imageParse', () => {
expect(
getDatasetImageTrainingMode({
supportVlm: true,
supportImageIndex: true,
imageId: 'dataset/team/image.png',
hasMarkdownImages: false
})
).toBe(TrainingModeEnum.imageParse);
});
it('有图片索引能力且正文有 markdown 图片时应走 image', () => {
expect(
getDatasetImageTrainingMode({
supportVlm: false,
supportImageIndex: true,
hasMarkdownImages: true
})
).toBe(TrainingModeEnum.image);
});
it('没有图片索引能力时应回退 chunk', () => {
expect(
getDatasetImageTrainingMode({
supportVlm: false,
supportImageIndex: false,
hasMarkdownImages: true
})
).toBe(TrainingModeEnum.chunk);
});
});
describe('getTrainingModeByCollection', () => {
beforeEach(() => {
global.feConfigs = {
...global.feConfigs,
isPlus: true
};
});
it('图片自动索引有 VLM 或原生 embedding 图片索引能力时进入 image 队列', () => {
expect(
getTrainingModeByCollection({
trainingType: DatasetCollectionDataProcessModeEnum.chunk,
imageIndex: true,
supportImageIndex: true
})
).toBe(TrainingModeEnum.image);
expect(
getTrainingModeByCollection({
trainingType: DatasetCollectionDataProcessModeEnum.chunk,
imageIndex: true,
supportImageIndex: false
})
).toBe(TrainingModeEnum.chunk);
});
});
describe('getDatasetImageIndexCapability', () => {
beforeEach(() => {
global.embeddingModelMap.set('vision-embedding-model', {
...global.systemDefaultModel.embedding,
model: 'vision-embedding-model',
name: 'vision-embedding-model',
vision: true
});
global.llmModelMap.set('dataset-vlm-model', {
...global.systemDefaultModel.llm,
model: 'dataset-vlm-model',
name: 'dataset-vlm-model',
vision: true
});
});
it('未配置 VLM 时不应自动回退到默认 VLM', () => {
const result = getDatasetImageIndexCapability({
vectorModel: 'vision-embedding-model'
});
expect(result.supportVlm).toBe(false);
expect(result.supportImageEmbedding).toBe(true);
expect(result.supportImageIndex).toBe(true);
expect(result.availableVlmModel).toBeUndefined();
});
it('配置 VLM 时应同时返回 VLM 和多模态索引能力', () => {
const result = getDatasetImageIndexCapability({
vectorModel: 'vision-embedding-model',
vlmModel: 'dataset-vlm-model'
});
expect(result.supportVlm).toBe(true);
expect(result.supportImageEmbedding).toBe(true);
expect(result.supportImageIndex).toBe(true);
expect(result.availableVlmModel?.model).toBe('dataset-vlm-model');
});
});
...@@ -50,6 +50,8 @@ ...@@ -50,6 +50,8 @@
"data_amount": "{{dataAmount}} chunks, {{indexAmount}} indexes", "data_amount": "{{dataAmount}} chunks, {{indexAmount}} indexes",
"data_error_amount": "{{errorAmount}} Group training exception", "data_error_amount": "{{errorAmount}} Group training exception",
"data_index_image": "Image index", "data_index_image": "Image index",
"data_index_image_embedding": "Multimodal image index",
"image_embedding_index_default_desc": "An image vector has been generated by the multimodal model and can be used for image search",
"data_parsing": "Data analysis", "data_parsing": "Data analysis",
"data_uploading": "Data is being uploaded: {{num}}%", "data_uploading": "Data is being uploaded: {{num}}%",
"dataset.Chunk_Number": "Block number", "dataset.Chunk_Number": "Block number",
......
...@@ -50,6 +50,8 @@ ...@@ -50,6 +50,8 @@
"data_amount": "{{dataAmount}} 组数据, {{indexAmount}} 组索引", "data_amount": "{{dataAmount}} 组数据, {{indexAmount}} 组索引",
"data_error_amount": "{{errorAmount}} 组训练异常", "data_error_amount": "{{errorAmount}} 组训练异常",
"data_index_image": "图片索引", "data_index_image": "图片索引",
"data_index_image_embedding": "多模态图片索引",
"image_embedding_index_default_desc": "已通过多模态模型生成图片向量,支持以图搜图",
"data_parsing": "数据解析中", "data_parsing": "数据解析中",
"data_uploading": "数据上传中: {{num}}%", "data_uploading": "数据上传中: {{num}}%",
"dataset.Chunk_Number": "分块号", "dataset.Chunk_Number": "分块号",
......
...@@ -50,6 +50,8 @@ ...@@ -50,6 +50,8 @@
"data_amount": "{{dataAmount}} 組資料,{{indexAmount}} 組索引", "data_amount": "{{dataAmount}} 組資料,{{indexAmount}} 組索引",
"data_error_amount": "{{errorAmount}} 組訓練異常", "data_error_amount": "{{errorAmount}} 組訓練異常",
"data_index_image": "圖片索引", "data_index_image": "圖片索引",
"data_index_image_embedding": "多模態圖片索引",
"image_embedding_index_default_desc": "已透過多模態模型生成圖片向量,支援以圖搜圖",
"data_parsing": "數據解析中", "data_parsing": "數據解析中",
"data_uploading": "數據上傳中: {{num}}%", "data_uploading": "數據上傳中: {{num}}%",
"dataset.Chunk_Number": "分塊號", "dataset.Chunk_Number": "分塊號",
......
Subproject commit 821e0d5d30280d6ed4ea5c42f8682d07a6f87bf6 Subproject commit 3cd897c756ca2c0d7c7fee9f68c0d88ad89b7009
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
DatasetDataIndexTypeEnum, DatasetDataIndexTypeEnum,
getDatasetIndexMapData getDatasetIndexMapData
} from '@fastgpt/global/core/dataset/data/constants'; } from '@fastgpt/global/core/dataset/data/constants';
import { isDatasetDataSystemIndexType } from '@fastgpt/global/core/dataset/data/utils';
import type { InputDataType } from './useInputDataModal'; import type { InputDataType } from './useInputDataModal';
type IndexField = UseFieldArrayReturn<InputDataType, 'indexes'>['fields'][number]; type IndexField = UseFieldArrayReturn<InputDataType, 'indexes'>['fields'][number];
...@@ -91,10 +92,14 @@ const IndexInputPanel = ({ ...@@ -91,10 +92,14 @@ const IndexInputPanel = ({
const canFoldIndex = indexes.length > 1; const canFoldIndex = indexes.length > 1;
const hasIndexDataId = !!index.dataId; const hasIndexDataId = !!index.dataId;
const isDeletingCurrentIndex = deletingIndexClientId === index.clientId; const isDeletingCurrentIndex = deletingIndexClientId === index.clientId;
const isDefaultIndex = index.type === DatasetDataIndexTypeEnum.default; const isSystem = isDatasetDataSystemIndexType(index.type);
const canDeleteIndex = const canDeleteIndex =
canWrite && !isDefaultIndex && hasIndexDataId && !isDeletingCurrentIndex; canWrite && !isSystem && hasIndexDataId && !isDeletingCurrentIndex;
const canToggleFold = canFoldIndex && !isDeletingCurrentIndex; const canToggleFold = canFoldIndex && !isDeletingCurrentIndex;
const isImageEmbeddingIndex = index.type === DatasetDataIndexTypeEnum.imageEmbedding;
const indexText = isImageEmbeddingIndex
? t('dataset:image_embedding_index_default_desc')
: index.text;
return ( return (
<MyBox <MyBox
...@@ -152,11 +157,11 @@ const IndexInputPanel = ({ ...@@ -152,11 +157,11 @@ const IndexInputPanel = ({
)} )}
</Flex> </Flex>
<DataIndexTextArea <DataIndexTextArea
disabled={!canWrite || isDefaultIndex} disabled={!canWrite || isSystem}
canClickMark={hasIndexDataId} canClickMark={hasIndexDataId}
autoFocus={focusIndexClientId === index.clientId} autoFocus={focusIndexClientId === index.clientId}
index={i} index={i}
value={index.text} value={indexText}
isFolder={index.fold && canFoldIndex} isFolder={index.fold && canFoldIndex}
maxToken={maxToken} maxToken={maxToken}
register={register} register={register}
......
...@@ -16,6 +16,7 @@ import { useRequest } from '@fastgpt/web/hooks/useRequest'; ...@@ -16,6 +16,7 @@ import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { useToast } from '@fastgpt/web/hooks/useToast'; import { useToast } from '@fastgpt/web/hooks/useToast';
import type { DatasetDataIndexItemType } from '@fastgpt/global/core/dataset/type'; import type { DatasetDataIndexItemType } from '@fastgpt/global/core/dataset/type';
import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants'; import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants';
import { isDatasetDataSystemIndexType } from '@fastgpt/global/core/dataset/data/utils';
import { DatasetCollectionTypeEnum } from '@fastgpt/global/core/dataset/constants'; import { DatasetCollectionTypeEnum } from '@fastgpt/global/core/dataset/constants';
export type InputDataType = { export type InputDataType = {
...@@ -45,8 +46,8 @@ const clearEditingIndexDelay = 600; ...@@ -45,8 +46,8 @@ const clearEditingIndexDelay = 600;
const sortIndexesForDisplay = (indexes: InputDataIndexType[] = []) => { const sortIndexesForDisplay = (indexes: InputDataIndexType[] = []) => {
const getOrder = (index: InputDataIndexType) => { const getOrder = (index: InputDataIndexType) => {
// Keep the editable custom indexes before the generated default index. // Keep editable indexes before the generated system indexes.
if (index.type === DatasetDataIndexTypeEnum.default) return 1; if (isDatasetDataSystemIndexType(index.type)) return 1;
return 0; return 0;
}; };
...@@ -82,7 +83,7 @@ const formatIndexesForForm = ( ...@@ -82,7 +83,7 @@ const formatIndexesForForm = (
const formatIndexesForRequest = (indexes: InputDataType['indexes'] = []) => const formatIndexesForRequest = (indexes: InputDataType['indexes'] = []) =>
indexes indexes
.filter((item) => !!item.text?.trim()) .filter((item) => !isDatasetDataSystemIndexType(item.type) && !!item.text?.trim())
.map((item) => ({ .map((item) => ({
// Strip UI-only fields before submitting to the import API. // Strip UI-only fields before submitting to the import API.
type: item.type, type: item.type,
...@@ -346,12 +347,17 @@ export const useInputDataModal = ({ ...@@ -346,12 +347,17 @@ export const useInputDataModal = ({
async (e: InputDataType) => { async (e: InputDataType) => {
if (!dataId) return Promise.reject(t('common:error.unKnow')); if (!dataId) return Promise.reject(t('common:error.unKnow'));
await putDatasetDataById({ const updateResult = await putDatasetDataById({
dataId, dataId,
q: e.q, q: e.q,
a: currentTab === TabEnum.qa ? e.a : '' a: currentTab === TabEnum.qa ? e.a : ''
}); });
return refreshDataForm(dataId); const refreshedData = await refreshDataForm(dataId);
return {
...refreshedData,
q: updateResult.q ?? refreshedData.q,
a: updateResult.a ?? refreshedData.a
};
}, },
{ {
refreshDeps: [currentTab, refreshDataForm], refreshDeps: [currentTab, refreshDataForm],
...@@ -377,7 +383,7 @@ export const useInputDataModal = ({ ...@@ -377,7 +383,7 @@ export const useInputDataModal = ({
const text = targetIndex?.text?.trim() || ''; const text = targetIndex?.text?.trim() || '';
const type = targetIndex?.type || DatasetDataIndexTypeEnum.custom; const type = targetIndex?.type || DatasetDataIndexTypeEnum.custom;
if (type === DatasetDataIndexTypeEnum.default) { if (isDatasetDataSystemIndexType(type)) {
return; return;
} }
...@@ -456,6 +462,12 @@ export const useInputDataModal = ({ ...@@ -456,6 +462,12 @@ export const useInputDataModal = ({
if (successData) { if (successData) {
onSuccess(successData); onSuccess(successData);
} }
if (!shouldSaveLatest) {
toast({
title: t('common:save_success'),
status: 'success'
});
}
} catch (error) { } catch (error) {
saveError = error; saveError = error;
} finally { } finally {
...@@ -476,7 +488,16 @@ export const useInputDataModal = ({ ...@@ -476,7 +488,16 @@ export const useInputDataModal = ({
} }
}, },
{ {
refreshDeps: [dataId, findIndexByClientId, getSuccessData, getValues, removeIndexes, setValue] refreshDeps: [
dataId,
findIndexByClientId,
getSuccessData,
getValues,
removeIndexes,
setValue,
t,
toast
]
} }
); );
......
...@@ -22,6 +22,7 @@ import { getTeamPlanStatus } from '@fastgpt/service/support/wallet/sub/utils'; ...@@ -22,6 +22,7 @@ import { getTeamPlanStatus } from '@fastgpt/service/support/wallet/sub/utils';
import { datasetImageCollectionFileType } from '@fastgpt/global/common/file/constants'; import { datasetImageCollectionFileType } from '@fastgpt/global/common/file/constants';
import { parseAllowedExtensions } from '@fastgpt/service/common/s3/utils/uploadConstraints'; import { parseAllowedExtensions } from '@fastgpt/service/common/s3/utils/uploadConstraints';
import { checkDatasetIndexLimit } from '@fastgpt/service/support/permission/teamLimit'; import { checkDatasetIndexLimit } from '@fastgpt/service/support/permission/teamLimit';
import { getDatasetImageIndexCapability } from '@fastgpt/service/core/dataset/utils';
async function handler(req: ApiRequestProps): Promise<CreateCollectionWithResultResponseType> { async function handler(req: ApiRequestProps): Promise<CreateCollectionWithResultResponseType> {
const filepaths: string[] = []; const filepaths: string[] = [];
...@@ -59,7 +60,12 @@ async function handler(req: ApiRequestProps): Promise<CreateCollectionWithResult ...@@ -59,7 +60,12 @@ async function handler(req: ApiRequestProps): Promise<CreateCollectionWithResult
num: result.fileMetadata.length num: result.fileMetadata.length
}); });
if (!dataset.vlmModel) { const { supportVlm, supportImageEmbedding } = getDatasetImageIndexCapability({
vectorModel: dataset.vectorModel,
vlmModel: dataset.vlmModel
});
if (!supportVlm && !supportImageEmbedding) {
return Promise.reject(i18nT('file:Image_dataset_requires_VLM_model_to_be_configured')); return Promise.reject(i18nT('file:Image_dataset_requires_VLM_model_to_be_configured'));
} }
...@@ -87,7 +93,9 @@ async function handler(req: ApiRequestProps): Promise<CreateCollectionWithResult ...@@ -87,7 +93,9 @@ async function handler(req: ApiRequestProps): Promise<CreateCollectionWithResult
datasetId, datasetId,
type: DatasetCollectionTypeEnum.images, type: DatasetCollectionTypeEnum.images,
name: collectionName, name: collectionName,
trainingType: DatasetCollectionDataProcessModeEnum.imageParse trainingType: supportVlm
? DatasetCollectionDataProcessModeEnum.imageParse
: DatasetCollectionDataProcessModeEnum.chunk
} }
}); });
} catch (error) { } catch (error) {
......
...@@ -47,6 +47,8 @@ async function handler(req: ApiRequestProps): Promise<InsertDataResponse> { ...@@ -47,6 +47,8 @@ async function handler(req: ApiRequestProps): Promise<InsertDataResponse> {
{ {
dataset: { _id: datasetId, vectorModel }, dataset: { _id: datasetId, vectorModel },
indexPrefixTitle, indexPrefixTitle,
imageIndex,
indexSize,
name name
} }
] = await Promise.all([getCollectionWithDataset(collectionId)]); ] = await Promise.all([getCollectionWithDataset(collectionId)]);
...@@ -76,8 +78,10 @@ async function handler(req: ApiRequestProps): Promise<InsertDataResponse> { ...@@ -76,8 +78,10 @@ async function handler(req: ApiRequestProps): Promise<InsertDataResponse> {
q: formatQ, q: formatQ,
a: formatA, a: formatA,
chunkIndex: 0, chunkIndex: 0,
indexSize,
indexPrefix: indexPrefixTitle ? `# ${name}` : undefined, indexPrefix: indexPrefixTitle ? `# ${name}` : undefined,
embeddingModel: vectorModelData.model, embeddingModel: vectorModelData.model,
imageIndex: !!imageIndex,
indexes: formatIndexes indexes: formatIndexes
}); });
......
...@@ -7,7 +7,7 @@ import { WritePermissionVal } from '@fastgpt/global/support/permission/constant' ...@@ -7,7 +7,7 @@ import { WritePermissionVal } from '@fastgpt/global/support/permission/constant'
import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun';
import { createTrainingUsage } from '@fastgpt/service/support/wallet/usage/controller'; import { createTrainingUsage } from '@fastgpt/service/support/wallet/usage/controller';
import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants';
import { getEmbeddingModel, getLLMModel, getVlmModel } from '@fastgpt/service/core/ai/model'; import { getEmbeddingModel, getLLMModel } from '@fastgpt/service/core/ai/model';
import { pushDataListToTrainingQueue } from '@fastgpt/service/core/dataset/training/controller'; import { pushDataListToTrainingQueue } from '@fastgpt/service/core/dataset/training/controller';
import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants'; import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants';
import path from 'node:path'; import path from 'node:path';
...@@ -21,6 +21,8 @@ import { ...@@ -21,6 +21,8 @@ import {
} from '@fastgpt/global/openapi/core/dataset/data/api'; } from '@fastgpt/global/openapi/core/dataset/data/api';
import { datasetImageCollectionFileType } from '@fastgpt/global/common/file/constants'; import { datasetImageCollectionFileType } from '@fastgpt/global/common/file/constants';
import { parseAllowedExtensions } from '@fastgpt/service/common/s3/utils/uploadConstraints'; import { parseAllowedExtensions } from '@fastgpt/service/common/s3/utils/uploadConstraints';
import { i18nT } from '@fastgpt/global/common/i18n/utils';
import { getDatasetImageIndexCapability } from '@fastgpt/service/core/dataset/utils';
async function handler(req: ApiRequestProps): Promise<InsertImagesResponse> { async function handler(req: ApiRequestProps): Promise<InsertImagesResponse> {
const filepaths: string[] = []; const filepaths: string[] = [];
...@@ -42,6 +44,16 @@ async function handler(req: ApiRequestProps): Promise<InsertImagesResponse> { ...@@ -42,6 +44,16 @@ async function handler(req: ApiRequestProps): Promise<InsertImagesResponse> {
authApiKey: true authApiKey: true
}); });
const dataset = collection.dataset; const dataset = collection.dataset;
const { availableVlmModel, supportVlm, supportImageEmbedding } = getDatasetImageIndexCapability(
{
vectorModel: dataset.vectorModel,
vlmModel: dataset.vlmModel
}
);
if (!supportVlm && !supportImageEmbedding) {
return Promise.reject(i18nT('file:Image_dataset_requires_VLM_model_to_be_configured'));
}
const planStatus = await getTeamPlanStatus({ teamId }); const planStatus = await getTeamPlanStatus({ teamId });
await authFrequencyLimit({ await authFrequencyLimit({
...@@ -75,7 +87,7 @@ async function handler(req: ApiRequestProps): Promise<InsertImagesResponse> { ...@@ -75,7 +87,7 @@ async function handler(req: ApiRequestProps): Promise<InsertImagesResponse> {
billSource: UsageSourceEnum.training, billSource: UsageSourceEnum.training,
vectorModel: getEmbeddingModel(dataset.vectorModel)?.name, vectorModel: getEmbeddingModel(dataset.vectorModel)?.name,
agentModel: getLLMModel(dataset.agentModel)?.name, agentModel: getLLMModel(dataset.agentModel)?.name,
vllmModel: getVlmModel(dataset.vlmModel)?.name, vllmModel: availableVlmModel?.name,
session session
}); });
return usageId; return usageId;
...@@ -89,9 +101,9 @@ async function handler(req: ApiRequestProps): Promise<InsertImagesResponse> { ...@@ -89,9 +101,9 @@ async function handler(req: ApiRequestProps): Promise<InsertImagesResponse> {
agentModel: dataset.agentModel, agentModel: dataset.agentModel,
vectorModel: dataset.vectorModel, vectorModel: dataset.vectorModel,
vlmModel: dataset.vlmModel, vlmModel: dataset.vlmModel,
mode: TrainingModeEnum.imageParse, mode: supportVlm ? TrainingModeEnum.imageParse : TrainingModeEnum.chunk,
billId: traingBillId, billId: traingBillId,
data: imageIds.map((item, index) => ({ data: imageIds.map((item) => ({
imageId: item imageId: item
})), })),
session session
......
...@@ -6,6 +6,7 @@ import { pushDataListToTrainingQueue } from '@fastgpt/service/core/dataset/train ...@@ -6,6 +6,7 @@ import { pushDataListToTrainingQueue } from '@fastgpt/service/core/dataset/train
import { NextAPI } from '@/service/middleware/entry'; import { NextAPI } from '@/service/middleware/entry';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant'; import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { getTrainingModeByCollection } from '@fastgpt/service/core/dataset/collection/utils'; import { getTrainingModeByCollection } from '@fastgpt/service/core/dataset/collection/utils';
import { getDatasetImageIndexCapability } from '@fastgpt/service/core/dataset/utils';
import type { ApiRequestProps } from '@fastgpt/service/type/next'; import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { import {
PushDataBodySchema, PushDataBodySchema,
...@@ -17,7 +18,6 @@ import { getLLMModel } from '@fastgpt/service/core/ai/model'; ...@@ -17,7 +18,6 @@ import { getLLMModel } from '@fastgpt/service/core/ai/model';
import { getVlmModel } from '@fastgpt/service/core/ai/model'; import { getVlmModel } from '@fastgpt/service/core/ai/model';
import { createTrainingUsage } from '@fastgpt/service/support/wallet/usage/controller'; import { createTrainingUsage } from '@fastgpt/service/support/wallet/usage/controller';
import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun';
import { DatasetCollectionDataProcessModeEnum } from '@fastgpt/global/core/dataset/constants';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
async function handler(req: ApiRequestProps): Promise<PushDataResponseType> { async function handler(req: ApiRequestProps): Promise<PushDataResponseType> {
...@@ -36,7 +36,13 @@ async function handler(req: ApiRequestProps): Promise<PushDataResponseType> { ...@@ -36,7 +36,13 @@ async function handler(req: ApiRequestProps): Promise<PushDataResponseType> {
per: WritePermissionVal per: WritePermissionVal
}); });
const mode = getTrainingModeByCollection(collection); const mode = getTrainingModeByCollection({
...collection,
supportImageIndex: getDatasetImageIndexCapability({
vectorModel: collection.dataset.vectorModel,
vlmModel: collection.dataset.vlmModel
}).supportImageIndex
});
// auth dataset limit // auth dataset limit
await checkDatasetIndexLimit({ await checkDatasetIndexLimit({
......
import { import {
updateDatasetDataByIndexes, updateDatasetDataSystemIndexes,
updateDatasetDataDefaultIndexes updateDatasetDataByIndexes
} from '@/service/core/dataset/data/data'; } from '@/service/core/dataset/data/data';
import { pushGenerateVectorUsage } from '@/service/support/wallet/usage/push'; import { pushGenerateVectorUsage } from '@/service/support/wallet/usage/push';
import { NextAPI } from '@/service/middleware/entry'; import { NextAPI } from '@/service/middleware/entry';
...@@ -16,6 +16,9 @@ import { ...@@ -16,6 +16,9 @@ import {
UpdateDatasetDataResponseSchema, UpdateDatasetDataResponseSchema,
type UpdateDatasetDataResponse type UpdateDatasetDataResponse
} from '@fastgpt/global/openapi/core/dataset/data/api'; } from '@fastgpt/global/openapi/core/dataset/data/api';
import { replaceS3KeyToPreviewUrl } from '@fastgpt/service/core/dataset/utils';
import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants';
import { addHours } from 'date-fns';
async function handler(req: ApiRequestProps): Promise<UpdateDatasetDataResponse> { async function handler(req: ApiRequestProps): Promise<UpdateDatasetDataResponse> {
const { dataId, q, a, indexes } = parseApiInput({ const { dataId, q, a, indexes } = parseApiInput({
...@@ -26,11 +29,7 @@ async function handler(req: ApiRequestProps): Promise<UpdateDatasetDataResponse> ...@@ -26,11 +29,7 @@ async function handler(req: ApiRequestProps): Promise<UpdateDatasetDataResponse>
// auth data permission // auth data permission
const { const {
collection: { collection: { name, indexPrefixTitle },
dataset: { vectorModel },
name,
indexPrefixTitle
},
teamId, teamId,
tmbId, tmbId,
collection, collection,
...@@ -43,13 +42,38 @@ async function handler(req: ApiRequestProps): Promise<UpdateDatasetDataResponse> ...@@ -43,13 +42,38 @@ async function handler(req: ApiRequestProps): Promise<UpdateDatasetDataResponse>
per: WritePermissionVal per: WritePermissionVal
}); });
const dataset = collection.dataset;
const vectorModel = dataset.vectorModel;
const nextQ = q ?? datasetData.q ?? '';
const nextA = a ?? datasetData.a ?? '';
const pushUpdateDataAuditLog = () => {
addAuditLog({
tmbId,
teamId,
event: AuditEventEnum.UPDATE_DATA,
params: {
collectionName: collection.name,
datasetName: collection.dataset?.name || '',
datasetType: getI18nDatasetType(collection.dataset?.type || '')
}
});
};
if (hasIndexes) { if (hasIndexes) {
// 兼容旧 API:调用方显式传 indexes 时仍按完整索引更新。
// imageEmbedding 是系统索引,不能被外部 indexes 覆盖,统一由 dataIndex 根据数据内容重建。
const manualIndexes = indexes?.filter(
(index) => index.type !== DatasetDataIndexTypeEnum.imageEmbedding
);
const { tokens } = await updateDatasetDataByIndexes({ const { tokens } = await updateDatasetDataByIndexes({
dataId, dataId,
q, q: nextQ,
a, a: nextA,
indexes: indexes ?? [], imageId: datasetData.imageId,
imageIndex: !!collection.imageIndex,
indexes: manualIndexes || [],
model: vectorModel, model: vectorModel,
indexSize: collection.indexSize,
indexPrefix: indexPrefixTitle ? `# ${name}` : undefined indexPrefix: indexPrefixTitle ? `# ${name}` : undefined
}); });
...@@ -61,14 +85,13 @@ async function handler(req: ApiRequestProps): Promise<UpdateDatasetDataResponse> ...@@ -61,14 +85,13 @@ async function handler(req: ApiRequestProps): Promise<UpdateDatasetDataResponse>
model: vectorModel model: vectorModel
}); });
} }
} else { } else if (!!nextQ || !!datasetData.imageId) {
const nextQ = q || datasetData.q || ''; const { tokens } = await updateDatasetDataSystemIndexes({
const nextA = a ?? datasetData.a ?? '';
const { tokens } = await updateDatasetDataDefaultIndexes({
dataId, dataId,
q: nextQ, q: nextQ,
a: nextA, a: nextA,
imageId: datasetData.imageId,
imageIndex: !!collection.imageIndex,
model: vectorModel, model: vectorModel,
indexSize: collection.indexSize, indexSize: collection.indexSize,
indexPrefix: indexPrefixTitle ? `# ${name}` : undefined indexPrefix: indexPrefixTitle ? `# ${name}` : undefined
...@@ -84,20 +107,12 @@ async function handler(req: ApiRequestProps): Promise<UpdateDatasetDataResponse> ...@@ -84,20 +107,12 @@ async function handler(req: ApiRequestProps): Promise<UpdateDatasetDataResponse>
} }
} }
(() => { pushUpdateDataAuditLog();
addAuditLog({
tmbId,
teamId,
event: AuditEventEnum.UPDATE_DATA,
params: {
collectionName: collection.name,
datasetName: collection.dataset?.name || '',
datasetType: getI18nDatasetType(collection.dataset?.type || '')
}
});
})();
return UpdateDatasetDataResponseSchema.parse({}); return UpdateDatasetDataResponseSchema.parse({
q: replaceS3KeyToPreviewUrl(nextQ, addHours(new Date(), 1)),
a: nextA ? replaceS3KeyToPreviewUrl(nextA, addHours(new Date(), 1)) : undefined
});
} }
export default NextAPI(handler); export default NextAPI(handler);
...@@ -3,10 +3,16 @@ import { authDataset } from '@fastgpt/service/support/permission/dataset/auth'; ...@@ -3,10 +3,16 @@ import { authDataset } from '@fastgpt/service/support/permission/dataset/auth';
import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun';
import { MongoDataset } from '@fastgpt/service/core/dataset/schema'; import { MongoDataset } from '@fastgpt/service/core/dataset/schema';
import { MongoDatasetData } from '@fastgpt/service/core/dataset/data/schema'; import { MongoDatasetData } from '@fastgpt/service/core/dataset/data/schema';
import { MongoDatasetCollection } from '@fastgpt/service/core/dataset/collection/schema';
import { MongoDatasetTraining } from '@fastgpt/service/core/dataset/training/schema'; import { MongoDatasetTraining } from '@fastgpt/service/core/dataset/training/schema';
import { createTrainingUsage } from '@fastgpt/service/support/wallet/usage/controller'; import { createTrainingUsage } from '@fastgpt/service/support/wallet/usage/controller';
import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants';
import { getLLMModel, getEmbeddingModel, getVlmModel } from '@fastgpt/service/core/ai/model'; import { getLLMModel, getEmbeddingModel } from '@fastgpt/service/core/ai/model';
import {
getDatasetImageIndexCapability,
getDatasetImageTrainingMode
} from '@fastgpt/service/core/dataset/utils';
import { uniqueDatasetDataMarkdownImageUrls } from '@fastgpt/service/core/dataset/data/utils';
import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants'; import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants';
import { type ApiRequestProps } from '@fastgpt/service/type/next'; import { type ApiRequestProps } from '@fastgpt/service/type/next';
import { OwnerPermissionVal } from '@fastgpt/global/support/permission/constant'; import { OwnerPermissionVal } from '@fastgpt/global/support/permission/constant';
...@@ -46,14 +52,19 @@ async function handler(req: ApiRequestProps): Promise<RebuildEmbeddingResponse> ...@@ -46,14 +52,19 @@ async function handler(req: ApiRequestProps): Promise<RebuildEmbeddingResponse>
return Promise.reject('数据集正在训练或者重建中,请稍后再试'); return Promise.reject('数据集正在训练或者重建中,请稍后再试');
} }
const { availableVlmModel, supportVlm, supportImageIndex } = getDatasetImageIndexCapability({
vectorModel,
vlmModel: dataset.vlmModel
});
const { usageId } = await createTrainingUsage({ const { usageId } = await createTrainingUsage({
teamId, teamId,
tmbId, tmbId,
appName: '切换索引模型', appName: '切换索引模型',
billSource: UsageSourceEnum.training, billSource: UsageSourceEnum.training,
vectorModel: getEmbeddingModel(dataset.vectorModel)?.name, vectorModel: getEmbeddingModel(vectorModel)?.name || vectorModel,
agentModel: getLLMModel(dataset.agentModel)?.name, agentModel: getLLMModel(dataset.agentModel)?.name,
vllmModel: getVlmModel(dataset.vlmModel)?.name vllmModel: availableVlmModel?.name
}); });
// update vector model and dataset.data rebuild field // update vector model and dataset.data rebuild field
...@@ -61,10 +72,27 @@ async function handler(req: ApiRequestProps): Promise<RebuildEmbeddingResponse> ...@@ -61,10 +72,27 @@ async function handler(req: ApiRequestProps): Promise<RebuildEmbeddingResponse>
await MongoDataset.findByIdAndUpdate( await MongoDataset.findByIdAndUpdate(
datasetId, datasetId,
{ {
vectorModel $set: {
vectorModel,
...(!supportImageIndex && { 'chunkSettings.imageIndex': false })
}
},
{ session }
);
if (!supportImageIndex) {
await MongoDatasetCollection.updateMany(
{
teamId,
datasetId
},
{
$set: {
imageIndex: false
}
}, },
{ session } { session }
); );
}
await MongoDatasetData.updateMany( await MongoDatasetData.updateMany(
{ {
teamId, teamId,
...@@ -85,7 +113,7 @@ async function handler(req: ApiRequestProps): Promise<RebuildEmbeddingResponse> ...@@ -85,7 +113,7 @@ async function handler(req: ApiRequestProps): Promise<RebuildEmbeddingResponse>
const max = global.systemEnv?.vectorMaxProcess || 10; const max = global.systemEnv?.vectorMaxProcess || 10;
const arr = new Array(max * 2).fill(0); const arr = new Array(max * 2).fill(0);
for await (const _ of arr) { for (let i = 0; i < arr.length; i++) {
try { try {
const hasNext = await mongoSessionRun(async (session) => { const hasNext = await mongoSessionRun(async (session) => {
// get next dataset.data // get next dataset.data
...@@ -106,10 +134,25 @@ async function handler(req: ApiRequestProps): Promise<RebuildEmbeddingResponse> ...@@ -106,10 +134,25 @@ async function handler(req: ApiRequestProps): Promise<RebuildEmbeddingResponse>
} }
).select({ ).select({
_id: 1, _id: 1,
collectionId: 1 collectionId: 1,
imageId: 1,
q: 1,
indexes: 1
}); });
if (data) { if (data) {
const collection = await MongoDatasetCollection.findById(data.collectionId)
.select('imageIndex')
.session(session);
const hasMarkdownImages =
!!collection?.imageIndex && uniqueDatasetDataMarkdownImageUrls([data.q]).length > 0;
const mode = getDatasetImageTrainingMode({
supportVlm,
supportImageIndex,
imageId: data.imageId,
hasMarkdownImages
});
await MongoDatasetTraining.create( await MongoDatasetTraining.create(
[ [
{ {
...@@ -118,9 +161,19 @@ async function handler(req: ApiRequestProps): Promise<RebuildEmbeddingResponse> ...@@ -118,9 +161,19 @@ async function handler(req: ApiRequestProps): Promise<RebuildEmbeddingResponse>
datasetId, datasetId,
collectionId: data.collectionId, collectionId: data.collectionId,
billId: usageId, billId: usageId,
mode: TrainingModeEnum.chunk, mode,
model: vectorModel, model:
(mode === TrainingModeEnum.imageParse || mode === TrainingModeEnum.image) &&
supportVlm &&
availableVlmModel
? availableVlmModel.model
: vectorModel,
dataId: data._id, dataId: data._id,
...(data.imageId && { imageId: data.imageId }),
...(mode === TrainingModeEnum.image && {
q: data.q,
indexes: data.indexes
}),
retryCount: 50 retryCount: 50
} }
], ],
...@@ -137,7 +190,7 @@ async function handler(req: ApiRequestProps): Promise<RebuildEmbeddingResponse> ...@@ -137,7 +190,7 @@ async function handler(req: ApiRequestProps): Promise<RebuildEmbeddingResponse>
if (!hasNext) { if (!hasNext) {
break; break;
} }
} catch (error) {} } catch {}
} }
return RebuildEmbeddingResponseSchema.parse({}); return RebuildEmbeddingResponseSchema.parse({});
......
...@@ -10,17 +10,49 @@ import { getEmbeddingModel } from '@fastgpt/service/core/ai/model'; ...@@ -10,17 +10,49 @@ import { getEmbeddingModel } from '@fastgpt/service/core/ai/model';
import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun';
import { type ClientSession } from '@fastgpt/service/common/mongo'; import { type ClientSession } from '@fastgpt/service/common/mongo';
import { MongoDatasetDataText } from '@fastgpt/service/core/dataset/data/dataTextSchema'; import { MongoDatasetDataText } from '@fastgpt/service/core/dataset/data/dataTextSchema';
import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants';
import { isS3ObjectKey, removeS3TTL } from '@fastgpt/service/common/s3/utils'; import { isS3ObjectKey, removeS3TTL } from '@fastgpt/service/common/s3/utils';
import { getS3DatasetSource } from '@fastgpt/service/common/s3/sources/dataset'; import { getS3DatasetSource } from '@fastgpt/service/common/s3/sources/dataset';
import { DatasetDataIndexOperation } from '@/service/core/dataset/data/dataIndex'; import {
datasetDataSystemIndexTypes,
isDatasetDataSystemIndexType
} from '@fastgpt/global/core/dataset/data/utils';
import {
DatasetDataIndexOperation,
type DatasetDataIndexDraft
} from '@/service/core/dataset/data/dataIndex';
type UpdateDatasetDataByIndexesProps = Omit<UpdateDatasetDataPropsType, 'indexes'> & { type UpdateDatasetDataByIndexesProps = Omit<UpdateDatasetDataPropsType, 'indexes'> & {
indexes: NonNullable<UpdateDatasetDataPropsType['indexes']>; indexes: NonNullable<UpdateDatasetDataPropsType['indexes']>;
model: string; model: string;
indexSize?: number; indexSize?: number;
imageIndex?: boolean;
}; };
type UpdateDatasetDataSystemIndexesProps = Omit<
UpdateDatasetDataByIndexesProps,
'indexes' | 'q'
> & {
q?: string;
imageIndex?: boolean;
indexes?: DatasetDataIndexDraft[];
};
/*
数据进入 data/dataIndex 层时,VLM 图片描述索引已经由训练链路提前处理。
这里只负责根据 data 当前内容生成系统索引,并保留外部传入的 question/summary/image/custom 索引。
数据的几种情况:
1. 普通文本数据:有 q/a
- q/a 拆成 default 文本索引。
- 如果 collection 开启 imageIndex 且 embedding model 支持多模态:
q/a 里的 markdown 图片链接会生成 imageEmbedding 图片向量索引。
2. 纯图片数据:有 imageId,q 可以没有
- 如果 embedding model 支持多模态:用 imageId 生成 imageEmbedding 图片向量索引。
- 如果上游 VLM 已经生成 q,q 会继续生成 default 文本索引。
*/
/** /**
* 数据条目的写操作入口。 * 数据条目的写操作入口。
* *
...@@ -81,26 +113,34 @@ export class DatasetDataOperation { ...@@ -81,26 +113,34 @@ export class DatasetDataOperation {
indexes, indexes,
indexPrefix, indexPrefix,
embeddingModel, embeddingModel,
imageIndex,
imageDescMap, imageDescMap,
session session
}: CreateDatasetDataPropsType & { }: CreateDatasetDataPropsType & {
embeddingModel: string; embeddingModel: string;
indexSize?: number; indexSize?: number;
imageIndex?: boolean;
imageDescMap?: Record<string, string>; imageDescMap?: Record<string, string>;
session?: ClientSession; session?: ClientSession;
}) { }) {
if (!q || !datasetId || !collectionId || !embeddingModel) { // 纯图片数据允许没有正文;indexQ 保持为空,避免生成普通 default 文本向量索引。
const dataQ = q || '';
const indexQ = q || '';
if ((!dataQ && !imageId) || !datasetId || !collectionId || !embeddingModel) {
return Promise.reject('q, datasetId, collectionId, embeddingModel is required'); return Promise.reject('q, datasetId, collectionId, embeddingModel is required');
} }
const embModel = getEmbeddingModel(embeddingModel); const embModel = getEmbeddingModel(embeddingModel)!;
indexSize = Math.min(embModel.maxToken, indexSize); indexSize = Math.min(embModel.maxToken, indexSize);
// 默认索引和自定义索引在这里统一规范化,确保后续向量写入的输入已去重、切分。 // 系统索引和外部索引在这里统一规范化,确保后续向量写入的输入已去重、切分。
const newIndexes = await this.indexOperation.formatIndexes({ const newIndexes = await this.indexOperation.formatIndexes({
indexes, indexes,
q, q: indexQ,
a, a,
imageId,
imageIndex,
indexSize, indexSize,
maxIndexSize: embModel.maxToken, maxIndexSize: embModel.maxToken,
indexPrefix indexPrefix
...@@ -121,7 +161,7 @@ export class DatasetDataOperation { ...@@ -121,7 +161,7 @@ export class DatasetDataOperation {
tmbId, tmbId,
datasetId, datasetId,
collectionId, collectionId,
q, q: dataQ,
a, a,
imageId, imageId,
imageDescMap, imageDescMap,
...@@ -140,7 +180,7 @@ export class DatasetDataOperation { ...@@ -140,7 +180,7 @@ export class DatasetDataOperation {
datasetId, datasetId,
collectionId, collectionId,
dataId: _id, dataId: _id,
fullTextToken: await jiebaSplit({ text: `${q}\n${a}`.trim() }) fullTextToken: await jiebaSplit({ text: `${indexQ}\n${a}`.trim() })
} }
], ],
{ session, ordered: true } { session, ordered: true }
...@@ -166,19 +206,25 @@ export class DatasetDataOperation { ...@@ -166,19 +206,25 @@ export class DatasetDataOperation {
/** /**
* 按调用方传入的完整 indexes 更新数据。 * 按调用方传入的完整 indexes 更新数据。
* *
* 这个路径用于“手动指定全部索引”的更新:调用方给出的 indexes 会和当前 indexes 做 * 这个路径用于“手动指定全部索引”的更新:调用方给出的 indexes 会和系统索引
* diff,新增/变更的索引重建向量,删除的索引清理旧向量。与 updateDefaultIndexes 不同, * 一起格式化后与当前 indexes 做 diff,新增/变更的索引重建向量,删除的索引清理旧向量。
* 它会以传入 indexes 为准更新整组索引。
*/ */
async updateByIndexes({ async updateByIndexes({
dataId, dataId,
q = '', q,
a, a,
imageId,
indexes, indexes,
model, model,
indexSize = 512, indexSize = 512,
indexPrefix indexPrefix,
imageIndex
}: UpdateDatasetDataByIndexesProps) { }: UpdateDatasetDataByIndexesProps) {
const embModel = getEmbeddingModel(model);
if (!embModel) {
return Promise.reject('Embedding model not found');
}
if (!Array.isArray(indexes)) { if (!Array.isArray(indexes)) {
return Promise.reject('indexes is required'); return Promise.reject('indexes is required');
} }
...@@ -186,30 +232,34 @@ export class DatasetDataOperation { ...@@ -186,30 +232,34 @@ export class DatasetDataOperation {
const mongoData = await MongoDatasetData.findById(dataId); const mongoData = await MongoDatasetData.findById(dataId);
if (!mongoData) return Promise.reject('Data not found'); if (!mongoData) return Promise.reject('Data not found');
const nextQ = q || mongoData.q || ''; // 获取新的索引组合
const nextQ = q ?? mongoData.q ?? '';
const nextA = a ?? mongoData.a ?? ''; const nextA = a ?? mongoData.a ?? '';
const nextImageId = imageId ?? mongoData.imageId;
const formatIndexesResult = await this.indexOperation.formatIndexes({ const formatIndexesResult = await this.indexOperation.formatIndexes({
indexes, indexes,
q: nextQ, q: nextQ,
a: nextA, a: nextA,
imageId: nextImageId,
imageIndex,
indexSize, indexSize,
maxIndexSize: getEmbeddingModel(model).maxToken, maxIndexSize: embModel.maxToken,
indexPrefix indexPrefix
}); });
const indexesWithExistingDefaultIds = this.indexOperation.mergeExistingDefaultIndexIds({
// 把旧的 dataId 加到新的索引里
const indexesWithExistingSystemIds = this.indexOperation.mergeExistingSystemIndexIds({
currentIndexes: mongoData.indexes, currentIndexes: mongoData.indexes,
nextDefaultIndexes: formatIndexesResult nextSystemIndexes: formatIndexesResult
}); });
// patchResult 先保留旧 dataId;insertVectorForPatch 会为 create/update 项写入新向量并回填新 dataId。 // patchResult 先保留旧 dataId;insertVectorForPatch 会为 create/update 项写入新向量并回填新 dataId。
const patchResult = this.indexOperation.buildPatch({ const patchResult = this.indexOperation.buildPatch({
currentIndexes: mongoData.indexes, currentIndexes: mongoData.indexes,
nextIndexes: indexesWithExistingDefaultIds nextIndexes: indexesWithExistingSystemIds
}); });
const deleteVectorIdList = this.indexOperation.getDeleteVectorIdList(patchResult);
// 提前刷新 updateTime,保持旧接口“进入更新流程即更新时间”的行为。 // 提前刷新 updateTime,方便 job 扫到该 data 进行处理。
const updateTime = mongoData.updateTime; const updateTime = mongoData.updateTime;
mongoData.updateTime = new Date(); mongoData.updateTime = new Date();
await mongoData.save(); await mongoData.save();
...@@ -222,6 +272,7 @@ export class DatasetDataOperation { ...@@ -222,6 +272,7 @@ export class DatasetDataOperation {
}); });
const newIndexes = this.indexOperation.getWritablePatchIndexes(patchResult); const newIndexes = this.indexOperation.getWritablePatchIndexes(patchResult);
const deleteVectorIdList = this.indexOperation.getDeleteVectorIdList(patchResult);
await mongoSessionRun(async (session) => { await mongoSessionRun(async (session) => {
// 仅在 Q/A 变化时记录历史,最多保留最近 10 条旧内容。 // 仅在 Q/A 变化时记录历史,最多保留最近 10 条旧内容。
...@@ -267,54 +318,53 @@ export class DatasetDataOperation { ...@@ -267,54 +318,53 @@ export class DatasetDataOperation {
} }
/** /**
* 只根据 Q/A 重建默认索引,保留人工维护的自定义索引。 * 只重建系统生成的索引:默认文本索引和多模态图片向量索引。
* *
* 数据内容更新时会走这个路径:default 索引来自 Q/A,因此需要重新生成;自定义索引 * “更新索引”按钮不能碰用户手动维护的索引。这里写 Mongo 时基于数据库当前值过滤,
* 是用户手动维护的检索提示,不应因为 Q/A 更新被覆盖。 * 只替换 `default` / `imageEmbedding`,再拼回新生成的系统索引,避免 custom、question、
* summary、image 等外部索引被格式化、去重或并发覆盖。
*/ */
async updateDefaultIndexes({ async updateSystemIndexes({
dataId, dataId,
q = '', q,
a, a,
imageId,
model, model,
indexSize = 512, indexSize = 512,
indexPrefix indexPrefix,
}: { imageIndex
dataId: string; }: UpdateDatasetDataSystemIndexesProps) {
q: string;
a?: string;
model: string;
indexSize?: number;
indexPrefix?: string;
}) {
const mongoData = await MongoDatasetData.findById(dataId); const mongoData = await MongoDatasetData.findById(dataId);
if (!mongoData) return Promise.reject('Data not found'); if (!mongoData) return Promise.reject('Data not found');
const embModel = getEmbeddingModel(model); const embModel = getEmbeddingModel(model)!;
const nextQ = q ?? mongoData.q ?? '';
const nextA = a ?? mongoData.a ?? '';
const nextImageId = imageId ?? mongoData.imageId;
indexSize = Math.min(embModel.maxToken, indexSize); indexSize = Math.min(embModel.maxToken, indexSize);
const defaultIndexes = await this.indexOperation.getDefaultIndexes({ const systemIndexes = await this.indexOperation.getSystemIndexes({
q, q: nextQ,
a, a: nextA,
imageId: nextImageId,
imageIndex,
indexSize, indexSize,
maxIndexSize: embModel.maxToken, maxIndexSize: embModel.maxToken,
indexPrefix indexPrefix
}); });
// 默认索引文本没变化时复用旧 dataId,避免无意义的向量重建。 // 系统索引文本没变化时复用旧 dataId,避免无意义的向量重建。
const nextDefaultIndexDrafts = this.indexOperation.mergeExistingDefaultIndexIds({ const nextSystemIndexDrafts = this.indexOperation.mergeExistingSystemIndexIds({
currentIndexes: mongoData.indexes, currentIndexes: mongoData.indexes,
nextDefaultIndexes: defaultIndexes nextSystemIndexes: systemIndexes
}); });
const patchResult = this.indexOperation.buildPatch({ const patchResult = this.indexOperation.buildPatch({
currentIndexes: mongoData.indexes, currentIndexes: mongoData.indexes,
nextIndexes: nextDefaultIndexDrafts, nextIndexes: nextSystemIndexDrafts,
currentIndexFilter: (index) => index.type === DatasetDataIndexTypeEnum.default, currentIndexFilter: (index) => isDatasetDataSystemIndexType(index.type),
isSameIndex: (current, next) => current.text === next.text && current.type === next.type isSameIndex: (current, next) => current.text === next.text && current.type === next.type
}); });
const deleteVectorIdList = this.indexOperation.getDeleteVectorIdList(patchResult);
// 只为新增或变化的默认索引写入向量;未变化的索引继续使用原 dataId。
const tokens = await this.indexOperation.insertVectorForPatch({ const tokens = await this.indexOperation.insertVectorForPatch({
patchResult, patchResult,
teamId: mongoData.teamId, teamId: mongoData.teamId,
...@@ -322,13 +372,17 @@ export class DatasetDataOperation { ...@@ -322,13 +372,17 @@ export class DatasetDataOperation {
collectionId: mongoData.collectionId collectionId: mongoData.collectionId
}); });
const nextDefaultIndexes = this.indexOperation.getWritablePatchIndexes(patchResult); const nextSystemIndexes = this.indexOperation.getWritablePatchIndexes(patchResult);
const deleteVectorIdList = this.indexOperation.getDeleteVectorIdList(patchResult);
const updateTime = mongoData.updateTime; const updateTime = mongoData.updateTime;
const nextQ = q || mongoData.q;
const nextA = a ?? mongoData.a;
const isDataChanged = nextQ !== mongoData.q || nextA !== mongoData.a; const isDataChanged = nextQ !== mongoData.q || nextA !== mongoData.a;
const updateFields = {
await mongoSessionRun(async (session) => {
await MongoDatasetData.updateOne(
{ _id: mongoData._id },
[
{
$set: {
...(isDataChanged ...(isDataChanged
? { ? {
history: { history: {
...@@ -351,35 +405,27 @@ export class DatasetDataOperation { ...@@ -351,35 +405,27 @@ export class DatasetDataOperation {
$filter: { $filter: {
input: '$indexes', input: '$indexes',
as: 'index', as: 'index',
cond: { $ne: ['$$index.type', DatasetDataIndexTypeEnum.default] } cond: {
$not: [{ $in: ['$$index.type', datasetDataSystemIndexTypes] }]
}
} }
}, },
{ $literal: nextDefaultIndexes } { $literal: nextSystemIndexes }
] ]
}, },
updateTime: { $literal: new Date() } updateTime: { $literal: new Date() }
}; }
await mongoSessionRun(async (session) => {
// Only replace default indexes at write time. Custom indexes may be created concurrently.
await MongoDatasetData.updateOne(
{ _id: mongoData._id },
[
{
$set: updateFields
} }
], ],
{ session } { session }
); );
// 默认索引来自 Q/A,全文检索 token 也必须和 Q/A 同步。
await MongoDatasetDataText.updateOne( await MongoDatasetDataText.updateOne(
{ dataId: mongoData._id }, { dataId: mongoData._id },
{ fullTextToken: await jiebaSplit({ text: `${nextQ}\n${nextA}`.trim() }) }, { fullTextToken: await jiebaSplit({ text: `${nextQ}\n${nextA}`.trim() }) },
{ session } { session }
); );
// 等 Mongo indexes 更新完成后再删除旧向量,避免短时间内出现悬空引用。
await this.indexOperation.deleteVectors({ await this.indexOperation.deleteVectors({
teamId: mongoData.teamId, teamId: mongoData.teamId,
idList: deleteVectorIdList idList: deleteVectorIdList
...@@ -436,6 +482,7 @@ export const createDatasetData = async ( ...@@ -436,6 +482,7 @@ export const createDatasetData = async (
props: CreateDatasetDataPropsType & { props: CreateDatasetDataPropsType & {
embeddingModel: string; embeddingModel: string;
indexSize?: number; indexSize?: number;
imageIndex?: boolean;
imageDescMap?: Record<string, string>; imageDescMap?: Record<string, string>;
session?: ClientSession; session?: ClientSession;
} }
...@@ -452,18 +499,13 @@ export const updateDatasetDataByIndexes = async (props: UpdateDatasetDataByIndex ...@@ -452,18 +499,13 @@ export const updateDatasetDataByIndexes = async (props: UpdateDatasetDataByIndex
}; };
/** /**
* 根据 Q/A 更新默认索引,同时保留自定义索引。 * 根据数据内容更新系统索引,同时保留外部索引。
* 适用于普通数据内容编辑场景。 * 系统索引包含 default 文本索引和 imageEmbedding 图片向量索引。
*/ */
export const updateDatasetDataDefaultIndexes = async (props: { export const updateDatasetDataSystemIndexes = async (
dataId: string; props: UpdateDatasetDataSystemIndexesProps
q: string; ) => {
a?: string; return new DatasetDataOperation(props.model).updateSystemIndexes(props);
model: string;
indexSize?: number;
indexPrefix?: string;
}) => {
return new DatasetDataOperation(props.model).updateDefaultIndexes(props);
}; };
/** /**
......
...@@ -8,12 +8,20 @@ import type { ...@@ -8,12 +8,20 @@ import type {
DatasetDataIndexItemType, DatasetDataIndexItemType,
DatasetDataItemType DatasetDataItemType
} from '@fastgpt/global/core/dataset/type'; } from '@fastgpt/global/core/dataset/type';
import { getEmbeddingModel } from '@fastgpt/service/core/ai/model'; import { getEmbeddingModel, isImageEmbeddingModel } from '@fastgpt/service/core/ai/model';
import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun';
import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants'; import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants';
import { countPromptTokens } from '@fastgpt/service/common/string/tiktoken'; import { countPromptTokens } from '@fastgpt/service/common/string/tiktoken';
import { text2Chunks } from '@fastgpt/service/worker/function'; import { text2Chunks } from '@fastgpt/service/worker/function';
import type { EmbeddingModelItemType } from '@fastgpt/global/core/ai/model.schema'; import type { EmbeddingModelItemType } from '@fastgpt/global/core/ai/model.schema';
import {
isValidImageEmbeddingSource,
normalizeImageToBase64
} from '@fastgpt/service/core/dataset/search/utils';
import { isS3ObjectKey } from '@fastgpt/service/common/s3/utils';
import { getS3DatasetSource } from '@fastgpt/service/common/s3/sources/dataset';
import { uniqueDatasetDataMarkdownImageUrls } from '@fastgpt/service/core/dataset/data/utils';
import { isDatasetDataSystemIndexType } from '@fastgpt/global/core/dataset/data/utils';
export type DatasetDataIndexDraft = Omit<DatasetDataIndexItemType, 'dataId'> & { export type DatasetDataIndexDraft = Omit<DatasetDataIndexItemType, 'dataId'> & {
dataId?: string; dataId?: string;
...@@ -29,10 +37,12 @@ export type DatasetDataIndexPatch = ...@@ -29,10 +37,12 @@ export type DatasetDataIndexPatch =
| { | {
type: 'create'; type: 'create';
index: DatasetDataIndexDraft; index: DatasetDataIndexDraft;
skipped?: boolean;
} }
| { | {
type: 'update'; type: 'update';
index: DatasetDataIndexItemType; index: DatasetDataIndexItemType;
skipped?: boolean;
} }
| { | {
type: 'delete'; type: 'delete';
...@@ -54,11 +64,26 @@ const formatIndexTextWithPrefix = (text: string, indexPrefix?: string) => { ...@@ -54,11 +64,26 @@ const formatIndexTextWithPrefix = (text: string, indexPrefix?: string) => {
return text; return text;
}; };
const isImageEmbeddingIndex = (index: DatasetDataIndexDraft) =>
index.type === DatasetDataIndexTypeEnum.imageEmbedding;
const normalizeDatasetIndexImageToModelInput = async (imageUrl: string) => {
if (
isS3ObjectKey(imageUrl, 'dataset') ||
isS3ObjectKey(imageUrl, 'temp') ||
isS3ObjectKey(imageUrl, 'chat')
) {
return getS3DatasetSource().getDatasetBase64Image(imageUrl);
}
return normalizeImageToBase64(imageUrl);
};
/** /**
* 数据索引变更的共享操作类。 * 数据索引变更的共享操作类。
* *
* 这里集中维护索引的完整生命周期: * 这里集中维护索引的完整生命周期:
* - 根据 Q/A 文本生成默认索引 * - 根据数据内容生成系统索引
* - 在向量化前规范化并切分自定义索引 * - 在向量化前规范化并切分自定义索引
* - 对比当前索引和下一版索引,生成 patch * - 对比当前索引和下一版索引,生成 patch
* - 写入或删除向量记录 * - 写入或删除向量记录
...@@ -77,25 +102,60 @@ export class DatasetDataIndexOperation { ...@@ -77,25 +102,60 @@ export class DatasetDataIndexOperation {
return this.getEmbeddingModel().maxToken; return this.getEmbeddingModel().maxToken;
} }
private getEmbeddingModel() { private getEmbeddingModel(): EmbeddingModelItemType {
return typeof this.model === 'string' ? getEmbeddingModel(this.model) : this.model!; return (typeof this.model === 'string' ? getEmbeddingModel(this.model) : this.model)!;
} }
/** /**
* 根据数据的 question 和 answer 生成系统维护的默认索引。 * 从数据正文中收集需要生成图片向量的图片源。
*
* 主图片 `imageId` 是图片数据自身的内容,只要模型支持图片向量就会生成;
* markdown 图片受 collection 的 imageIndex 开关控制,避免未开启图片索引的普通文本
* 数据被隐式生成额外图片向量。
*/
getImageEmbeddingSources({
q = '',
a = '',
imageId, // 纯图数据
imageIndex // 文本数据,是否需要提取图片
}: {
q?: string;
a?: string;
imageId?: string;
imageIndex?: boolean;
}) {
if (!isImageEmbeddingModel(this.getEmbeddingModel())) return [];
const sources = [
...(imageId ? [imageId] : []),
...(imageIndex ? uniqueDatasetDataMarkdownImageUrls([q, a]) : [])
];
return Array.from(new Set(sources.filter(isValidImageEmbeddingSource)));
}
/**
* 根据数据内容生成系统维护的索引。
*
* 系统索引包含:
* - `default`:由 question/answer 切分出的文本向量索引;
* - `imageEmbedding`:由主图片和 markdown 图片源生成的图片向量索引。
* *
* 默认索引由数据内容重新生成,不允许用户单独编辑。question 和 answer 都可能 * 这些索引由数据内容重新生成,不允许用户单独编辑。
* 被切成多个 chunk,确保每条向量输入不会超过 embedding 模型限制。
*/ */
async getDefaultIndexes({ async getSystemIndexes({
q = '', q = '',
a, a,
imageId,
imageIndex,
indexSize, indexSize,
maxIndexSize, maxIndexSize,
indexPrefix indexPrefix
}: { }: {
q?: string; q?: string;
a?: string; a?: string;
imageId?: string;
imageIndex?: boolean;
indexSize: number; indexSize: number;
maxIndexSize?: number; maxIndexSize?: number;
indexPrefix?: string; indexPrefix?: string;
...@@ -125,6 +185,15 @@ export class DatasetDataIndexOperation { ...@@ -125,6 +185,15 @@ export class DatasetDataIndexOperation {
...aChunks.map((text) => ({ ...aChunks.map((text) => ({
text: formatIndexTextWithPrefix(text, indexPrefix), text: formatIndexTextWithPrefix(text, indexPrefix),
type: DatasetDataIndexTypeEnum.default type: DatasetDataIndexTypeEnum.default
})),
...this.getImageEmbeddingSources({
q,
a,
imageId,
imageIndex
}).map((text) => ({
text,
type: DatasetDataIndexTypeEnum.imageEmbedding
})) }))
]; ];
} }
...@@ -132,13 +201,15 @@ export class DatasetDataIndexOperation { ...@@ -132,13 +201,15 @@ export class DatasetDataIndexOperation {
/** /**
* 在对比或写入向量前,规范化所有索引草稿。 * 在对比或写入向量前,规范化所有索引草稿。
* *
* 该流程会保留自定义索引、根据 Q/A 重新生成默认索引、按文本去重,并切分过长的 * 该流程会保留外部索引、根据数据内容重新生成系统索引、按文本去重,并切分过长的
* 自定义索引。文本未变化时会沿用已有 dataId,避免重复重建向量。 * 外部文本索引。文本未变化时会沿用已有 dataId,避免重复重建向量。
*/ */
async formatIndexes({ async formatIndexes({
indexes = [], indexes = [],
q, q,
a = '', a = '',
imageId,
imageIndex,
indexSize, indexSize,
maxIndexSize, maxIndexSize,
indexPrefix indexPrefix
...@@ -146,6 +217,8 @@ export class DatasetDataIndexOperation { ...@@ -146,6 +217,8 @@ export class DatasetDataIndexOperation {
indexes?: DatasetDataIndexDraft[]; indexes?: DatasetDataIndexDraft[];
q: string; q: string;
a?: string; a?: string;
imageId?: string;
imageIndex?: boolean;
indexSize: number; indexSize: number;
maxIndexSize?: number; maxIndexSize?: number;
indexPrefix?: string; indexPrefix?: string;
...@@ -156,40 +229,59 @@ export class DatasetDataIndexOperation { ...@@ -156,40 +229,59 @@ export class DatasetDataIndexOperation {
dataId: item.dataId dataId: item.dataId
})); }));
const defaultIndexes = await this.getDefaultIndexes({ const systemIndexes = await this.getSystemIndexes({
q, q,
a, a,
imageId,
imageIndex,
indexSize, indexSize,
maxIndexSize: maxIndexSize ?? this.maxToken, maxIndexSize: maxIndexSize ?? this.maxToken,
indexPrefix indexPrefix
}); });
// 把 dataId 合并到旧的 index 上,避免重复生成 // 系统索引由当前数据内容重新生成;传入 indexes 里的系统索引只用于复用旧 dataId。
const concatDefaultIndexes = defaultIndexes.map((item) => { let systemIndexesWithExistingIds = this.mergeExistingSystemIndexIds({
const oldIndex = indexes.find((index) => index.text === item.text); currentIndexes: indexes,
if (oldIndex) { nextSystemIndexes: systemIndexes
return { });
type: DatasetDataIndexTypeEnum.default,
text: item.text, const systemTextIndexTexts = new Set(
dataId: oldIndex.dataId systemIndexesWithExistingIds
}; .filter((item) => item.type !== DatasetDataIndexTypeEnum.imageEmbedding)
.map((item) => item.text)
);
const externalIndexes = indexes.filter((item) => !isDatasetDataSystemIndexType(item.type));
// 若一个普通文本索引被系统 default 覆盖掉,可以复用它的文本向量 id;
// imageEmbedding 不能复用文本向量,必须按图片输入重新生成。
systemIndexesWithExistingIds = systemIndexesWithExistingIds.map((item) => {
if (item.dataId || item.type !== DatasetDataIndexTypeEnum.default) return item;
const sameTextIndex = externalIndexes.find(
(index) => index.text === item.text && !!index.dataId
);
return sameTextIndex?.dataId
? {
...item,
dataId: sameTextIndex.dataId
} }
return item; : item;
}); });
// 筛选掉重复索引:不是默认的,文案相同的
indexes = indexes.filter( indexes = indexes.filter(
(item, index, self) => (item, index, self) =>
item.type !== DatasetDataIndexTypeEnum.default && !isDatasetDataSystemIndexType(item.type) &&
!concatDefaultIndexes.find((t) => t.text === item.text) && !systemTextIndexTexts.has(item.text) &&
index === self.findIndex((t) => t.text === item.text) index ===
self.findIndex((t) => !isDatasetDataSystemIndexType(t.type) && t.text === item.text)
); );
indexes.push(...concatDefaultIndexes); indexes.push(...systemIndexesWithExistingIds);
const checkedIndexes = ( const checkedIndexes = (
await Promise.all( await Promise.all(
indexes.map(async (item) => { indexes.map(async (item) => {
if (item.type === DatasetDataIndexTypeEnum.default) { if (item.type === DatasetDataIndexTypeEnum.imageEmbedding) {
return item; return item;
} }
...@@ -217,7 +309,13 @@ export class DatasetDataIndexOperation { ...@@ -217,7 +309,13 @@ export class DatasetDataIndexOperation {
return indexPrefix return indexPrefix
? checkedIndexes.map((index) => { ? checkedIndexes.map((index) => {
if (index.type === DatasetDataIndexTypeEnum.custom) return index; // 自定义索引与图片向量索引不需要添加前缀
if (
index.type === DatasetDataIndexTypeEnum.custom ||
index.type === DatasetDataIndexTypeEnum.imageEmbedding
) {
return index;
}
return { return {
...index, ...index,
text: formatIndexTextWithPrefix(index.text, indexPrefix) text: formatIndexTextWithPrefix(index.text, indexPrefix)
...@@ -227,26 +325,30 @@ export class DatasetDataIndexOperation { ...@@ -227,26 +325,30 @@ export class DatasetDataIndexOperation {
} }
/** /**
* 默认索引重新生成后,重新挂回当前默认索引的 dataId。 * 系统索引重新生成后,重新挂回当前系统索引的 dataId。
* *
* 默认索引来自内容,调用方通常会先生成没有 id 的草稿。这里按文本匹配,让未变化的 * 系统索引来自内容,调用方通常会先生成没有 id 的草稿。这里按类型和文本匹配,
* 默认索引继续指向已有向量记录。 * 让未变化的系统索引继续指向已有向量记录。
*/ */
mergeExistingDefaultIndexIds({ mergeExistingSystemIndexIds({
currentIndexes, currentIndexes,
nextDefaultIndexes nextSystemIndexes
}: { }: {
currentIndexes: DatasetDataIndexItemType[]; currentIndexes: DatasetDataIndexDraft[];
nextDefaultIndexes: DatasetDataIndexDraft[]; nextSystemIndexes: DatasetDataIndexDraft[];
}) { }) {
const existingDefaultMap = new Map( const getIndexKey = (index: Pick<DatasetDataIndexDraft, 'type' | 'text'>) =>
`${index.type || DatasetDataIndexTypeEnum.custom}:${index.text}`;
const existingSystemIndexMap = new Map(
currentIndexes currentIndexes
.filter((index) => index.type === DatasetDataIndexTypeEnum.default) .filter((index) => isDatasetDataSystemIndexType(index.type))
.map((index) => [index.text, index]) .map((index) => [getIndexKey(index), index])
); );
return nextDefaultIndexes.map((index) => { return nextSystemIndexes.map((index) => {
const existingIndex = existingDefaultMap.get(index.text); const existingIndex = isDatasetDataSystemIndexType(index.type)
? existingSystemIndexMap.get(getIndexKey(index))
: undefined;
return { return {
...index, ...index,
...(existingIndex?.dataId && { dataId: existingIndex.dataId }) ...(existingIndex?.dataId && { dataId: existingIndex.dataId })
...@@ -257,7 +359,7 @@ export class DatasetDataIndexOperation { ...@@ -257,7 +359,7 @@ export class DatasetDataIndexOperation {
/** /**
* 对比已存索引和下一版索引,生成需要执行的向量操作。 * 对比已存索引和下一版索引,生成需要执行的向量操作。
* *
* 可选 filter 用于数据级更新时只 patch 默认索引,避免影响人工维护的自定义索引。 * 可选 filter 用于数据级更新时只 patch 系统索引,避免影响人工维护的自定义索引。
* 可选 comparator 用于让调用方决定除文本外的字段变化是否需要重建向量。 * 可选 comparator 用于让调用方决定除文本外的字段变化是否需要重建向量。
*/ */
buildPatch({ buildPatch({
...@@ -341,10 +443,78 @@ export class DatasetDataIndexOperation { ...@@ -341,10 +443,78 @@ export class DatasetDataIndexOperation {
*/ */
getWritablePatchIndexes(patchResult: DatasetDataIndexPatch[]) { getWritablePatchIndexes(patchResult: DatasetDataIndexPatch[]) {
return patchResult return patchResult
.filter((item) => item.type !== 'delete') .filter((item) => item.type !== 'delete' && !('skipped' in item && item.skipped))
.map((item) => item.index) as DatasetDataIndexItemType[]; .map((item) => item.index) as DatasetDataIndexItemType[];
} }
private async insertIndexVectorIds({
indexes,
teamId,
datasetId,
collectionId
}: {
indexes: DatasetDataIndexDraft[];
teamId: string;
datasetId: string;
collectionId: string;
}) {
const embModel = this.getEmbeddingModel();
const vectorInputItems = (
await Promise.all(
indexes.map(async (index) => {
if (!isImageEmbeddingIndex(index)) {
return {
item: index,
input: index.text
};
}
if (!isImageEmbeddingModel(embModel) || !isValidImageEmbeddingSource(index.text)) {
return;
}
try {
return {
item: index,
input: {
type: 'image' as const,
input: await normalizeDatasetIndexImageToModelInput(index.text)
}
};
} catch {
return;
}
})
)
).filter(Boolean) as {
item: DatasetDataIndexDraft;
input: string | { type: 'image'; input: string };
}[];
const insertResult = vectorInputItems.length
? await insertDatasetDataVector({
inputs: vectorInputItems.map((item) => item.input),
model: embModel,
teamId,
datasetId,
collectionId
})
: { tokens: 0, insertIds: [] as string[] };
const insertedIndexIdMap = new WeakMap<DatasetDataIndexDraft, string>();
vectorInputItems.forEach(({ item }, index) => {
const dataId = insertResult.insertIds[index];
if (dataId) {
insertedIndexIdMap.set(item, dataId);
}
});
return {
tokens: insertResult.tokens,
insertedIndexIdMap
};
}
/** /**
* 为需要新向量 id 的 patch 项写入向量。 * 为需要新向量 id 的 patch 项写入向量。
* *
...@@ -366,19 +536,23 @@ export class DatasetDataIndexOperation { ...@@ -366,19 +536,23 @@ export class DatasetDataIndexOperation {
); );
if (insertItems.length === 0) return 0; if (insertItems.length === 0) return 0;
const result = await insertDatasetDataVector({ const { tokens, insertedIndexIdMap } = await this.insertIndexVectorIds({
inputs: insertItems.map((item) => item.index.text), indexes: insertItems.map((item) => item.index),
model: this.getEmbeddingModel(),
teamId, teamId,
datasetId, datasetId,
collectionId collectionId
}); });
insertItems.forEach((item, index) => { insertItems.forEach((item) => {
item.index.dataId = result.insertIds[index]; const dataId = insertedIndexIdMap.get(item.index);
if (dataId) {
item.index.dataId = dataId;
} else {
item.skipped = true;
}
}); });
return result.tokens; return tokens;
} }
/** /**
...@@ -396,9 +570,8 @@ export class DatasetDataIndexOperation { ...@@ -396,9 +570,8 @@ export class DatasetDataIndexOperation {
datasetId: string; datasetId: string;
collectionId: string; collectionId: string;
}) { }) {
const { tokens, insertIds } = await insertDatasetDataVector({ const { tokens, insertedIndexIdMap } = await this.insertIndexVectorIds({
inputs: indexes.map((item) => item.text), indexes,
model: this.getEmbeddingModel(),
teamId, teamId,
datasetId, datasetId,
collectionId collectionId
...@@ -406,10 +579,16 @@ export class DatasetDataIndexOperation { ...@@ -406,10 +579,16 @@ export class DatasetDataIndexOperation {
return { return {
tokens, tokens,
indexes: indexes.map((item, index) => ({ indexes: indexes
.map((item) => {
const dataId = insertedIndexIdMap.get(item);
if (!dataId) return;
return {
...item, ...item,
dataId: insertIds[index] dataId
})) as DatasetDataIndexItemType[] };
})
.filter(Boolean) as DatasetDataIndexItemType[]
}; };
} }
...@@ -447,7 +626,7 @@ export class DatasetDataIndexOperation { ...@@ -447,7 +626,7 @@ export class DatasetDataIndexOperation {
return Promise.reject('Dataset data index text is required'); return Promise.reject('Dataset data index text is required');
} }
if (type === DatasetDataIndexTypeEnum.default) { if (isDatasetDataSystemIndexType(type)) {
return Promise.reject('System indexes cannot be saved separately'); return Promise.reject('System indexes cannot be saved separately');
} }
...@@ -550,7 +729,7 @@ export class DatasetDataIndexOperation { ...@@ -550,7 +729,7 @@ export class DatasetDataIndexOperation {
if (!targetIndex) { if (!targetIndex) {
return Promise.reject('Dataset data index not found'); return Promise.reject('Dataset data index not found');
} }
if (targetIndex.type === DatasetDataIndexTypeEnum.default) { if (isDatasetDataSystemIndexType(targetIndex.type)) {
return Promise.reject('System indexes cannot be deleted separately'); return Promise.reject('System indexes cannot be deleted separately');
} }
......
...@@ -23,6 +23,7 @@ import { getLLMMaxChunkSize } from '@fastgpt/global/core/dataset/training/utils' ...@@ -23,6 +23,7 @@ import { getLLMMaxChunkSize } from '@fastgpt/global/core/dataset/training/utils'
import { checkDatasetIndexLimit } from '@fastgpt/service/support/permission/teamLimit'; import { checkDatasetIndexLimit } from '@fastgpt/service/support/permission/teamLimit';
import { predictDataLimitLength } from '@fastgpt/global/core/dataset/utils'; import { predictDataLimitLength } from '@fastgpt/global/core/dataset/utils';
import { getTrainingModeByCollection } from '@fastgpt/service/core/dataset/collection/utils'; import { getTrainingModeByCollection } from '@fastgpt/service/core/dataset/collection/utils';
import { getDatasetImageIndexCapability } from '@fastgpt/service/core/dataset/utils';
import { pushDataListToTrainingQueue } from '@fastgpt/service/core/dataset/training/controller'; import { pushDataListToTrainingQueue } from '@fastgpt/service/core/dataset/training/controller';
import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants'; import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants';
import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun';
...@@ -150,7 +151,7 @@ export const datasetParseQueue = async (): Promise<any> => { ...@@ -150,7 +151,7 @@ export const datasetParseQueue = async (): Promise<any> => {
return { return {
data data
}; };
} catch (error) { } catch {
return { return {
error: true error: true
}; };
...@@ -197,7 +198,11 @@ export const datasetParseQueue = async (): Promise<any> => { ...@@ -197,7 +198,11 @@ export const datasetParseQueue = async (): Promise<any> => {
const trainingMode = getTrainingModeByCollection({ const trainingMode = getTrainingModeByCollection({
trainingType: collection.trainingType ?? DatasetCollectionDataProcessModeEnum.chunk, trainingType: collection.trainingType ?? DatasetCollectionDataProcessModeEnum.chunk,
autoIndexes: collection.autoIndexes, autoIndexes: collection.autoIndexes,
imageIndex: collection.imageIndex imageIndex: collection.imageIndex,
supportImageIndex: getDatasetImageIndexCapability({
vectorModel: dataset.vectorModel,
vlmModel: dataset.vlmModel
}).supportImageIndex
}); });
// 1. Parse rawtext // 1. Parse rawtext
......
import { createDatasetData } from '@/service/core/dataset/data/data'; import { createDatasetData, updateDatasetDataByIndexes } from '@/service/core/dataset/data/data';
import { MongoDatasetTraining } from '@fastgpt/service/core/dataset/training/schema'; import { MongoDatasetTraining } from '@fastgpt/service/core/dataset/training/schema';
import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants'; import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants';
import { pushGenerateVectorUsage } from '@/service/support/wallet/usage/push'; import { pushGenerateVectorUsage } from '@/service/support/wallet/usage/push';
...@@ -6,10 +6,7 @@ import { checkTeamAiPointsAndLock } from './utils'; ...@@ -6,10 +6,7 @@ import { checkTeamAiPointsAndLock } from './utils';
import { addMinutes } from 'date-fns'; import { addMinutes } from 'date-fns';
import { getLogger, LogCategories } from '@fastgpt/service/common/logger'; import { getLogger, LogCategories } from '@fastgpt/service/common/logger';
import { MongoDatasetData } from '@fastgpt/service/core/dataset/data/schema'; import { MongoDatasetData } from '@fastgpt/service/core/dataset/data/schema';
import { import { MongoDatasetCollection } from '@fastgpt/service/core/dataset/collection/schema';
deleteDatasetDataVector,
insertDatasetDataVector
} from '@fastgpt/service/common/vectorDB/controller';
import { getEmbeddingModel } from '@fastgpt/service/core/ai/model'; import { getEmbeddingModel } from '@fastgpt/service/core/ai/model';
import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun';
import { getErrText } from '@fastgpt/global/common/error/utils'; import { getErrText } from '@fastgpt/global/common/error/utils';
...@@ -20,6 +17,13 @@ import type { ...@@ -20,6 +17,13 @@ import type {
} from '@fastgpt/global/core/dataset/type'; } from '@fastgpt/global/core/dataset/type';
import { retryFn } from '@fastgpt/global/common/system/utils'; import { retryFn } from '@fastgpt/global/common/system/utils';
import { delay } from '@fastgpt/service/common/bullmq'; import { delay } from '@fastgpt/service/common/bullmq';
import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants';
import { isDatasetDataSystemIndexType } from '@fastgpt/global/core/dataset/data/utils';
import {
getDatasetImageIndexCapability,
getDatasetImageTrainingMode
} from '@fastgpt/service/core/dataset/utils';
import { uniqueDatasetDataMarkdownImageUrls } from '@fastgpt/service/core/dataset/data/utils';
const logger = getLogger(LogCategories.MODULE.DATASET.EMBEDDING); const logger = getLogger(LogCategories.MODULE.DATASET.EMBEDDING);
...@@ -30,12 +34,48 @@ const reduceQueue = () => { ...@@ -30,12 +34,48 @@ const reduceQueue = () => {
}; };
type PopulateType = { type PopulateType = {
dataset: { vectorModel: string }; dataset: { vectorModel: string; vlmModel?: string };
collection: { name: string; indexPrefixTitle: boolean }; collection: { name: string; indexPrefixTitle: boolean; imageIndex?: boolean };
data: { _id: string; indexes: DatasetDataSchemaType['indexes'] }; data?: {
_id: string;
q: string;
a?: string;
imageId?: string;
indexes: DatasetDataSchemaType['indexes'];
};
}; };
type TrainingDataType = DatasetTrainingSchemaType & PopulateType; type TrainingDataType = DatasetTrainingSchemaType & PopulateType;
/**
* 获取重建时需要从训练任务透传给 data 层的外部索引。
*
* `default` 和 `imageEmbedding` 都是系统索引,由 data/dataIndex 根据当前 q/a/imageId
* 重新生成;这里仅保留 custom/question/summary/image 等外部索引。其中 image 是 VLM
* 生成的文本描述索引,只有当前集合仍开启图片索引且 VLM 可用时才保留。
*/
export const getRebuildBaseIndexes = (trainingData: TrainingDataType) => {
const sourceIndexes = trainingData.indexes?.length
? trainingData.indexes.map((index) => ({ ...index }))
: trainingData.data?.indexes || [];
const { supportVlm } = getDatasetImageIndexCapability({
vectorModel: trainingData.dataset.vectorModel,
vlmModel: trainingData.dataset.vlmModel
});
return sourceIndexes.filter((index) => {
if (isDatasetDataSystemIndexType(index.type)) {
return false;
}
if (
index.type === DatasetDataIndexTypeEnum.image &&
(!supportVlm || !trainingData.collection.imageIndex)
) {
return false;
}
return true;
});
};
/* 索引生成队列。每导入一次,就是一个单独的线程 */ /* 索引生成队列。每导入一次,就是一个单独的线程 */
export async function generateVector(): Promise<any> { export async function generateVector(): Promise<any> {
const max = global.systemEnv?.vectorMaxProcess || 10; const max = global.systemEnv?.vectorMaxProcess || 10;
...@@ -69,15 +109,15 @@ export async function generateVector(): Promise<any> { ...@@ -69,15 +109,15 @@ export async function generateVector(): Promise<any> {
.populate<PopulateType>([ .populate<PopulateType>([
{ {
path: 'dataset', path: 'dataset',
select: 'vectorModel' select: 'vectorModel vlmModel'
}, },
{ {
path: 'collection', path: 'collection',
select: 'name indexPrefixTitle' select: 'name indexPrefixTitle imageIndex'
}, },
{ {
path: 'data', path: 'data',
select: '_id indexes' select: '_id q a imageId indexes'
} }
]) ])
.lean(); .lean();
...@@ -192,15 +232,12 @@ const rebuildData = async ({ trainingData }: { trainingData: TrainingDataType }) ...@@ -192,15 +232,12 @@ const rebuildData = async ({ trainingData }: { trainingData: TrainingDataType })
await MongoDatasetTraining.deleteOne({ _id: trainingData._id }); await MongoDatasetTraining.deleteOne({ _id: trainingData._id });
return Promise.reject('Not data'); return Promise.reject('Not data');
} }
const datasetData = trainingData.data;
// Old vectorId // 批量重建时先挂下一条任务,避免当前任务耗时太长导致后续数据迟迟不入队。
const deleteVectorIdList = trainingData.data.indexes.map((index) => index.dataId);
// Find next rebuilding data to insert training queue
try { try {
await retryFn(() => await retryFn(() =>
mongoSessionRun(async (session) => { mongoSessionRun(async (session) => {
// get new mongoData insert to training
const newRebuildingData = await MongoDatasetData.findOneAndUpdate( const newRebuildingData = await MongoDatasetData.findOneAndUpdate(
{ {
rebuilding: true, rebuilding: true,
...@@ -216,10 +253,31 @@ const rebuildData = async ({ trainingData }: { trainingData: TrainingDataType }) ...@@ -216,10 +253,31 @@ const rebuildData = async ({ trainingData }: { trainingData: TrainingDataType })
{ session } { session }
).select({ ).select({
_id: 1, _id: 1,
collectionId: 1 collectionId: 1,
q: 1,
imageId: 1,
indexes: 1
}); });
if (newRebuildingData) { if (newRebuildingData) {
const collection = await MongoDatasetCollection.findById(newRebuildingData.collectionId)
.select('imageIndex')
.session(session);
const hasMarkdownImages =
!!collection?.imageIndex &&
uniqueDatasetDataMarkdownImageUrls([newRebuildingData.q]).length > 0;
const { availableVlmModel, supportVlm, supportImageIndex } =
getDatasetImageIndexCapability({
vectorModel: trainingData.dataset.vectorModel,
vlmModel: trainingData.dataset.vlmModel
});
const mode = getDatasetImageTrainingMode({
supportVlm,
supportImageIndex,
imageId: newRebuildingData.imageId,
hasMarkdownImages
});
await MongoDatasetTraining.create( await MongoDatasetTraining.create(
[ [
{ {
...@@ -228,8 +286,19 @@ const rebuildData = async ({ trainingData }: { trainingData: TrainingDataType }) ...@@ -228,8 +286,19 @@ const rebuildData = async ({ trainingData }: { trainingData: TrainingDataType })
datasetId: trainingData.datasetId, datasetId: trainingData.datasetId,
collectionId: newRebuildingData.collectionId, collectionId: newRebuildingData.collectionId,
billId: trainingData.billId, billId: trainingData.billId,
mode: TrainingModeEnum.chunk, mode,
model:
(mode === TrainingModeEnum.imageParse || mode === TrainingModeEnum.image) &&
supportVlm &&
availableVlmModel
? availableVlmModel.model
: trainingData.dataset.vectorModel,
dataId: newRebuildingData._id, dataId: newRebuildingData._id,
...(newRebuildingData.imageId && { imageId: newRebuildingData.imageId }),
...(mode === TrainingModeEnum.image && {
q: newRebuildingData.q,
indexes: newRebuildingData.indexes
}),
retryCount: 50 retryCount: 50
} }
], ],
...@@ -240,46 +309,43 @@ const rebuildData = async ({ trainingData }: { trainingData: TrainingDataType }) ...@@ -240,46 +309,43 @@ const rebuildData = async ({ trainingData }: { trainingData: TrainingDataType })
); );
} catch {} } catch {}
// update vector, update dataset_data rebuilding status, delete data from training const embModel = getEmbeddingModel(trainingData.dataset.vectorModel);
// 1. Insert new vector to dataset_data const q = trainingData.q || datasetData.q;
const insertResult = await insertDatasetDataVector({ const a = trainingData.a ?? datasetData.a;
inputs: trainingData.data.indexes.map((index) => index.text), const rebuildIndexes = getRebuildBaseIndexes(trainingData);
model: getEmbeddingModel(trainingData.dataset.vectorModel),
teamId: trainingData.teamId,
datasetId: trainingData.datasetId,
collectionId: trainingData.collectionId
});
trainingData.data.indexes.forEach((item, index) => { const { tokens } = await updateDatasetDataByIndexes({
item.dataId = insertResult.insertIds[index]; dataId: String(datasetData._id),
q,
a,
imageId: datasetData.imageId,
imageIndex: !!trainingData.collection.imageIndex,
indexes: rebuildIndexes,
model: trainingData.dataset.vectorModel,
indexSize: trainingData.indexSize || getMaxIndexSize(embModel),
indexPrefix: trainingData.collection.indexPrefixTitle
? `# ${trainingData.collection.name}`
: undefined
}); });
await mongoSessionRun(async (session) => { await mongoSessionRun(async (session) => {
// 2. Ensure that the training data is deleted after the Mongo update is successful if (trainingData.imageDescMap) {
await MongoDatasetData.updateOne( await MongoDatasetData.updateOne(
{ _id: trainingData.data._id }, { _id: datasetData._id },
{ { $set: { imageDescMap: trainingData.imageDescMap } },
$set: {
indexes: trainingData.data.indexes
}
},
{ session } { session }
); );
// 3. Delete the training data }
await MongoDatasetTraining.deleteOne({ _id: trainingData._id }, { session }); await MongoDatasetTraining.deleteOne({ _id: trainingData._id }, { session });
// 4. Delete old vector
await deleteDatasetDataVector({
teamId: trainingData.teamId,
idList: deleteVectorIdList
});
}); });
return { tokens: insertResult.tokens }; return { tokens };
}; };
const insertData = async ({ trainingData }: { trainingData: TrainingDataType }) => { const insertData = async ({ trainingData }: { trainingData: TrainingDataType }) => {
return mongoSessionRun(async (session) => { return mongoSessionRun(async (session) => {
const embModel = getEmbeddingModel(trainingData.dataset.vectorModel);
// insert new data to dataset // insert new data to dataset
const { tokens } = await createDatasetData({ const { tokens } = await createDatasetData({
teamId: trainingData.teamId, teamId: trainingData.teamId,
...@@ -291,14 +357,13 @@ const insertData = async ({ trainingData }: { trainingData: TrainingDataType }) ...@@ -291,14 +357,13 @@ const insertData = async ({ trainingData }: { trainingData: TrainingDataType })
imageId: trainingData.imageId, imageId: trainingData.imageId,
imageDescMap: trainingData.imageDescMap, imageDescMap: trainingData.imageDescMap,
chunkIndex: trainingData.chunkIndex, chunkIndex: trainingData.chunkIndex,
indexSize: indexSize: trainingData.indexSize || getMaxIndexSize(embModel),
trainingData.indexSize || indexes: trainingData.indexes || [],
getMaxIndexSize(getEmbeddingModel(trainingData.dataset.vectorModel)),
indexes: trainingData.indexes,
indexPrefix: trainingData.collection.indexPrefixTitle indexPrefix: trainingData.collection.indexPrefixTitle
? `# ${trainingData.collection.name}` ? `# ${trainingData.collection.name}`
: undefined, : undefined,
embeddingModel: trainingData.dataset.vectorModel, embeddingModel: trainingData.dataset.vectorModel,
imageIndex: !!trainingData.collection.imageIndex,
session session
}); });
......
...@@ -41,6 +41,32 @@ import { useSystemStore } from '@/web/common/system/useSystemStore'; ...@@ -41,6 +41,32 @@ import { useSystemStore } from '@/web/common/system/useSystemStore';
import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema';
/* ====== node ======= */ /* ====== node ======= */
/**
* 适配从数据库读取出的节点输入。
* 旧知识库搜索节点使用 userChatInput;当前节点改为 datasetSearchInput 数组。
* 这里仅处理旧字段到新字段的 key 和 valueType 迁移。
*/
export const adaptStoreNodeInputs = (storeNode: StoreNodeItemType): FlowNodeInputItemType[] => {
if (storeNode.flowNodeType !== FlowNodeTypeEnum.datasetSearchNode) {
return storeNode.inputs;
}
return storeNode.inputs.map((input) => {
if (input.key !== NodeInputKeyEnum.userChatInput) return input;
const isReferenceValue = isValidReferenceValueFormat(input.value);
return {
...input,
key: NodeInputKeyEnum.datasetSearchInput,
label: 'workflow:search_query',
value: isReferenceValue ? [input.value] : input.value,
valueType: WorkflowIOValueTypeEnum.arrayString,
selectedTypeIndex: isReferenceValue ? 0 : 1
};
});
};
export const nodeTemplate2FlowNode = ({ export const nodeTemplate2FlowNode = ({
template, template,
position, position,
...@@ -100,6 +126,7 @@ export const storeNode2FlowNode = ({ ...@@ -100,6 +126,7 @@ export const storeNode2FlowNode = ({
const dynamicInput = template.inputs.find( const dynamicInput = template.inputs.find(
(input) => input.renderTypeList[0] === FlowNodeInputTypeEnum.addInputParam (input) => input.renderTypeList[0] === FlowNodeInputTypeEnum.addInputParam
); );
const adaptedStoreInputs = adaptStoreNodeInputs(storeNode);
// replace item data // replace item data
const nodeItem: FlowNodeItemType = { const nodeItem: FlowNodeItemType = {
...@@ -113,7 +140,7 @@ export const storeNode2FlowNode = ({ ...@@ -113,7 +140,7 @@ export const storeNode2FlowNode = ({
inputs: templateInputs inputs: templateInputs
.map<FlowNodeInputItemType>((templateInput) => { .map<FlowNodeInputItemType>((templateInput) => {
const storeInput = const storeInput =
storeNode.inputs.find((item) => item.key === templateInput.key) || templateInput; adaptedStoreInputs.find((item) => item.key === templateInput.key) || templateInput;
return { return {
...storeInput, ...storeInput,
...@@ -126,7 +153,7 @@ export const storeNode2FlowNode = ({ ...@@ -126,7 +153,7 @@ export const storeNode2FlowNode = ({
}) })
.concat( .concat(
// 合并 store 中有,template 中没有的输入 // 合并 store 中有,template 中没有的输入
storeNode.inputs adaptedStoreInputs
.filter((item) => !templateInputs.find((input) => input.key === item.key)) .filter((item) => !templateInputs.find((input) => input.key === item.key))
.map((item) => { .map((item) => {
const templateInput = template.inputs.find((input) => input.key === item.key); const templateInput = template.inputs.find((input) => input.key === item.key);
......
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
DatasetCollectionDataProcessModeEnum,
DatasetCollectionTypeEnum
} from '@fastgpt/global/core/dataset/constants';
const {
mockResolveMultipleFormData,
mockClearDiskTempFiles,
mockAuthDataset,
mockCheckDatasetIndexLimit,
mockAuthFrequencyLimit,
mockGetTeamPlanStatus,
mockReadFile,
mockGetFileS3Key,
mockUploadImage2S3Bucket,
mockCreateCollectionAndInsertData,
mockGetDatasetImageIndexCapability
} = vi.hoisted(() => ({
mockResolveMultipleFormData: vi.fn(),
mockClearDiskTempFiles: vi.fn(),
mockAuthDataset: vi.fn(),
mockCheckDatasetIndexLimit: vi.fn(),
mockAuthFrequencyLimit: vi.fn(),
mockGetTeamPlanStatus: vi.fn(),
mockReadFile: vi.fn(),
mockGetFileS3Key: {
dataset: vi.fn()
},
mockUploadImage2S3Bucket: vi.fn(),
mockCreateCollectionAndInsertData: vi.fn(),
mockGetDatasetImageIndexCapability: vi.fn()
}));
vi.mock('@/service/middleware/entry', () => ({
NextAPI: (handler: any) => handler
}));
vi.mock('@fastgpt/service/common/file/multer', () => ({
multer: {
resolveMultipleFormData: mockResolveMultipleFormData,
clearDiskTempFiles: mockClearDiskTempFiles
}
}));
vi.mock('@fastgpt/service/support/permission/dataset/auth', () => ({
authDataset: mockAuthDataset
}));
vi.mock('@fastgpt/service/support/permission/teamLimit', () => ({
checkDatasetIndexLimit: mockCheckDatasetIndexLimit
}));
vi.mock('@fastgpt/service/common/system/frequencyLimit/utils', () => ({
authFrequencyLimit: mockAuthFrequencyLimit
}));
vi.mock('@fastgpt/service/support/wallet/sub/utils', () => ({
getTeamPlanStatus: mockGetTeamPlanStatus
}));
vi.mock('node:fs', () => ({
default: {
promises: {
readFile: mockReadFile
}
},
promises: {
readFile: mockReadFile
}
}));
vi.mock('@fastgpt/service/common/s3/utils', () => ({
getFileS3Key: mockGetFileS3Key,
uploadImage2S3Bucket: mockUploadImage2S3Bucket
}));
vi.mock('@fastgpt/service/core/dataset/collection/controller', () => ({
createCollectionAndInsertData: mockCreateCollectionAndInsertData
}));
vi.mock('@fastgpt/service/core/dataset/utils', async (importOriginal) => {
const actual = (await importOriginal()) as any;
return {
...actual,
getDatasetImageIndexCapability: mockGetDatasetImageIndexCapability
};
});
import handler from '@/pages/api/core/dataset/collection/create/images';
const datasetId = '68ad85a7463006c963799a07';
const parentId = '68ad85a7463006c963799a08';
describe('POST /api/core/dataset/collection/create/images', () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolveMultipleFormData.mockResolvedValue({
data: {
parentId,
datasetId,
collectionName: 'Native image embedding collection'
},
fileMetadata: [
{
path: '/tmp/cat.png',
filename: 'cat.png',
mimetype: 'image/png'
}
]
});
mockAuthDataset.mockResolvedValue({
teamId: 'team-id',
tmbId: 'tmb-id',
dataset: {
_id: datasetId,
vectorModel: 'vision-embedding',
agentModel: 'gpt-5'
}
});
mockGetDatasetImageIndexCapability.mockReturnValue({
availableVlmModel: undefined,
supportVlm: false,
supportImageEmbedding: true,
supportImageIndex: true
});
mockGetTeamPlanStatus.mockResolvedValue({ standard: { maxUploadFileCount: 10 } });
mockReadFile.mockResolvedValue(Buffer.from('image-bytes'));
mockGetFileS3Key.dataset.mockReturnValue({ fileKey: 'dataset/team/cat.png' });
mockUploadImage2S3Bucket.mockResolvedValue('dataset/team/cat.png');
mockCreateCollectionAndInsertData.mockResolvedValue({
collectionId: 'collection-id',
results: {
insertLen: 1
}
});
});
it('should create an image collection with chunk training when only native image embedding is available', async () => {
const result = await handler({} as any);
expect(result).toEqual({
collectionId: 'collection-id',
results: {
insertLen: 1
}
});
expect(mockCreateCollectionAndInsertData).toHaveBeenCalledWith({
dataset: {
_id: datasetId,
vectorModel: 'vision-embedding',
agentModel: 'gpt-5'
},
imageIds: ['dataset/team/cat.png'],
createCollectionParams: {
parentId,
teamId: 'team-id',
tmbId: 'tmb-id',
datasetId,
type: DatasetCollectionTypeEnum.images,
name: 'Native image embedding collection',
trainingType: DatasetCollectionDataProcessModeEnum.chunk
}
});
expect(mockClearDiskTempFiles).toHaveBeenCalledWith(['/tmp/cat.png']);
});
});
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants';
const {
mockResolveMultipleFormData,
mockClearDiskTempFiles,
mockAuthDatasetCollection,
mockAuthFrequencyLimit,
mockGetTeamPlanStatus,
mockReadFile,
mockGetFileS3Key,
mockUploadImage2S3Bucket,
mockMongoSessionRun,
mockCreateTrainingUsage,
mockPushDataListToTrainingQueue,
mockGetDatasetImageIndexCapability
} = vi.hoisted(() => ({
mockResolveMultipleFormData: vi.fn(),
mockClearDiskTempFiles: vi.fn(),
mockAuthDatasetCollection: vi.fn(),
mockAuthFrequencyLimit: vi.fn(),
mockGetTeamPlanStatus: vi.fn(),
mockReadFile: vi.fn(),
mockGetFileS3Key: {
dataset: vi.fn()
},
mockUploadImage2S3Bucket: vi.fn(),
mockMongoSessionRun: vi.fn(),
mockCreateTrainingUsage: vi.fn(),
mockPushDataListToTrainingQueue: vi.fn(),
mockGetDatasetImageIndexCapability: vi.fn()
}));
vi.mock('@/service/middleware/entry', () => ({
NextAPI: (handler: any) => handler
}));
vi.mock('@fastgpt/service/common/file/multer', () => ({
multer: {
resolveMultipleFormData: mockResolveMultipleFormData,
clearDiskTempFiles: mockClearDiskTempFiles
}
}));
vi.mock('@fastgpt/service/support/permission/dataset/auth', () => ({
authDatasetCollection: mockAuthDatasetCollection
}));
vi.mock('@fastgpt/service/common/system/frequencyLimit/utils', () => ({
authFrequencyLimit: mockAuthFrequencyLimit
}));
vi.mock('@fastgpt/service/support/wallet/sub/utils', () => ({
getTeamPlanStatus: mockGetTeamPlanStatus
}));
vi.mock('node:fs', () => ({
default: {
promises: {
readFile: mockReadFile
}
},
promises: {
readFile: mockReadFile
}
}));
vi.mock('@fastgpt/service/common/s3/utils', () => ({
getFileS3Key: mockGetFileS3Key,
uploadImage2S3Bucket: mockUploadImage2S3Bucket
}));
vi.mock('@fastgpt/service/common/mongo/sessionRun', () => ({
mongoSessionRun: mockMongoSessionRun
}));
vi.mock('@fastgpt/service/support/wallet/usage/controller', () => ({
createTrainingUsage: mockCreateTrainingUsage
}));
vi.mock('@fastgpt/service/core/dataset/training/controller', () => ({
pushDataListToTrainingQueue: mockPushDataListToTrainingQueue
}));
vi.mock('@fastgpt/service/core/ai/model', async (importOriginal) => {
const actual = (await importOriginal()) as any;
return {
...actual,
getEmbeddingModel: vi.fn((model: string) => ({ name: model, model })),
getLLMModel: vi.fn((model: string) => ({ name: model, model }))
};
});
vi.mock('@fastgpt/service/core/dataset/utils', async (importOriginal) => {
const actual = (await importOriginal()) as any;
return {
...actual,
getDatasetImageIndexCapability: mockGetDatasetImageIndexCapability
};
});
import handler from '@/pages/api/core/dataset/data/insertImages';
const collectionId = '68ad85a7463006c963799a06';
const datasetId = '68ad85a7463006c963799a07';
describe('POST /api/core/dataset/data/insertImages', () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolveMultipleFormData.mockResolvedValue({
data: { collectionId },
fileMetadata: [
{
path: '/tmp/cat.png',
filename: 'cat.png',
mimetype: 'image/png'
}
]
});
mockAuthDatasetCollection.mockResolvedValue({
teamId: 'team-id',
tmbId: 'tmb-id',
collection: {
_id: collectionId,
name: 'Images',
datasetId,
dataset: {
_id: datasetId,
vectorModel: 'vision-embedding',
agentModel: 'gpt-5'
}
}
});
mockGetDatasetImageIndexCapability.mockReturnValue({
availableVlmModel: undefined,
supportVlm: false,
supportImageEmbedding: true,
supportImageIndex: true
});
mockGetTeamPlanStatus.mockResolvedValue({ standard: { maxUploadFileCount: 10 } });
mockReadFile.mockResolvedValue(Buffer.from('image-bytes'));
mockGetFileS3Key.dataset.mockReturnValue({ fileKey: 'dataset/team/cat.png' });
mockUploadImage2S3Bucket.mockResolvedValue('dataset/team/cat.png');
mockMongoSessionRun.mockImplementation((fn: any) => fn('session'));
mockCreateTrainingUsage.mockResolvedValue({ usageId: 'usage-id' });
mockPushDataListToTrainingQueue.mockResolvedValue({ insertLen: 1 });
});
it('should upload images with chunk mode when only native image embedding is available', async () => {
const result = await handler({} as any);
expect(result).toEqual({});
expect(mockCreateTrainingUsage).toHaveBeenCalledWith(
expect.objectContaining({
vectorModel: 'vision-embedding',
agentModel: 'gpt-5',
vllmModel: undefined,
session: 'session'
})
);
expect(mockPushDataListToTrainingQueue).toHaveBeenCalledWith({
teamId: 'team-id',
tmbId: 'tmb-id',
datasetId,
collectionId,
agentModel: 'gpt-5',
vectorModel: 'vision-embedding',
vlmModel: undefined,
mode: TrainingModeEnum.chunk,
billId: 'usage-id',
data: [{ imageId: 'dataset/team/cat.png' }],
session: 'session'
});
expect(mockClearDiskTempFiles).toHaveBeenCalledWith(['/tmp/cat.png']);
});
it('should reject image upload when neither VLM nor native image embedding is available', async () => {
mockGetDatasetImageIndexCapability.mockReturnValueOnce({
availableVlmModel: undefined,
supportVlm: false,
supportImageEmbedding: false,
supportImageIndex: false
});
await expect(handler({} as any)).rejects.toBeTruthy();
expect(mockUploadImage2S3Bucket).not.toHaveBeenCalled();
expect(mockPushDataListToTrainingQueue).not.toHaveBeenCalled();
expect(mockClearDiskTempFiles).toHaveBeenCalledWith(['/tmp/cat.png']);
});
});
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DatasetCollectionTypeEnum } from '@fastgpt/global/core/dataset/constants';
import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants';
const {
mockAuthDatasetData,
mockUpdateDatasetDataByIndexes,
mockUpdateDatasetDataSystemIndexes,
mockPushGenerateVectorUsage,
mockAddAuditLog,
mockReplaceS3KeyToPreviewUrl
} = vi.hoisted(() => ({
mockAuthDatasetData: vi.fn(),
mockUpdateDatasetDataByIndexes: vi.fn(),
mockUpdateDatasetDataSystemIndexes: vi.fn(),
mockPushGenerateVectorUsage: vi.fn(),
mockAddAuditLog: vi.fn(),
mockReplaceS3KeyToPreviewUrl: vi.fn()
}));
vi.mock('@/service/middleware/entry', () => ({
NextAPI: (handler: any) => handler
}));
vi.mock('@fastgpt/service/support/permission/dataset/auth', () => ({
authDatasetData: mockAuthDatasetData
}));
vi.mock('@/service/core/dataset/data/data', () => ({
updateDatasetDataByIndexes: mockUpdateDatasetDataByIndexes,
updateDatasetDataSystemIndexes: mockUpdateDatasetDataSystemIndexes
}));
vi.mock('@/service/support/wallet/usage/push', () => ({
pushGenerateVectorUsage: mockPushGenerateVectorUsage
}));
vi.mock('@fastgpt/service/support/user/audit/util', () => ({
addAuditLog: mockAddAuditLog,
getI18nDatasetType: vi.fn((type: string) => type)
}));
vi.mock('@fastgpt/service/core/dataset/utils', () => ({
replaceS3KeyToPreviewUrl: mockReplaceS3KeyToPreviewUrl
}));
import handler from '@/pages/api/core/dataset/data/update';
const dataId = '68ad85a7463006c963799a05';
const buildAuthResult = () => ({
teamId: 'team-id',
tmbId: 'tmb-id',
collection: {
name: 'Collection',
indexPrefixTitle: true,
indexSize: 256,
imageIndex: true,
type: DatasetCollectionTypeEnum.images,
dataset: {
name: 'Dataset',
type: 'dataset',
vectorModel: 'vision-embedding',
vlmModel: 'vlm-model'
}
},
datasetData: {
q: 'old question',
a: 'old answer',
imageId: 'dataset/team/main.png',
indexes: [
{
type: DatasetDataIndexTypeEnum.custom,
text: 'old custom',
dataId: 'custom_old'
},
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'dataset/team/main.png',
dataId: 'image_embedding_old'
}
]
}
});
describe('PUT /api/core/dataset/data/update', () => {
beforeEach(() => {
vi.clearAllMocks();
mockAuthDatasetData.mockResolvedValue(buildAuthResult());
mockReplaceS3KeyToPreviewUrl.mockImplementation((text: string) => text);
mockUpdateDatasetDataByIndexes.mockResolvedValue({ tokens: 12 });
mockUpdateDatasetDataSystemIndexes.mockResolvedValue({ tokens: 0 });
});
it('should keep legacy indexes update API compatible while rebuilding system image embedding indexes', async () => {
const result = await handler({
body: {
dataId,
q: 'new question',
a: 'new answer',
indexes: [
{
type: DatasetDataIndexTypeEnum.custom,
text: 'new custom'
},
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'dataset/team/client-should-not-replace.png'
}
]
}
} as any);
expect(mockUpdateDatasetDataByIndexes).toHaveBeenCalledWith({
dataId,
q: 'new question',
a: 'new answer',
imageId: 'dataset/team/main.png',
imageIndex: true,
indexes: [
{
type: DatasetDataIndexTypeEnum.custom,
text: 'new custom'
}
],
model: 'vision-embedding',
indexSize: 256,
indexPrefix: '# Collection'
});
expect(mockUpdateDatasetDataSystemIndexes).not.toHaveBeenCalled();
expect(mockPushGenerateVectorUsage).toHaveBeenCalledWith({
teamId: 'team-id',
tmbId: 'tmb-id',
inputTokens: 12,
model: 'vision-embedding'
});
expect(result).toEqual({
q: 'new question',
a: 'new answer'
});
});
it('should pass an explicit empty question to the index update path', async () => {
await handler({
body: {
dataId,
q: '',
a: '',
indexes: []
}
} as any);
expect(mockUpdateDatasetDataByIndexes).toHaveBeenCalledWith(
expect.objectContaining({
dataId,
q: '',
a: '',
imageId: 'dataset/team/main.png',
imageIndex: true,
indexes: []
})
);
});
it('should pass image context when rebuilding generated indexes', async () => {
await handler({
body: {
dataId,
q: 'new question ![new](dataset/team/new.png)'
}
} as any);
expect(mockUpdateDatasetDataSystemIndexes).toHaveBeenCalledWith({
dataId,
q: 'new question ![new](dataset/team/new.png)',
a: 'old answer',
imageId: 'dataset/team/main.png',
imageIndex: true,
model: 'vision-embedding',
indexSize: 256,
indexPrefix: '# Collection'
});
expect(mockUpdateDatasetDataByIndexes).not.toHaveBeenCalled();
});
});
import { beforeEach, describe, expect, it } from 'vitest';
import handler from '@/pages/api/core/dataset/training/rebuildEmbedding';
import { MongoDatasetCollection } from '@fastgpt/service/core/dataset/collection/schema';
import { MongoDatasetData } from '@fastgpt/service/core/dataset/data/schema';
import { MongoDataset } from '@fastgpt/service/core/dataset/schema';
import { MongoDatasetTraining } from '@fastgpt/service/core/dataset/training/schema';
import {
DatasetCollectionTypeEnum,
TrainingModeEnum
} from '@fastgpt/global/core/dataset/constants';
import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants';
import { getRootUser } from '@test/datas/users';
import { Call } from '@test/utils/request';
const registerEmbeddingModel = ({ model, vision = false }: { model: string; vision?: boolean }) => {
global.embeddingModelMap.set(model, {
...global.systemDefaultModel.embedding,
model,
name: model,
vision
});
};
const registerVlmModel = (model: string) => {
global.llmModelMap.set(model, {
...global.systemDefaultModel.llm,
model,
name: model,
vision: true
});
};
const createDatasetContext = async ({ vlmModel }: { vlmModel?: string } = {}) => {
const root = await getRootUser();
const dataset = await MongoDataset.create({
name: 'test dataset',
teamId: root.teamId,
tmbId: root.tmbId,
vectorModel: 'old-embedding',
agentModel: 'gpt-5',
vlmModel
});
const collection = await MongoDatasetCollection.create({
name: 'test collection',
type: DatasetCollectionTypeEnum.file,
teamId: root.teamId,
tmbId: root.tmbId,
datasetId: dataset._id,
imageIndex: true
});
return { root, dataset, collection };
};
describe('POST /api/core/dataset/training/rebuildEmbedding', () => {
beforeEach(() => {
global.systemEnv = {
...global.systemEnv,
vectorMaxProcess: 1
};
registerEmbeddingModel({ model: 'old-embedding' });
registerEmbeddingModel({ model: 'vision-embedding', vision: true });
registerEmbeddingModel({ model: 'text-only-embedding' });
registerVlmModel('dataset-vlm-model');
});
it('should keep image index and enqueue image mode when the new embedding model supports images', async () => {
const { root, dataset, collection } = await createDatasetContext();
const data = await MongoDatasetData.create({
teamId: root.teamId,
tmbId: root.tmbId,
datasetId: dataset._id,
collectionId: collection._id,
q: 'question with ![cat](dataset/team/cat.png)',
indexes: [
{
type: DatasetDataIndexTypeEnum.custom,
text: 'manual index',
dataId: 'manual_id'
}
]
});
const res = await Call(handler, {
auth: root,
body: {
datasetId: String(dataset._id),
vectorModel: 'vision-embedding'
}
});
const updatedDataset = await MongoDataset.findById(dataset._id).lean();
const updatedCollection = await MongoDatasetCollection.findById(collection._id).lean();
const training = await MongoDatasetTraining.findOne({ dataId: data._id }).lean();
expect(res.code).toBe(200);
expect(updatedDataset?.vectorModel).toBe('vision-embedding');
expect(updatedCollection?.imageIndex).toBe(true);
expect(training).toEqual(
expect.objectContaining({
mode: TrainingModeEnum.image,
q: 'question with ![cat](dataset/team/cat.png)',
retryCount: 50
})
);
expect(training?.indexes).toEqual([
expect.objectContaining({
type: DatasetDataIndexTypeEnum.custom,
text: 'manual index'
})
]);
});
it('should disable image index and enqueue chunk mode when the new embedding model has no image capability', async () => {
const { root, dataset, collection } = await createDatasetContext();
const data = await MongoDatasetData.create({
teamId: root.teamId,
tmbId: root.tmbId,
datasetId: dataset._id,
collectionId: collection._id,
q: 'question with ![cat](dataset/team/cat.png)'
});
const res = await Call(handler, {
auth: root,
body: {
datasetId: String(dataset._id),
vectorModel: 'text-only-embedding'
}
});
const updatedDataset = await MongoDataset.findById(dataset._id).lean();
const updatedCollection = await MongoDatasetCollection.findById(collection._id).lean();
const training = await MongoDatasetTraining.findOne({ dataId: data._id }).lean();
expect(res.code).toBe(200);
expect(updatedDataset?.vectorModel).toBe('text-only-embedding');
expect(updatedDataset?.chunkSettings?.imageIndex).toBe(false);
expect(updatedCollection?.imageIndex).toBe(false);
expect(training).toEqual(
expect.objectContaining({
mode: TrainingModeEnum.chunk,
retryCount: 50
})
);
expect(training?.q).toBe('');
});
it('should enqueue imageParse mode with VLM model for image data when VLM is configured', async () => {
const { root, dataset, collection } = await createDatasetContext({
vlmModel: 'dataset-vlm-model'
});
const data = await MongoDatasetData.create({
teamId: root.teamId,
tmbId: root.tmbId,
datasetId: dataset._id,
collectionId: collection._id,
q: '',
imageId: 'dataset/team/main.png'
});
const res = await Call(handler, {
auth: root,
body: {
datasetId: String(dataset._id),
vectorModel: 'text-only-embedding'
}
});
const training = await MongoDatasetTraining.findOne({ dataId: data._id }).lean();
expect(res.code).toBe(200);
expect(training).toEqual(
expect.objectContaining({
mode: TrainingModeEnum.imageParse,
imageId: 'dataset/team/main.png',
retryCount: 50
})
);
});
});
...@@ -21,17 +21,23 @@ import { ...@@ -21,17 +21,23 @@ import {
createDatasetData, createDatasetData,
deleteDatasetData, deleteDatasetData,
updateDatasetDataByIndexes, updateDatasetDataByIndexes,
updateDatasetDataDefaultIndexes updateDatasetDataSystemIndexes
} from '@/service/core/dataset/data/data'; } from '@/service/core/dataset/data/data';
const { mockDeleteDatasetFileByKey, mockCountPromptTokens } = vi.hoisted(() => ({ const { mockDeleteDatasetFileByKey, mockGetDatasetBase64Image, mockCountPromptTokens } = vi.hoisted(
() => ({
mockDeleteDatasetFileByKey: vi.fn(), mockDeleteDatasetFileByKey: vi.fn(),
mockGetDatasetBase64Image: vi.fn(
async (imageUrl: string) => `data:image/png;base64,${imageUrl}`
),
mockCountPromptTokens: vi.fn(async (text: string) => text.length) mockCountPromptTokens: vi.fn(async (text: string) => text.length)
})); })
);
vi.mock('@fastgpt/service/common/s3/sources/dataset', () => ({ vi.mock('@fastgpt/service/common/s3/sources/dataset', () => ({
getS3DatasetSource: vi.fn(() => ({ getS3DatasetSource: vi.fn(() => ({
deleteDatasetFileByKey: mockDeleteDatasetFileByKey deleteDatasetFileByKey: mockDeleteDatasetFileByKey,
getDatasetBase64Image: mockGetDatasetBase64Image
})) }))
})); }));
...@@ -69,11 +75,13 @@ const createDatasetContext = async () => { ...@@ -69,11 +75,13 @@ const createDatasetContext = async () => {
const createMongoData = async ({ const createMongoData = async ({
q = 'old question', q = 'old question',
a = 'old answer', a = 'old answer',
imageId,
indexes, indexes,
history history
}: { }: {
q?: string; q?: string;
a?: string; a?: string;
imageId?: string;
indexes?: DatasetDataIndexItemType[]; indexes?: DatasetDataIndexItemType[];
history?: DatasetDataItemType['history']; history?: DatasetDataItemType['history'];
} = {}) => { } = {}) => {
...@@ -85,6 +93,7 @@ const createMongoData = async ({ ...@@ -85,6 +93,7 @@ const createMongoData = async ({
collectionId: collection._id, collectionId: collection._id,
q, q,
a, a,
imageId,
history, history,
indexes: indexes ?? [ indexes: indexes ?? [
{ {
...@@ -229,6 +238,46 @@ describe('Dataset data service', () => { ...@@ -229,6 +238,46 @@ describe('Dataset data service', () => {
} as any) } as any)
).rejects.toBe('q, datasetId, collectionId, embeddingModel is required'); ).rejects.toBe('q, datasetId, collectionId, embeddingModel is required');
}); });
it('should allow empty question text for image data without creating default text index', async () => {
const { root, dataset, collection } = await createDatasetContext();
const imageId = `dataset/${dataset._id}/黄芪.png`;
vi.mocked(getEmbeddingModel).mockReturnValue({
...embeddingModel,
vision: true
});
const result = await createDatasetData({
teamId: String(root.teamId),
tmbId: String(root.tmbId),
datasetId: String(dataset._id),
collectionId: String(collection._id),
q: '',
imageId,
embeddingModel: 'text-embedding-3-small',
indexSize: 50
});
const data = await MongoDatasetData.findById(result.insertId).lean();
expect(data?.q).toBe('');
expect(data?.indexes).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: imageId
})
])
);
expect(data?.indexes).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
type: DatasetDataIndexTypeEnum.default,
text: ''
})
])
);
});
}); });
describe('updateDatasetDataByIndexes', () => { describe('updateDatasetDataByIndexes', () => {
...@@ -332,9 +381,105 @@ describe('Dataset data service', () => { ...@@ -332,9 +381,105 @@ describe('Dataset data service', () => {
}) })
).rejects.toBe('Data not found'); ).rejects.toBe('Data not found');
}); });
it('should rebuild image embedding indexes from data content when image index is enabled', async () => {
vi.mocked(getEmbeddingModel).mockReturnValue({
...embeddingModel,
vision: true
});
const mainImage = 'dataset/team/main.png';
const oldMarkdownImage = 'dataset/team/old.png';
const newMarkdownImage = 'dataset/team/new.png';
const { data } = await createMongoData({
q: `old question ![old](${oldMarkdownImage})`,
a: '',
imageId: mainImage,
indexes: [
{
type: DatasetDataIndexTypeEnum.custom,
text: 'old custom index',
dataId: 'custom_old'
},
{
type: DatasetDataIndexTypeEnum.default,
text: `old question ![old](${oldMarkdownImage})`,
dataId: 'default_old'
},
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: mainImage,
dataId: 'main_image_old'
},
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: oldMarkdownImage,
dataId: 'old_markdown_image'
}
]
});
await updateDatasetDataByIndexes({
dataId: String(data._id),
q: `new question ![new](${newMarkdownImage})`,
a: '',
imageId: mainImage,
imageIndex: true,
indexes: [
{
type: DatasetDataIndexTypeEnum.custom,
text: 'new custom index'
}
],
model: 'text-embedding-3-small',
indexSize: 50
});
const updatedData = await MongoDatasetData.findById(data._id).lean();
expect(updatedData?.indexes).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: DatasetDataIndexTypeEnum.custom,
text: 'new custom index'
}),
expect.objectContaining({
type: DatasetDataIndexTypeEnum.default,
text: `new question ![new](${newMarkdownImage})`
}),
expect.objectContaining({
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: mainImage,
dataId: 'main_image_old'
}),
expect.objectContaining({
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: newMarkdownImage
})
])
);
expect(
updatedData?.indexes.find(
(index) =>
index.type === DatasetDataIndexTypeEnum.imageEmbedding &&
index.text === oldMarkdownImage
)
).toBeUndefined();
expect(mockGetVectors).toHaveBeenCalledWith(
expect.objectContaining({
inputs: expect.arrayContaining([
{
type: 'image',
input: `data:image/png;base64,${newMarkdownImage}`
}
])
})
);
expect(mockVectorDelete.mock.calls[0]?.[0].idList).toEqual(
expect.arrayContaining(['custom_old', 'default_old', 'old_markdown_image'])
);
});
}); });
describe('updateDatasetDataDefaultIndexes', () => { describe('updateDatasetDataSystemIndexes', () => {
it('should replace only default indexes and keep concurrently added custom indexes', async () => { it('should replace only default indexes and keep concurrently added custom indexes', async () => {
const { data } = await createMongoData({ const { data } = await createMongoData({
q: 'old question', q: 'old question',
...@@ -353,7 +498,7 @@ describe('Dataset data service', () => { ...@@ -353,7 +498,7 @@ describe('Dataset data service', () => {
] ]
}); });
const updatePromise = updateDatasetDataDefaultIndexes({ const updatePromise = updateDatasetDataSystemIndexes({
dataId: String(data._id), dataId: String(data._id),
q: 'new question', q: 'new question',
a: '', a: '',
...@@ -436,7 +581,7 @@ describe('Dataset data service', () => { ...@@ -436,7 +581,7 @@ describe('Dataset data service', () => {
] ]
}); });
const result = await updateDatasetDataDefaultIndexes({ const result = await updateDatasetDataSystemIndexes({
dataId: String(data._id), dataId: String(data._id),
q: 'same question', q: 'same question',
a: 'same answer', a: 'same answer',
...@@ -453,7 +598,7 @@ describe('Dataset data service', () => { ...@@ -453,7 +598,7 @@ describe('Dataset data service', () => {
it('should reject when data does not exist', async () => { it('should reject when data does not exist', async () => {
await expect( await expect(
updateDatasetDataDefaultIndexes({ updateDatasetDataSystemIndexes({
dataId: String(new Types.ObjectId()), dataId: String(new Types.ObjectId()),
q: 'question', q: 'question',
model: 'text-embedding-3-small' model: 'text-embedding-3-small'
...@@ -462,6 +607,110 @@ describe('Dataset data service', () => { ...@@ -462,6 +607,110 @@ describe('Dataset data service', () => {
}); });
}); });
describe('updateDatasetDataSystemIndexes with image embedding', () => {
it('should replace only default and image embedding indexes without touching manual indexes', async () => {
vi.mocked(getEmbeddingModel).mockReturnValue({
...embeddingModel,
vision: true
});
const { data } = await createMongoData({
q: 'old question',
a: '',
indexes: [
{
type: DatasetDataIndexTypeEnum.custom,
text: 'manual custom',
dataId: 'custom_old'
},
{
type: DatasetDataIndexTypeEnum.question,
text: 'manual question',
dataId: 'question_old'
},
{
type: DatasetDataIndexTypeEnum.summary,
text: 'manual summary',
dataId: 'summary_old'
},
{
type: DatasetDataIndexTypeEnum.image,
text: 'manual image summary',
dataId: 'image_old'
},
{
type: DatasetDataIndexTypeEnum.default,
text: 'old question',
dataId: 'default_old'
},
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'dataset/team/old.png',
dataId: 'image_embedding_old'
}
]
});
const nextImage = `dataset/${data.datasetId}/new.png`;
await updateDatasetDataSystemIndexes({
dataId: String(data._id),
q: `new question ![new](${nextImage})`,
a: '',
imageIndex: true,
model: 'text-embedding-3-small',
indexSize: 50
});
const updatedData = await MongoDatasetData.findById(data._id).lean();
expect(updatedData?.indexes).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: DatasetDataIndexTypeEnum.custom,
text: 'manual custom',
dataId: 'custom_old'
}),
expect.objectContaining({
type: DatasetDataIndexTypeEnum.question,
text: 'manual question',
dataId: 'question_old'
}),
expect.objectContaining({
type: DatasetDataIndexTypeEnum.summary,
text: 'manual summary',
dataId: 'summary_old'
}),
expect.objectContaining({
type: DatasetDataIndexTypeEnum.image,
text: 'manual image summary',
dataId: 'image_old'
}),
expect.objectContaining({
type: DatasetDataIndexTypeEnum.default,
text: `new question ![new](${nextImage})`
}),
expect.objectContaining({
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: nextImage
})
])
);
expect(
updatedData?.indexes.find(
(index) =>
index.type === DatasetDataIndexTypeEnum.imageEmbedding &&
index.text === 'dataset/team/old.png'
)
).toBeUndefined();
const deleteCall = mockVectorDelete.mock.calls[0]?.[0];
expect(String(deleteCall?.teamId)).toBe(String(data.teamId));
expect(deleteCall?.idList).toEqual(['default_old', 'image_embedding_old']);
expect(mockVectorDelete).not.toHaveBeenCalledWith(
expect.objectContaining({
idList: expect.arrayContaining(['custom_old', 'question_old', 'summary_old', 'image_old'])
})
);
});
});
describe('deleteDatasetData', () => { describe('deleteDatasetData', () => {
it('should delete data, full-text data, dataset image and vectors', async () => { it('should delete data, full-text data, dataset image and vectors', async () => {
const { data } = await createMongoData({ const { data } = await createMongoData({
......
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Types } from '@fastgpt/service/common/mongo'; import { Types } from '@fastgpt/service/common/mongo';
import { getEmbeddingModel } from '@fastgpt/service/core/ai/model'; import { getEmbeddingModel } from '@fastgpt/service/core/ai/model';
import { MongoDatasetCollection } from '@fastgpt/service/core/dataset/collection/schema'; import { MongoDatasetCollection } from '@fastgpt/service/core/dataset/collection/schema';
...@@ -13,6 +13,7 @@ import type { ...@@ -13,6 +13,7 @@ import type {
import { getRootUser } from '@test/datas/users'; import { getRootUser } from '@test/datas/users';
import { mockGetVectors, createMockVectorsResponse } from '@test/mocks/core/ai/embedding'; import { mockGetVectors, createMockVectorsResponse } from '@test/mocks/core/ai/embedding';
import { mockVectorDelete, mockVectorInsert, resetVectorMocks } from '@test/mocks/common/vector'; import { mockVectorDelete, mockVectorInsert, resetVectorMocks } from '@test/mocks/common/vector';
import { serviceEnv } from '@fastgpt/service/env';
import { import {
createDatasetDataIndex, createDatasetDataIndex,
DatasetDataIndexOperation, DatasetDataIndexOperation,
...@@ -24,6 +25,16 @@ const { mockCountPromptTokens } = vi.hoisted(() => ({ ...@@ -24,6 +25,16 @@ const { mockCountPromptTokens } = vi.hoisted(() => ({
mockCountPromptTokens: vi.fn(async (text: string) => text.length) mockCountPromptTokens: vi.fn(async (text: string) => text.length)
})); }));
const { mockGetDatasetBase64Image } = vi.hoisted(() => ({
mockGetDatasetBase64Image: vi.fn(async (imageUrl: string) => `data:image/png;base64,${imageUrl}`)
}));
vi.mock('@fastgpt/service/common/s3/sources/dataset', () => ({
getS3DatasetSource: vi.fn(() => ({
getDatasetBase64Image: mockGetDatasetBase64Image
}))
}));
vi.mock('@fastgpt/service/common/string/tiktoken', () => ({ vi.mock('@fastgpt/service/common/string/tiktoken', () => ({
countPromptTokens: mockCountPromptTokens countPromptTokens: mockCountPromptTokens
})); }));
...@@ -33,6 +44,7 @@ const embeddingModel = { ...@@ -33,6 +44,7 @@ const embeddingModel = {
name: 'text-embedding-3-small', name: 'text-embedding-3-small',
maxToken: 12 maxToken: 12
} as any; } as any;
const originalMultipleDataToBase64 = serviceEnv.MULTIPLE_DATA_TO_BASE64;
const createDatasetContext = async () => { const createDatasetContext = async () => {
const root = await getRootUser(); const root = await getRootUser();
...@@ -101,8 +113,10 @@ const createData = async ( ...@@ -101,8 +113,10 @@ const createData = async (
describe('DatasetDataIndexOperation', () => { describe('DatasetDataIndexOperation', () => {
beforeEach(() => { beforeEach(() => {
serviceEnv.MULTIPLE_DATA_TO_BASE64 = true;
resetVectorMocks(); resetVectorMocks();
mockGetVectors.mockClear(); mockGetVectors.mockClear();
mockGetDatasetBase64Image.mockClear();
mockCountPromptTokens.mockClear(); mockCountPromptTokens.mockClear();
vi.mocked(getEmbeddingModel).mockReturnValue(embeddingModel); vi.mocked(getEmbeddingModel).mockReturnValue(embeddingModel);
mockGetVectors.mockImplementation(async ({ inputs }) => mockGetVectors.mockImplementation(async ({ inputs }) =>
...@@ -114,11 +128,48 @@ describe('DatasetDataIndexOperation', () => { ...@@ -114,11 +128,48 @@ describe('DatasetDataIndexOperation', () => {
mockVectorDelete.mockResolvedValue(undefined); mockVectorDelete.mockResolvedValue(undefined);
}); });
describe('getDefaultIndexes', () => { afterEach(() => {
serviceEnv.MULTIPLE_DATA_TO_BASE64 = originalMultipleDataToBase64;
});
describe('getSystemIndexes', () => {
it('should collect image embedding sources by image index switch and model capability', () => {
const textModelOperation = new DatasetDataIndexOperation(embeddingModel);
const visionOperation = new DatasetDataIndexOperation({
...embeddingModel,
vision: true
});
expect(
textModelOperation.getImageEmbeddingSources({
q: '![one](dataset/team/one.png)',
imageId: 'dataset/team/main.png',
imageIndex: true
})
).toEqual([]);
expect(
visionOperation.getImageEmbeddingSources({
q: '![one](dataset/team/one.png) ![invalid](relative/image.png)',
a: '![two](https://example.com/two.png) ![repeat](dataset/team/one.png)',
imageId: 'dataset/team/main.png',
imageIndex: true
})
).toEqual(['dataset/team/main.png', 'dataset/team/one.png', 'https://example.com/two.png']);
expect(
visionOperation.getImageEmbeddingSources({
q: '![one](dataset/team/one.png)',
imageId: 'dataset/team/main.png',
imageIndex: false
})
).toEqual(['dataset/team/main.png']);
});
it('should create prefixed default indexes from question and answer', async () => { it('should create prefixed default indexes from question and answer', async () => {
const operation = new DatasetDataIndexOperation(embeddingModel); const operation = new DatasetDataIndexOperation(embeddingModel);
const result = await operation.getDefaultIndexes({ const result = await operation.getSystemIndexes({
q: 'question text', q: 'question text',
a: 'answer text', a: 'answer text',
indexSize: 50, indexSize: 50,
...@@ -136,6 +187,45 @@ describe('DatasetDataIndexOperation', () => { ...@@ -136,6 +187,45 @@ describe('DatasetDataIndexOperation', () => {
} }
]); ]);
}); });
it('should create image embedding indexes from image id and markdown images', async () => {
const operation = new DatasetDataIndexOperation({
...embeddingModel,
vision: true
});
const result = await operation.getSystemIndexes({
q: 'question ![one](dataset/team/one.png)',
a: 'answer ![two](https://example.com/two.png) ![repeat](dataset/team/one.png)',
imageId: 'dataset/team/main.png',
imageIndex: true,
indexSize: 50,
maxIndexSize: 200
});
expect(result).toEqual([
{
type: DatasetDataIndexTypeEnum.default,
text: 'question ![one](dataset/team/one.png)'
},
{
type: DatasetDataIndexTypeEnum.default,
text: 'answer ![two](https://example.com/two.png) ![repeat](dataset/team/one.png)'
},
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'dataset/team/main.png'
},
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'dataset/team/one.png'
},
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'https://example.com/two.png'
}
]);
});
}); });
describe('formatIndexes', () => { describe('formatIndexes', () => {
...@@ -173,6 +263,91 @@ describe('DatasetDataIndexOperation', () => { ...@@ -173,6 +263,91 @@ describe('DatasetDataIndexOperation', () => {
]); ]);
}); });
it('should regenerate system indexes and only reuse matching image embedding ids', async () => {
const operation = new DatasetDataIndexOperation({
...embeddingModel,
vision: true
});
const result = await operation.formatIndexes({
q: 'question ![new](dataset/team/new.png)',
a: '',
imageId: 'dataset/team/main.png',
imageIndex: true,
indexSize: 20,
maxIndexSize: 200,
indexes: [
{ type: DatasetDataIndexTypeEnum.custom, text: 'manual', dataId: 'manual_1' },
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'dataset/team/main.png',
dataId: 'main_old'
},
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'dataset/team/old.png',
dataId: 'old_image'
}
]
});
expect(result).toEqual([
{
type: DatasetDataIndexTypeEnum.custom,
text: 'manual',
dataId: 'manual_1'
},
{
type: DatasetDataIndexTypeEnum.default,
text: 'question ![new](dataset/team/new.png)'
},
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'dataset/team/main.png',
dataId: 'main_old'
},
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'dataset/team/new.png'
}
]);
});
it('should keep text indexes whose text matches image embedding source', async () => {
const operation = new DatasetDataIndexOperation({
...embeddingModel,
vision: true
});
const imageSource = 'dataset/team/main.png';
const result = await operation.formatIndexes({
q: '',
a: '',
imageId: imageSource,
indexSize: 20,
maxIndexSize: 200,
indexes: [
{
type: DatasetDataIndexTypeEnum.custom,
text: imageSource,
dataId: 'custom_image_text'
}
]
});
expect(result).toEqual([
{
type: DatasetDataIndexTypeEnum.custom,
text: imageSource,
dataId: 'custom_image_text'
},
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: imageSource
}
]);
});
it('should split a custom index when token count exceeds max token', async () => { it('should split a custom index when token count exceeds max token', async () => {
const operation = new DatasetDataIndexOperation(embeddingModel); const operation = new DatasetDataIndexOperation(embeddingModel);
mockCountPromptTokens.mockResolvedValueOnce(30); mockCountPromptTokens.mockResolvedValueOnce(30);
...@@ -196,18 +371,65 @@ describe('DatasetDataIndexOperation', () => { ...@@ -196,18 +371,65 @@ describe('DatasetDataIndexOperation', () => {
expect(mergedText).toContain('first'); expect(mergedText).toContain('first');
expect(mergedText).toContain('third'); expect(mergedText).toContain('third');
}); });
it('should check default index token size after prefix is applied', async () => {
const operation = new DatasetDataIndexOperation(embeddingModel);
mockCountPromptTokens.mockResolvedValueOnce(21);
const result = await operation.formatIndexes({
q: 'short content',
a: '',
indexSize: 10,
maxIndexSize: 20,
indexPrefix: '# LongTitle',
indexes: []
});
expect(mockCountPromptTokens).toHaveBeenCalledWith('# LongTitle\nshort content');
expect(result).toEqual([
{
type: DatasetDataIndexTypeEnum.default,
text: '# LongTitle\nshort content'
}
]);
});
it('should keep image embedding indexes unsplit even when text looks too long', async () => {
const operation = new DatasetDataIndexOperation({
...embeddingModel,
vision: true
});
const imageSource = 'dataset/team/a-very-long-image-source-name-that-is-not-text.png';
const result = await operation.formatIndexes({
q: '',
a: '',
imageId: imageSource,
indexSize: 8,
maxIndexSize: 12,
indexes: []
});
expect(mockCountPromptTokens).not.toHaveBeenCalled();
expect(result).toEqual([
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: imageSource
}
]);
});
}); });
describe('mergeExistingDefaultIndexIds', () => { describe('mergeExistingSystemIndexIds', () => {
it('should reuse dataId for unchanged default indexes only', () => { it('should reuse dataId for unchanged default indexes only', () => {
const operation = new DatasetDataIndexOperation(embeddingModel); const operation = new DatasetDataIndexOperation(embeddingModel);
const result = operation.mergeExistingDefaultIndexIds({ const result = operation.mergeExistingSystemIndexIds({
currentIndexes: [ currentIndexes: [
{ type: DatasetDataIndexTypeEnum.default, text: 'same', dataId: 'default_old' }, { type: DatasetDataIndexTypeEnum.default, text: 'same', dataId: 'default_old' },
{ type: DatasetDataIndexTypeEnum.custom, text: 'same custom', dataId: 'custom_old' } { type: DatasetDataIndexTypeEnum.custom, text: 'same custom', dataId: 'custom_old' }
], ],
nextDefaultIndexes: [ nextSystemIndexes: [
{ type: DatasetDataIndexTypeEnum.default, text: 'same' }, { type: DatasetDataIndexTypeEnum.default, text: 'same' },
{ type: DatasetDataIndexTypeEnum.default, text: 'new' } { type: DatasetDataIndexTypeEnum.default, text: 'new' }
] ]
...@@ -218,6 +440,38 @@ describe('DatasetDataIndexOperation', () => { ...@@ -218,6 +440,38 @@ describe('DatasetDataIndexOperation', () => {
{ type: DatasetDataIndexTypeEnum.default, text: 'new' } { type: DatasetDataIndexTypeEnum.default, text: 'new' }
]); ]);
}); });
it('should reuse system dataId by type and text without crossing text and image indexes', () => {
const operation = new DatasetDataIndexOperation(embeddingModel);
const result = operation.mergeExistingSystemIndexIds({
currentIndexes: [
{ type: DatasetDataIndexTypeEnum.default, text: 'same-source', dataId: 'default_old' },
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'same-source',
dataId: 'image_old'
},
{ type: DatasetDataIndexTypeEnum.custom, text: 'dataset/team/new.png', dataId: 'custom' }
],
nextSystemIndexes: [
{ type: DatasetDataIndexTypeEnum.imageEmbedding, text: 'same-source' },
{ type: DatasetDataIndexTypeEnum.imageEmbedding, text: 'dataset/team/new.png' }
]
});
expect(result).toEqual([
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'same-source',
dataId: 'image_old'
},
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'dataset/team/new.png'
}
]);
});
}); });
describe('buildPatch', () => { describe('buildPatch', () => {
...@@ -324,6 +578,50 @@ describe('DatasetDataIndexOperation', () => { ...@@ -324,6 +578,50 @@ describe('DatasetDataIndexOperation', () => {
expect(tokens).toBe(0); expect(tokens).toBe(0);
expect(mockVectorInsert).not.toHaveBeenCalled(); expect(mockVectorInsert).not.toHaveBeenCalled();
}); });
it('should insert text and image embedding patch items in one vector call', async () => {
const operation = new DatasetDataIndexOperation({
...embeddingModel,
vision: true
});
mockVectorInsert.mockResolvedValueOnce({ insertIds: ['text_vector_id', 'image_vector_id'] });
const patchResult = operation.buildPatch({
currentIndexes: [],
nextIndexes: [
{ type: DatasetDataIndexTypeEnum.custom, text: 'text index' },
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'dataset/team/collection/image.png'
}
]
});
const tokens = await operation.insertVectorForPatch({
patchResult,
teamId: 'team_id',
datasetId: 'dataset_id',
collectionId: 'collection_id'
});
expect(tokens).toBeGreaterThan(0);
expect(mockVectorInsert).toHaveBeenCalledTimes(1);
expect(mockGetVectors).toHaveBeenCalledWith(
expect.objectContaining({
inputs: [
{ type: 'text', input: 'text index' },
{ type: 'image', input: 'data:image/png;base64,dataset/team/collection/image.png' }
]
})
);
expect(operation.getWritablePatchIndexes(patchResult)).toEqual([
{ type: DatasetDataIndexTypeEnum.custom, text: 'text index', dataId: 'text_vector_id' },
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'dataset/team/collection/image.png',
dataId: 'image_vector_id'
}
]);
});
}); });
describe('insertVectors and deleteVectors', () => { describe('insertVectors and deleteVectors', () => {
...@@ -346,6 +644,51 @@ describe('DatasetDataIndexOperation', () => { ...@@ -346,6 +644,51 @@ describe('DatasetDataIndexOperation', () => {
]); ]);
}); });
it('should skip image embedding indexes when the model does not support image input', async () => {
const operation = new DatasetDataIndexOperation(embeddingModel);
const result = await operation.insertVectors({
indexes: [
{ type: DatasetDataIndexTypeEnum.custom, text: 'one' },
{ type: DatasetDataIndexTypeEnum.imageEmbedding, text: 'dataset/team/image.png' }
],
teamId: 'team_id',
datasetId: 'dataset_id',
collectionId: 'collection_id'
});
expect(result.indexes).toEqual([
{ type: DatasetDataIndexTypeEnum.custom, text: 'one', dataId: 'id_1' }
]);
expect(mockVectorInsert).toHaveBeenCalledTimes(1);
});
it('should skip invalid image embedding sources without dropping valid text indexes', async () => {
const operation = new DatasetDataIndexOperation({
...embeddingModel,
vision: true
});
const result = await operation.insertVectors({
indexes: [
{ type: DatasetDataIndexTypeEnum.custom, text: 'one' },
{ type: DatasetDataIndexTypeEnum.imageEmbedding, text: 'relative/image.png' }
],
teamId: 'team_id',
datasetId: 'dataset_id',
collectionId: 'collection_id'
});
expect(result.indexes).toEqual([
{ type: DatasetDataIndexTypeEnum.custom, text: 'one', dataId: 'id_1' }
]);
expect(mockGetVectors).toHaveBeenCalledWith(
expect.objectContaining({
inputs: [{ type: 'text', input: 'one' }]
})
);
});
it('should skip vector delete when id list is empty', async () => { it('should skip vector delete when id list is empty', async () => {
const operation = new DatasetDataIndexOperation(embeddingModel); const operation = new DatasetDataIndexOperation(embeddingModel);
...@@ -412,6 +755,45 @@ describe('DatasetDataIndexOperation', () => { ...@@ -412,6 +755,45 @@ describe('DatasetDataIndexOperation', () => {
}); });
}); });
it('should allow manually updating generated non-protected index types', async () => {
const editableTypes = [
DatasetDataIndexTypeEnum.summary,
DatasetDataIndexTypeEnum.question,
DatasetDataIndexTypeEnum.image
];
for (const type of editableTypes) {
const { data, dataItem } = await createData([
{
type,
text: 'old',
dataId: `${type}_old`
}
]);
const result = await updateDatasetDataIndex({
data: dataItem,
indexDataId: `${type}_old`,
type,
text: 'new',
model: 'text-embedding-3-small'
});
const updatedData = await MongoDatasetData.findById(data._id).lean();
expect(result.index).toEqual({
type,
text: 'new',
dataId: expect.any(String)
});
expect(updatedData?.indexes).toEqual([
expect.objectContaining({
type,
text: 'new'
})
]);
}
});
it('should reuse existing index when text and type do not change', async () => { it('should reuse existing index when text and type do not change', async () => {
const { dataItem } = await createData(); const { dataItem } = await createData();
...@@ -456,6 +838,15 @@ describe('DatasetDataIndexOperation', () => { ...@@ -456,6 +838,15 @@ describe('DatasetDataIndexOperation', () => {
).rejects.toBe('System indexes cannot be saved separately'); ).rejects.toBe('System indexes cannot be saved separately');
await expect( await expect(
createDatasetDataIndex({
data: dataItem,
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'dataset/team/image.png',
model: 'text-embedding-3-small'
})
).rejects.toBe('System indexes cannot be saved separately');
await expect(
updateDatasetDataIndex({ updateDatasetDataIndex({
data: dataItem, data: dataItem,
indexDataId: 'missing_id', indexDataId: 'missing_id',
...@@ -525,6 +916,47 @@ describe('DatasetDataIndexOperation', () => { ...@@ -525,6 +916,47 @@ describe('DatasetDataIndexOperation', () => {
indexDataId: 'default_id' indexDataId: 'default_id'
}) })
).rejects.toBe('System indexes cannot be deleted separately'); ).rejects.toBe('System indexes cannot be deleted separately');
const imageData = await createData([
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'dataset/team/image.png',
dataId: 'image_id'
}
]);
await expect(
deleteDatasetDataIndex({
data: imageData.dataItem,
indexDataId: 'image_id'
})
).rejects.toBe('System indexes cannot be deleted separately');
});
it('should allow manually deleting generated non-protected index types', async () => {
const editableTypes = [
DatasetDataIndexTypeEnum.summary,
DatasetDataIndexTypeEnum.question,
DatasetDataIndexTypeEnum.image
];
for (const type of editableTypes) {
const { data, dataItem } = await createData([
{
type,
text: `${type} text`,
dataId: `${type}_id`
}
]);
await deleteDatasetDataIndex({
data: dataItem,
indexDataId: `${type}_id`
});
const updatedData = await MongoDatasetData.findById(data._id).lean();
expect(updatedData?.indexes).toEqual([]);
}
}); });
}); });
......
import { beforeEach, describe, expect, it } from 'vitest';
import { getRebuildBaseIndexes } from '@/service/core/dataset/queues/generateVector';
import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants';
const visionEmbeddingModel = {
model: 'vision-embedding',
name: 'vision-embedding',
maxToken: 100,
vision: true
} as any;
describe('generateVector image embedding helpers', () => {
beforeEach(() => {
global.embeddingModelMap.set(visionEmbeddingModel.model, visionEmbeddingModel);
global.llmModelMap.set('vlm-model', {
...global.systemDefaultModel.llm,
model: 'vlm-model',
name: 'vlm-model',
vision: true
});
});
it('should drop system indexes and keep supported external image description indexes when rebuilding', () => {
const result = getRebuildBaseIndexes({
indexes: [
{ type: DatasetDataIndexTypeEnum.default, text: 'old default', dataId: 'default_id' },
{ type: DatasetDataIndexTypeEnum.custom, text: 'manual', dataId: 'manual_id' },
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'dataset/team/main.png',
dataId: 'main_vector_id'
},
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'dataset/team/stale.png',
dataId: 'stale_vector_id'
},
{
type: DatasetDataIndexTypeEnum.image,
text: 'image description',
dataId: 'image_desc_id'
}
],
q: 'content ![markdown](dataset/team/markdown.png)',
dataset: {
vectorModel: visionEmbeddingModel.model,
vlmModel: 'vlm-model'
},
collection: {
imageIndex: true
},
data: {
imageId: 'dataset/team/main.png',
indexes: []
}
} as any);
expect(result).toEqual([
{ type: DatasetDataIndexTypeEnum.custom, text: 'manual', dataId: 'manual_id' },
{
type: DatasetDataIndexTypeEnum.image,
text: 'image description',
dataId: 'image_desc_id'
}
]);
});
it('should drop VLM image description indexes when collection image index is disabled', () => {
const result = getRebuildBaseIndexes({
indexes: [
{ type: DatasetDataIndexTypeEnum.custom, text: 'manual', dataId: 'manual_id' },
{
type: DatasetDataIndexTypeEnum.image,
text: 'image description',
dataId: 'image_desc_id'
},
{
type: DatasetDataIndexTypeEnum.imageEmbedding,
text: 'dataset/team/main.png',
dataId: 'main_vector_id'
}
],
dataset: {
vectorModel: visionEmbeddingModel.model,
vlmModel: 'vlm-model'
},
collection: {
imageIndex: false
},
data: {
imageId: 'dataset/team/main.png',
indexes: []
}
} as any);
expect(result).toEqual([
{ type: DatasetDataIndexTypeEnum.custom, text: 'manual', dataId: 'manual_id' }
]);
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment