Skip to content

Commit 5b4117f

Browse files
committed
feat: implement lossless knowledge archive export and import functionality
1 parent a68f388 commit 5b4117f

5 files changed

Lines changed: 878 additions & 14 deletions

File tree

‎apps/knowledge/ARCHIVE.md‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# 知识库导入与导出
2+
3+
知识库整体 ZIP 导出接口 `export_knowledge` 使用版本 2 归档。工作空间、系统资源和共享资源
4+
复用同一导出实现,共享资源与工作空间导入也复用同一导入实现。本次仅涉及后端。
5+
6+
## 文件结构与兼容
7+
8+
- `knowledge.json`:知识库设置,以及 `archive_version: 2` 标识的完整 `resources` 数据。
9+
- `knowledge.xlsx`:保留原来的表格表示,便于查看内容及兼容旧格式;新版导入以 JSON 中的
10+
`resources` 为准,避免 Excel 单元格长度、工作表名称和标签分隔符造成数据损失。
11+
- `oss/file/{原文件ID}`:图片及内容所引用的文件。
12+
- `source_file/`:选择 `with_source_file=true` 时附带的普通文档原始文件。
13+
- `workflow.kbwf`:工作流知识库沿用原有工作流和工具导出格式。
14+
15+
不含版本标识的旧 ZIP 继续走原有 Excel 导入流程;缺少新增配置时使用当前默认值。
16+
不支持的版本、缺失文件或损坏的资源关联会报错,不会静默降级为只导入文字。
17+
普通 Excel/ZIP 内容导出接口仍是原有用途,完整迁移请使用知识库整体 ZIP 导出。
18+
19+
## 保存与恢复的数据
20+
21+
| 范围 | 内容 |
22+
| --- | --- |
23+
| 知识库设置 | 名称、描述、类型、文件限制、外部服务开关与身份认证开关、知识库级处理策略、定时同步设置及保存的工作流同步输入 |
24+
| 文档和独立图片 | 原始名称、资源类型、启停状态、召回处理方式、阈值、文档处理策略、源信息、同步版本/时间及策略哈希 |
25+
| 分段 | 完整正文、标题、子块、结构化图片内容、启停及排序、来源快照、本地/同步状态、锚点关系 |
26+
| 图片资产 | 图片文件、标题、OCR、视觉描述、处理状态、来源与分段关联;即使未选择附带普通原始文件也会打包图片 |
27+
| 问题与标签 | 包括未关联的问题、问题与分段映射、标签及文档关联;标签中的冒号和竖线不丢失 |
28+
| 召回统计 | 文档、分段、问题及图片资产的次数和最后召回时间 |
29+
| 工作流 | 工作流定义、原有工具导入选项,以及默认模型设置 |
30+
31+
导入生成新的知识库、文档、分段、问题、资产、标签和文件 ID,重建关联,并替换正文、
32+
结构化内容和元数据里的文件引用。文件通过目标环境的存储后端写入,不携带原数据库大对象地址
33+
或对象存储 key。内部文件 URL 转换为目标环境可使用的相对路径。
34+
35+
外部服务开关按归档恢复,包括旧知识库空配置的兼容语义;新建知识库两个开关默认关闭的规则不变。
36+
已有的对话用户 API Key 和授权关系不随归档复制。已开启的定时设置在导入事务提交后注册调度任务,
37+
关闭的设置只保存配置。
38+
39+
保留原有飞书连接凭据不导出的规则,同时单独保留其处理策略和定时设置;导入后需要重新配置
40+
飞书连接。模型/视觉工具保留配置引用,不导出模型凭据;目标环境需具备对应资源与权限。
41+
向量、分词索引、正在执行的任务以及同步执行日志不作为归档资源复制。导入后仍按原有流程选择
42+
嵌入模型并重建索引,外部连接和任务依赖需在目标环境配置完成。
43+
44+
## 验证
45+
46+
`python apps/manage.py test knowledge.test_archive` 覆盖真实 ZIP/JSON/XLSX 序列化往返、
47+
旧格式导入、图片二进制与关联恢复、设置/统计/工作流保留、长正文、空文档、原始文件选项、
48+
跨知识库文件保护以及损坏归档校验。数据库和文件存储写入在这些单元测试中被隔离;真实
49+
PostgreSQL、对象存储和调度器仍需在配置好的集成环境验收。

‎apps/knowledge/serializers/knowledge.py‎

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,14 @@
6565
Termbase,
6666
)
6767
from knowledge.services.document_strategy import normalize_document_strategy
68-
from knowledge.services.knowledge_sync_schedule import remove_knowledge_sync_job
68+
from knowledge.services.knowledge_archive import (
69+
ARCHIVE_VERSION,
70+
export_resources,
71+
portable_knowledge_meta,
72+
restore_resources,
73+
validate_archive,
74+
)
75+
from knowledge.services.knowledge_sync_schedule import deploy_knowledge_sync_job, remove_knowledge_sync_job
6976
from knowledge.services.multimodal_retrieval import (
7077
MAX_QUERY_IMAGE_COUNT,
7178
get_hit_asset_map,
@@ -664,6 +671,7 @@ def export_knowledge(self, with_source_file=False, with_valid=True):
664671
QuerySet(File).filter(source_id__in=document_id_list, source_type=FileSourceType.DOCUMENT)
665672
)
666673
source_file_map = {str(source_file.source_id): source_file for source_file in source_file_list}
674+
source_files_by_id = {str(source_file.id): source_file for source_file in source_file_list}
667675

668676
# 查询标签和文档标签关联
669677
tag_list = list(QuerySet(Tag).filter(knowledge_id=knowledge_id).values("id", "key", "value"))
@@ -685,7 +693,9 @@ def export_knowledge(self, with_source_file=False, with_valid=True):
685693
for doc in document_list:
686694
if with_source_file:
687695
doc.meta = {**doc.meta} if doc.meta else {}
688-
source_file = source_file_map.get(str(doc.id))
696+
source_file = source_files_by_id.get(str(doc.meta.get("source_file_id"))) or source_file_map.get(
697+
str(doc.id)
698+
)
689699
if source_file:
690700
doc.meta["source_file_id"] = str(source_file.id)
691701
else:
@@ -747,15 +757,21 @@ def export_knowledge(self, with_source_file=False, with_valid=True):
747757
f.write(source_file.get_bytes())
748758

749759
knowledge_json = {
760+
"archive_version": ARCHIVE_VERSION,
761+
"source_knowledge_id": str(knowledge.id),
750762
"name": knowledge.name,
751763
"desc": knowledge.desc,
752764
"type": knowledge.type,
753-
"meta": {} if knowledge.type == KnowledgeType.LARK else (knowledge.meta if knowledge.meta else {}),
765+
"meta": portable_knowledge_meta(knowledge),
766+
"external_service": knowledge.external_service,
754767
"file_size_limit": knowledge.file_size_limit,
755768
"file_count_limit": knowledge.file_count_limit,
756769
"tags": [{"key": t["key"], "value": t["value"]} for t in tag_list],
757770
"termbase": terms,
758771
"source_file_list": source_file_export_list,
772+
"resources": export_resources(
773+
knowledge, document_list, source_file_list, tempdir, source_file_export_list
774+
),
759775
}
760776

761777
with open(os.path.join(tempdir, "knowledge.json"), "w", encoding="utf-8") as f:
@@ -954,6 +970,7 @@ def import_knowledge(self, file, is_import_tool=False, with_valid=True):
954970

955971
# knowledge.json -> knowledge
956972
knowledge_data = json.loads(zf.read("knowledge.json"))
973+
resources = validate_archive(knowledge_data, zf)
957974
source_file_meta_map = {
958975
str(source_file.get("id")): source_file
959976
for source_file in knowledge_data.get("source_file_list", [])
@@ -981,12 +998,20 @@ def import_knowledge(self, file, is_import_tool=False, with_valid=True):
981998
user_id=user_id,
982999
workspace_id=workspace_id,
9831000
folder_id=folder_id,
1001+
**(
1002+
{"external_service": knowledge_data["external_service"]}
1003+
if "external_service" in knowledge_data
1004+
else {}
1005+
),
9841006
)
9851007
knowledge.save()
9861008

1009+
if resources is not None:
1010+
restore_resources(knowledge, knowledge_data, zf)
1011+
9871012
# 图片
9881013
old_to_new_file_map = {}
989-
for name in namelist:
1014+
for name in namelist if resources is None else []:
9901015
if name.startswith("oss/file/") and name != "oss/file/":
9911016
old_id = name.split("/")[-1]
9921017
if not old_id:
@@ -1003,15 +1028,17 @@ def import_knowledge(self, file, is_import_tool=False, with_valid=True):
10031028
old_to_new_file_map[old_id] = str(new_file.id)
10041029

10051030
# knowledge.xlsx -> doc + para + problem
1006-
xlsx_bytes = io.BytesIO(zf.read("knowledge.xlsx"))
1007-
workbook = openpyxl.load_workbook(xlsx_bytes)
1031+
sheets = []
1032+
if resources is None:
1033+
xlsx_bytes = io.BytesIO(zf.read("knowledge.xlsx"))
1034+
sheets = openpyxl.load_workbook(xlsx_bytes).worksheets
10081035

10091036
document_model_list = []
10101037
paragraph_model_list = []
10111038
problem_paragraph_object_list = []
10121039
doc_tags_map = {}
10131040

1014-
for sheet in workbook.worksheets:
1041+
for sheet in sheets:
10151042
doc_name = sheet.title
10161043
rows = list(sheet.iter_rows(min_row=2, values_only=True))
10171044
if not rows:
@@ -1112,14 +1139,15 @@ def import_knowledge(self, file, is_import_tool=False, with_valid=True):
11121139
QuerySet(Paragraph).bulk_create(paragraph_model_list) if len(paragraph_model_list) > 0 else None
11131140

11141141
# 问题
1115-
problem_model_list, problem_paragraph_mapping_list = ProblemParagraphManage(
1116-
problem_paragraph_object_list, knowledge_id
1117-
).to_problem_model_list()
1118-
bulk_create_in_batches(Problem, problem_model_list, batch_size=1000)
1119-
bulk_create_in_batches(ProblemParagraphMapping, problem_paragraph_mapping_list, batch_size=1000)
1142+
if resources is None:
1143+
problem_model_list, problem_paragraph_mapping_list = ProblemParagraphManage(
1144+
problem_paragraph_object_list, knowledge_id
1145+
).to_problem_model_list()
1146+
bulk_create_in_batches(Problem, problem_model_list, batch_size=1000)
1147+
bulk_create_in_batches(ProblemParagraphMapping, problem_paragraph_mapping_list, batch_size=1000)
11201148

11211149
# Tag
1122-
tag_list = knowledge_data.get("tags", [])
1150+
tag_list = knowledge_data.get("tags", []) if resources is None else []
11231151
if tag_list:
11241152
tag_model_list = []
11251153
tag_key_value_to_model = {}
@@ -1170,6 +1198,9 @@ def import_knowledge(self, file, is_import_tool=False, with_valid=True):
11701198

11711199
update_resource_mapping_by_knowledge(str(knowledge_id))
11721200

1201+
if (knowledge.meta.get("sync_setting") or {}).get("enabled"):
1202+
transaction.on_commit(partial(deploy_knowledge_sync_job, str(knowledge_id)), robust=True)
1203+
11731204
zf.close()
11741205
return {"knowledge_id": str(knowledge_id), "type": knowledge.type}
11751206

‎apps/knowledge/serializers/knowledge_workflow.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,16 @@ def import_(self, instance: dict, is_import_tool, with_valid=True):
584584
)
585585
tool_model_list = [self.to_tool(tool, workspace_id, user_id) for tool in tool_list]
586586
KnowledgeWorkflow.objects.filter(workspace_id=workspace_id, knowledge_id=knowledge_id).update_or_create(
587-
knowledge_id=knowledge_id, workspace_id=workspace_id, defaults={"work_flow": work_flow}
587+
knowledge_id=knowledge_id,
588+
workspace_id=workspace_id,
589+
defaults={
590+
"work_flow": work_flow,
591+
**(
592+
{"default_model_setting": knowledge_workflow["default_model_setting"]}
593+
if "default_model_setting" in knowledge_workflow
594+
else {}
595+
),
596+
},
588597
)
589598

590599
if is_import_tool:

0 commit comments

Comments
 (0)