Skip to content

Commit 6b433a1

Browse files
committed
feat: enrich paragraph sources with document metadata and update related tests
1 parent 6933341 commit 6b433a1

14 files changed

Lines changed: 335 additions & 84 deletions

File tree

‎apps/application/serializers/application_chat_record.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,13 @@
2525
from common.utils.common import post
2626
from django.db import transaction
2727
from django.db.models import QuerySet
28-
from django.db.models.aggregates import Max, Min
28+
from django.db.models.aggregates import Max
2929
from django.utils.translation import gettext
3030
from django.utils.translation import gettext_lazy as _
3131
from knowledge.models import Document, Knowledge, Paragraph, Problem, ProblemParagraphMapping
3232
from knowledge.serializers.common import get_embedding_model_id_by_knowledge_id, update_document_char_length
3333
from knowledge.serializers.paragraph import ParagraphSerializers
34+
from knowledge.services.multimodal_retrieval import enrich_paragraph_sources
3435
from knowledge.task.embedding import embedding_by_paragraph, embedding_by_paragraph_list
3536
from rest_framework import serializers
3637
from rest_framework.utils.formatting import lazy_format
@@ -188,6 +189,8 @@ def reset_chat_record(chat_record, show_source, show_exec):
188189
if "document_id" in (rl.get("metadata") or {}) and "knowledge_id" in (rl.get("metadata") or {})
189190
]
190191
paragraph_list = list({p.get("id"): p for p in paragraph_list}.values())
192+
if show_source:
193+
paragraph_list = enrich_paragraph_sources(paragraph_list)
191194
knowledge_list = knowledge_list + [
192195
{"id": knowledge_id, **knowledge}
193196
for knowledge_id, knowledge in reduce(
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from types import SimpleNamespace
2+
from unittest.mock import patch
3+
4+
from django.test import SimpleTestCase
5+
6+
from application.serializers.application_chat_record import ApplicationChatRecordQuerySerializers
7+
from knowledge.models import DocumentResourceType, KnowledgeType
8+
9+
10+
class ChatRecordSourceTests(SimpleTestCase):
11+
def setUp(self):
12+
self.paragraph = {"id": "paragraph-1", "document_id": "document-1", "knowledge_id": "knowledge-1"}
13+
self.record = SimpleNamespace(
14+
details={
15+
"search": {"type": "search-knowledge-node", "show_knowledge": True, "paragraph_list": [self.paragraph]}
16+
},
17+
improve_paragraph_id_list=[],
18+
)
19+
serializer = patch("application.serializers.application_chat_record.ChatRecordSerializerModel")
20+
serializer.start().return_value.data = {}
21+
self.addCleanup(serializer.stop)
22+
23+
@patch("knowledge.services.multimodal_retrieval.QuerySet")
24+
def test_legacy_chat_sources_include_document_metadata_without_changing_snapshot(self, query_set):
25+
query_set.return_value.filter.return_value.values_list.return_value = [
26+
("document-1", KnowledgeType.WORKFLOW, DocumentResourceType.IMAGE, "original-image-file")
27+
]
28+
29+
result = ApplicationChatRecordQuerySerializers.reset_chat_record(self.record, True, False)
30+
31+
self.assertEqual(result["paragraph_list"][0]["knowledge_type"], KnowledgeType.WORKFLOW)
32+
self.assertEqual(result["paragraph_list"][0]["document_resource_type"], DocumentResourceType.IMAGE)
33+
self.assertEqual(result["paragraph_list"][0]["document_image_id"], "original-image-file")
34+
self.assertEqual(result["knowledge_list"][0]["knowledge_type"], KnowledgeType.WORKFLOW)
35+
self.assertNotIn("document_image_id", self.paragraph)
36+
37+
@patch("knowledge.services.multimodal_retrieval.QuerySet")
38+
def test_hidden_sources_do_not_load_metadata(self, query_set):
39+
result = ApplicationChatRecordQuerySerializers.reset_chat_record(self.record, False, False)
40+
41+
self.assertNotIn("paragraph_list", result)
42+
query_set.assert_not_called()
43+
44+
@patch("knowledge.services.multimodal_retrieval.QuerySet")
45+
def test_reranker_sources_include_metadata(self, query_set):
46+
self.record.details = {
47+
"rerank": {"type": "reranker-node", "show_knowledge": True, "result_list": [{"metadata": self.paragraph}]}
48+
}
49+
query_set.return_value.filter.return_value.values_list.return_value = [
50+
("document-1", KnowledgeType.LARK, DocumentResourceType.DOCUMENT, "ordinary-file")
51+
]
52+
53+
result = ApplicationChatRecordQuerySerializers.reset_chat_record(self.record, True, False)
54+
55+
self.assertEqual(result["paragraph_list"][0]["knowledge_type"], KnowledgeType.LARK)
56+
self.assertEqual(result["paragraph_list"][0]["document_resource_type"], DocumentResourceType.DOCUMENT)
57+
self.assertIsNone(result["paragraph_list"][0]["document_image_id"])

‎apps/application/tests/test_workflow_nodes.py‎

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
from application.workflow.nodes.data_source_web_node.data_source_web_node import DataSourceWebNode
99
from application.workflow.nodes.search_knowledge_node.search_knowledge_node import (
1010
_get_recalled_image_list,
11+
_list_paragraph,
1112
_record_recalled_items,
1213
_reset_paragraph,
1314
)
14-
from knowledge.models import SourceType
15+
from knowledge.models import Document, DocumentResourceType, Knowledge, KnowledgeType, SourceType
16+
from application.workflow.nodes.search_document_node.search_document_node import SearchDocumentNode
1517
from common.utils.fork import ChildLink, Fork
1618

1719

@@ -26,6 +28,21 @@ def test_failed_web_fetch_fails_workflow_instead_of_returning_an_empty_snapshot(
2628

2729

2830
class SearchKnowledgeNodeTests(SimpleTestCase):
31+
@patch("knowledge.services.multimodal_retrieval.QuerySet")
32+
@patch("application.workflow.nodes.search_knowledge_node.search_knowledge_node.native_search")
33+
def test_workflow_paragraph_query_includes_source_fields(self, native_search, query_set):
34+
paragraph_id = "00000000-0000-0000-0000-000000000001"
35+
native_search.return_value = [{"id": paragraph_id, "document_id": "image-document"}]
36+
query_set.return_value.filter.return_value.values_list.return_value = [
37+
("image-document", KnowledgeType.BASE, DocumentResourceType.IMAGE, "original-image-file")
38+
]
39+
40+
result = _list_paragraph([{"paragraph_id": paragraph_id}], MagicMock())
41+
42+
self.assertEqual(result[0]["knowledge_type"], KnowledgeType.BASE)
43+
self.assertEqual(result[0]["document_resource_type"], DocumentResourceType.IMAGE)
44+
self.assertEqual(result[0]["document_image_id"], "original-image-file")
45+
2946
def test_image_hit_metadata_is_attached_to_recalled_paragraph(self):
3047
paragraph_id = "00000000-0000-0000-0000-000000000001"
3148
asset_id = "00000000-0000-0000-0000-000000000002"
@@ -40,6 +57,9 @@ def test_image_hit_metadata_is_attached_to_recalled_paragraph(self):
4057
"update_time": created_at,
4158
"create_time": created_at,
4259
"meta": {},
60+
"knowledge_type": KnowledgeType.BASE,
61+
"document_resource_type": DocumentResourceType.IMAGE,
62+
"document_image_id": file_id,
4363
}
4464
embedding = {
4565
"paragraph_id": paragraph_id,
@@ -57,6 +77,9 @@ def test_image_hit_metadata_is_attached_to_recalled_paragraph(self):
5777
self.assertEqual(result["hit_unit_type"], "image")
5878
self.assertEqual(result["hit_asset"], asset)
5979
self.assertEqual(result["comprehensive_score"], 0.93)
80+
self.assertEqual(result["knowledge_type"], KnowledgeType.BASE)
81+
self.assertEqual(result["document_resource_type"], DocumentResourceType.IMAGE)
82+
self.assertEqual(result["document_image_id"], file_id)
6083
self.assertEqual(_get_recalled_image_list([result, result]), [asset])
6184

6285
def test_image_text_hit_adds_visual_text_to_retrieval_context(self):
@@ -117,6 +140,48 @@ def test_does_not_record_recall_in_debug_mode(self, record_recall):
117140
record_recall.assert_not_called()
118141

119142

143+
class SearchDocumentNodeSourceTests(SimpleTestCase):
144+
@patch(
145+
"application.workflow.nodes.search_document_node.search_document_node.filter_workflow_knowledge",
146+
return_value=["knowledge-1"],
147+
)
148+
@patch(
149+
"application.workflow.nodes.search_document_node.search_document_node.filter_authorized_ids",
150+
return_value=["knowledge-1"],
151+
)
152+
@patch("application.workflow.nodes.search_document_node.search_document_node.QuerySet")
153+
def test_document_search_returns_source_fields_using_current_knowledge_type(
154+
self, query_set, _authorized, _workflow
155+
):
156+
documents = MagicMock()
157+
knowledge = MagicMock()
158+
query_set.side_effect = {Document: documents, Knowledge: knowledge}.get
159+
documents.filter.return_value.values_list.return_value.__iter__.return_value = iter(["document-1"])
160+
documents.filter.return_value.values_list.return_value.distinct.return_value = ["knowledge-1"]
161+
documents.filter.return_value.values.return_value = [
162+
{
163+
"id": "document-1",
164+
"knowledge_id": "knowledge-1",
165+
"type": KnowledgeType.BASE,
166+
"resource_type": DocumentResourceType.IMAGE,
167+
"meta": {"source_file_id": "original-image-file"},
168+
}
169+
]
170+
knowledge.filter.return_value.values.return_value = [{"id": "knowledge-1", "type": KnowledgeType.WEB}]
171+
node = SearchDocumentNode.__new__(SearchDocumentNode)
172+
node.get_parameters = MagicMock(return_value={"knowledge_id_list": ["knowledge-1"], "search_mode": "custom"})
173+
node.get_workflow_parameters = MagicMock(return_value={"workspace_id": "default"})
174+
node.workflow_manage = MagicMock()
175+
node.write_context = MagicMock()
176+
177+
node.execute()
178+
179+
output = next(call.args[1] for call in node.write_context.call_args_list if call.args[0] == "document_items")
180+
self.assertEqual(output[0]["knowledge_type"], KnowledgeType.WEB)
181+
self.assertEqual(output[0]["document_resource_type"], DocumentResourceType.IMAGE)
182+
self.assertEqual(output[0]["document_image_id"], "original-image-file")
183+
184+
120185
class AIChatKnowledgeImageTests(SimpleTestCase):
121186
def setUp(self):
122187
search_node = SimpleNamespace(id="search", type="search-knowledge-node")

‎apps/application/workflow/nodes/search_document_node/search_document_node.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from application.workflow.i_node import INode
1616
from common.utils.shared_resource_auth import filter_authorized_ids
1717
from knowledge.models import Document, DocumentTag, Knowledge
18+
from knowledge.services.multimodal_retrieval import get_retrieval_source_metadata
1819
from knowledge.services.retrieval_access import filter_workflow_knowledge
1920

2021

@@ -246,6 +247,18 @@ def execute(self):
246247
final_document_ids = [str(doc["id"]) for doc in document_items]
247248
final_knowledge_ids = list(set(str(doc["knowledge_id"]) for doc in document_items))
248249
knowledge_items = list(QuerySet(Knowledge).filter(id__in=final_knowledge_ids).values())
250+
knowledge_types = {str(knowledge["id"]): knowledge["type"] for knowledge in knowledge_items}
251+
document_items = [
252+
{
253+
**document,
254+
**get_retrieval_source_metadata(
255+
knowledge_types.get(str(document["knowledge_id"])),
256+
document["resource_type"],
257+
(document.get("meta") or {}).get("source_file_id"),
258+
),
259+
}
260+
for document in document_items
261+
]
249262

250263
self.write_context("document_list", final_document_ids)
251264
self.write_context("document_items", _serialize_items(document_items))

‎apps/application/workflow/nodes/search_knowledge_node/search_knowledge_node.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from common.utils.common import flat_map, get_file_content
2525
from common.utils.shared_resource_auth import filter_authorized_ids
2626
from knowledge.models import Document, Paragraph, Knowledge, SearchMode, SourceType
27-
from knowledge.services.multimodal_retrieval import get_hit_asset_map
27+
from knowledge.services.multimodal_retrieval import enrich_paragraph_sources, get_hit_asset_map
2828
from knowledge.services.retrieval_stats import get_recall_tracker, record_recall_safely
2929
from knowledge.services.retrieval_access import filter_workflow_knowledge
3030
from maxkb.conf import PROJECT_DIR
@@ -202,7 +202,7 @@ def _list_paragraph(embedding_list: List, vector, knowledge_ids=None, document_i
202202
for paragraph_id in paragraph_id_list:
203203
if paragraph_id not in exist_paragraph_list:
204204
vector.delete_by_paragraph_id(paragraph_id)
205-
return paragraph_list
205+
return enrich_paragraph_sources(paragraph_list)
206206

207207

208208
class SearchKnowledgeNode(INode):

‎apps/knowledge/EXTERNAL_RETRIEVAL.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ curl "$BASE_URL/chat/api/v3/knowledge/$KNOWLEDGE_ID/retrieve" \
4848
similarity 为 0–1(默认 0),search_mode 支持 embedding、keywords、blend(默认 embedding)。
4949
检索限定当前知识库,返回 `knowledge_id` 和 `hits`,命中包含段落正文(最多 8000 字符)、
5050
分数、来源与 `citation`(知识库、文档、段落 ID 和名称);不生成回答。
51+
每个命中同时包含 `knowledge_type`(KnowledgeType 数字枚举)、`document_resource_type`(`image` 或 `document`)
52+
和 `document_image_id`。图片文档的 `document_image_id` 为文档原图文件 ID,普通文档或缺少原图引用时为 `null`;
53+
这些字段与命中向量类型独立,REST 与 MCP 返回相同结果结构。
5154
过滤跨库、停用或失效段落,仅对实际返回命中累计原有召回统计。正文中的附件沿用现有文件访问规则。
5255

5356
请求体最大 64 KiB。400 为参数错误、401 为缺少/无效凭据、403 为无权限/不允许的 Origin、

‎apps/knowledge/README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,11 @@ the external vision model or tool runs in the worker.
9393
使用相同字段;此字段与命中向量类型无关,普通文档中的插图命中仍返回 `document`。
9494
图片文档的结果额外包含 `document_image_id`,取自文档的 `meta.source_file_id`,用于通过现有文件接口显示原图。
9595
该字段不依赖命中的是文本还是图片向量;普通文档以及缺少原文件引用的图片文档返回 `null`。
96+
每条结果同时返回所属知识库当前的 `knowledge_type`,沿用 `KnowledgeType` 数字枚举:
97+
`0` 通用、`1` Web 站点、`2` 飞书、`3` 语雀、`4` 工作流。
98+
99+
上述三个来源字段也适用于问题关联段落、工作流知识库检索的 `paragraph_list`、
100+
文档检索节点的 `document_items`、聊天记录查询的来源 `paragraph_list`,以及 REST/MCP 外部检索的 `hits`。
101+
知识库类型取自所属知识库,图片 ID 取自所属文档的原图引用,与命中向量或普通文档内的插图无关。
102+
段落来源通过一次批量查询补齐;已包含完整来源字段的聊天快照直接复用,旧快照缺少字段时在读取时补齐,
103+
不修改已保存的聊天记录。隐藏来源时不加载这些元数据。文档已删除时保留快照中的已知字段,缺少的图片 ID 为 `null`。

‎apps/knowledge/serializers/common.py‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,13 @@
2929
from common.utils.common import get_file_content
3030
from common.utils.fork import Fork
3131
from common.utils.logger import maxkb_logger
32-
from knowledge.models import Document, DocumentResourceType, KnowledgeWorkflow, KnowledgeWorkflowVersion, KnowledgeType
32+
from knowledge.models import Document, DocumentResourceType, KnowledgeWorkflow, KnowledgeType
3333
from knowledge.models import Paragraph, Problem, ProblemParagraphMapping, Knowledge, File
3434
from knowledge.serializers.document_strategy import DocumentStrategySerializer
35+
from knowledge.services.multimodal_retrieval import enrich_paragraph_sources
3536
from maxkb.conf import PROJECT_DIR
3637
from models_provider.tools import get_model, get_model_default_params
37-
from system_manage.models.resource_mapping import ResourceMapping, ResourceType
38+
from system_manage.models.resource_mapping import ResourceType
3839

3940

4041
class MetaSerializer(serializers.Serializer):
@@ -242,9 +243,11 @@ def update_document_char_length(document_id: str):
242243
def list_paragraph(paragraph_list: List[str]):
243244
if paragraph_list is None or len(paragraph_list) == 0:
244245
return []
245-
return native_search(
246-
QuerySet(Paragraph).filter(id__in=paragraph_list),
247-
get_file_content(os.path.join(PROJECT_DIR, "apps", "knowledge", "sql", "list_paragraph.sql")),
246+
return enrich_paragraph_sources(
247+
native_search(
248+
QuerySet(Paragraph).filter(id__in=paragraph_list),
249+
get_file_content(os.path.join(PROJECT_DIR, "apps", "knowledge", "sql", "list_paragraph.sql")),
250+
)
248251
)
249252

250253

‎apps/knowledge/serializers/knowledge.py‎

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@
4646

4747
from knowledge.models import (
4848
Document,
49-
DocumentResourceType,
5049
DocumentTag,
5150
File,
5251
FileSourceType,
@@ -1448,20 +1447,12 @@ def hit_test(self):
14481447
str(paragraph.get("id")): paragraph
14491448
for paragraph in list_paragraph([hit.get("paragraph_id") for hit in hit_list])
14501449
}
1451-
document_map = {
1452-
str(document_id): {"resource_type": resource_type, "source_file_id": source_file_id}
1453-
for document_id, resource_type, source_file_id in QuerySet(Document)
1454-
.filter(id__in={paragraph.get("document_id") for paragraph in paragraph_dict.values()})
1455-
.values_list("id", "resource_type", "meta__source_file_id")
1456-
}
14571450
hit_asset_map = get_hit_asset_map(hit_list)
14581451
result_list = []
14591452
for hit in hit_list:
14601453
paragraph = paragraph_dict.get(str(hit.get("paragraph_id")))
14611454
if paragraph is None:
14621455
continue
1463-
document = document_map.get(str(paragraph.get("document_id")), {})
1464-
document_resource_type = document.get("resource_type", DocumentResourceType.DOCUMENT)
14651456
current_hit = hit
14661457
source_id = current_hit.get("source_id")
14671458
source_type = current_hit.get("source_type")
@@ -1470,10 +1461,6 @@ def hit_test(self):
14701461
result_list.append(
14711462
{
14721463
**paragraph,
1473-
"document_resource_type": document_resource_type,
1474-
"document_image_id": document.get("source_file_id")
1475-
if document_resource_type == DocumentResourceType.IMAGE
1476-
else None,
14771464
"similarity": current_hit.get("similarity"),
14781465
"comprehensive_score": current_hit.get("comprehensive_score"),
14791466
"source_id": source_id,

‎apps/knowledge/serializers/problem.py‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from common.utils.common import get_file_content
1414
from knowledge.models import Problem, ProblemParagraphMapping, Paragraph, Knowledge, SourceType
1515
from knowledge.serializers.common import get_embedding_model_id_by_knowledge_id
16+
from knowledge.services.multimodal_retrieval import enrich_paragraph_sources
1617
from knowledge.task.embedding import delete_embedding_by_source_ids, update_problem_embedding, embedding_by_data_list
1718
from maxkb.const import PROJECT_DIR
1819

@@ -175,14 +176,16 @@ def list_paragraph(self, with_valid=True):
175176
)
176177
if problem_paragraph_mapping is None or len(problem_paragraph_mapping) == 0:
177178
return []
178-
return native_search(
179-
QuerySet(Paragraph).filter(
180-
knowledge_id=self.data.get("knowledge_id"),
181-
id__in=[row.paragraph_id for row in problem_paragraph_mapping],
182-
),
183-
select_string=get_file_content(
184-
os.path.join(PROJECT_DIR, "apps", "knowledge", "sql", "list_paragraph.sql")
185-
),
179+
return enrich_paragraph_sources(
180+
native_search(
181+
QuerySet(Paragraph).filter(
182+
knowledge_id=self.data.get("knowledge_id"),
183+
id__in=[row.paragraph_id for row in problem_paragraph_mapping],
184+
),
185+
select_string=get_file_content(
186+
os.path.join(PROJECT_DIR, "apps", "knowledge", "sql", "list_paragraph.sql")
187+
),
188+
)
186189
)
187190

188191
def one(self, with_valid=True):

0 commit comments

Comments
 (0)