fix: 更新知识库检索工具,支持 Dify 和 LightRAG 类型,优化结果处理逻辑 Fix Error: query_kb不支持查询dify知识库了?

Fixes #622
This commit is contained in:
Wenjie Zhang 2026-04-09 23:05:22 +08:00
parent d209c58b5d
commit 63786b7c66
5 changed files with 357 additions and 20 deletions

View File

@ -241,8 +241,6 @@ async def query_kb(kb_name: str, query_text: str, file_name: str | None = None,
metadata = target_info.get("metadata") if isinstance(target_info, dict) else None
kb_type = str((metadata or {}).get("kb_type") or "").strip().lower()
if kb_type != "milvus":
return f"知识库 '{kb_name}' 不是 Milvus 类型,当前 query_kb 仅支持 Milvus"
try:
retriever = target_info["retriever"]
@ -255,11 +253,15 @@ async def query_kb(kb_name: str, query_text: str, file_name: str | None = None,
else:
result = retriever(query_text, **kwargs)
if kb_type != "milvus":
return result
if not isinstance(result, list):
return f"知识库 '{kb_name}' 返回结果不是 Milvus chunks 列表,当前 query_kb 仅支持 Milvus"
return f"知识库 '{kb_name}' 返回结果不是 Milvus chunks 列表,无法注入文件路径"
from yuxi.agents.backends.knowledge_base_backend import inject_filepaths_into_retrieval_result
# 只有 Milvus 结果的 file_id 对应本地文件系统,可补充沙盒可读路径。
return await inject_filepaths_into_retrieval_result(
retrieval_chunks=result,
visible_kbs=visible_kbs,

View File

@ -79,10 +79,61 @@ async def test_query_kb_injects_filepath_into_chunk_metadata(monkeypatch) -> Non
@pytest.mark.asyncio
async def test_query_kb_rejects_non_milvus_knowledge_base(monkeypatch) -> None:
async def test_query_kb_allows_dify_knowledge_base(monkeypatch) -> None:
async def _fake_retriever(query_text: str, **kwargs):
assert query_text == "auth"
return []
return [
{
"content": "auth guide",
"score": 0.98,
"metadata": {
"file_id": "dify-doc-1",
"source": "Dify Doc",
},
}
]
monkeypatch.setattr(
tools.knowledge_base,
"get_retrievers",
lambda: {
"db-1": {
"name": "FAQ",
"retriever": _fake_retriever,
"metadata": {"kb_type": "dify"},
}
},
)
async def _fake_visible_kbs(runtime):
return [{"db_id": "db-1", "name": "FAQ"}]
monkeypatch.setattr(tools, "_resolve_visible_knowledge_bases_for_query", _fake_visible_kbs)
monkeypatch.setattr(
"yuxi.agents.backends.knowledge_base_backend.inject_filepaths_into_retrieval_result",
pytest.fail,
)
runtime = SimpleNamespace(context=SimpleNamespace())
result = await _run_query_kb(kb_name="FAQ", query_text="auth", runtime=runtime)
assert result == [
{
"content": "auth guide",
"score": 0.98,
"metadata": {
"file_id": "dify-doc-1",
"source": "Dify Doc",
},
}
]
@pytest.mark.asyncio
async def test_query_kb_returns_lightrag_result_without_filepath_injection(monkeypatch) -> None:
async def _fake_retriever(query_text: str, **kwargs):
assert query_text == "auth"
return "LightRAG context"
monkeypatch.setattr(
tools.knowledge_base,
@ -100,11 +151,15 @@ async def test_query_kb_rejects_non_milvus_knowledge_base(monkeypatch) -> None:
return [{"db_id": "db-1", "name": "FAQ"}]
monkeypatch.setattr(tools, "_resolve_visible_knowledge_bases_for_query", _fake_visible_kbs)
monkeypatch.setattr(
"yuxi.agents.backends.knowledge_base_backend.inject_filepaths_into_retrieval_result",
pytest.fail,
)
runtime = SimpleNamespace(context=SimpleNamespace())
result = await _run_query_kb(kb_name="FAQ", query_text="auth", runtime=runtime)
assert result == "知识库 'FAQ' 不是 Milvus 类型,当前 query_kb 仅支持 Milvus"
assert result == "LightRAG context"
@pytest.mark.asyncio

View File

@ -45,7 +45,7 @@
- 新增 Skills 远程安装能力Skills 管理页支持填写 `owner/repo` 或 GitHub URL后端通过隔离的临时 `HOME` 调用 `npx skills add` 下载指定 skill再复用现有导入链路写入 `saves/skills` 和数据库,避免将 `~/.agents/skills` 直接作为系统主存储;前端远程安装弹窗补充多选串行安装与批量进度展示,复用现有单 skill 安装接口逐个提交请求
- 调整部门删除语义:删除部门时不再要求用户数为 0而是将部门下用户迁移到默认部门同时清理部门级配置和部门 API Key保证测试部门、撤换部门等场景可直接删除并补充对应集成测试覆盖该链路
- 重构 MCP 运行时配置加载模型:移除 `MCP_SERVERS` 作为运行正确性前提的设计,改为每次直接从数据库读取最新 MCP 配置,并用 `server_name:config_hash` 作为本地工具缓存 key同时将内置 MCP 初始化职责收敛为仅同步数据库默认项,前端 MCP 选项改为直接使用实时资源列表,解决 `api`/`worker` 分进程下的配置不一致与缓存失效问题
- 为知识库检索工具补充 `metadata.filepath` 注入:在 `query_kb` 统一出口基于会话可见知识库构建 `file_id -> /home/gem/kbs/...` 映射并回填检索结果,注入逻辑复用知识库只读后端命名规则;并将工具调用范围收敛为 Milvus仅支持 Milvus chunks 列表且要求显式 `file_id`,不再兼容无显式 `file_id` 的推断注入,新增单测覆盖该约束
- 为知识库检索工具补充 `metadata.filepath` 注入:在 `query_kb` 统一出口基于会话可见知识库构建 `file_id -> /home/gem/kbs/...` 映射并回填 Milvus 检索结果,注入逻辑复用知识库只读后端命名规则;路径注入仅作用于 Milvus chunks 列表Dify 和 LightRAG 等其他知识库保持原检索结果返回,不再兼容无显式 `file_id` 的推断注入,新增单测覆盖该约束
- 调整 Milvus 混合检索实现:集合 schema 增加 Milvus 内置 BM25 稀疏向量字段、BM25 函数和中文 analyzer 配置,`keyword` 模式改为 BM25 全文检索,`hybrid` 模式改为 Milvus 原生向量 + BM25 混合检索,并同步更新检索参数说明。
- 修复 DOCX 解析中的图片回插顺序Docling 导出的多个 `<!-- image -->` 占位符现在按文档图片顺序替换,避免多图文档中的图片链接前后颠倒。
- 修复前端依赖安全告警:通过 `pnpm.overrides` 将传递依赖 `flatted` 锁定到 `3.4.2`、`lodash-es` 锁定到 `4.18.1`,并同步更新 `pnpm-lock.yaml` 以消除 DriftGuard 报告的高危 CVE

View File

@ -11,7 +11,79 @@
</template>
<template #result="{ resultContent }">
<div class="query-kb-result">
<KbResultGroupedList :chunks="parsedData(resultContent)" />
<KbResultGroupedList
v-if="parsedResult(resultContent).chunks.length > 0"
:chunks="parsedResult(resultContent).chunks"
/>
<div v-if="hasGraphData(parsedResult(resultContent))" class="graph-result-card">
<div class="graph-summary">
图谱检索: 实体 {{ parsedResult(resultContent).entities.length }} , 关系
{{ parsedResult(resultContent).relationships.length }} , 引用
{{ parsedResult(resultContent).references.length }}
</div>
<div v-if="parsedResult(resultContent).entities.length > 0" class="graph-section">
<div class="section-title">实体</div>
<div class="entity-list">
<div
v-for="(entity, index) in parsedResult(resultContent).entities"
:key="`entity-${index}-${getEntityName(entity)}`"
class="entity-item"
>
<div class="entity-header">
<span class="entity-name">{{ getEntityName(entity) }}</span>
<span class="entity-type">{{ getEntityType(entity) }}</span>
</div>
<div v-if="entity?.description" class="entity-description">
{{ getPreviewText(entity.description, 220) }}
</div>
</div>
</div>
</div>
<div v-if="parsedResult(resultContent).relationships.length > 0" class="graph-section">
<div class="section-title">关系</div>
<div class="relation-list">
<div
v-for="(relation, index) in parsedResult(resultContent).relationships"
:key="`relation-${index}`"
class="relation-item"
>
<span class="relation-node">{{ relation?.src_id || '-' }}</span>
<span class="relation-arrow"></span>
<span class="relation-node">{{ relation?.tgt_id || '-' }}</span>
<span class="relation-keywords">{{ relation?.keywords || '关联' }}</span>
</div>
</div>
</div>
<div v-if="parsedResult(resultContent).references.length > 0" class="graph-section">
<div class="section-title">引用</div>
<div class="reference-list">
<a
v-for="(reference, index) in parsedResult(resultContent).references"
:key="`reference-${index}`"
class="reference-item"
:href="getReferenceUrl(reference)"
target="_blank"
rel="noopener noreferrer"
>
{{ getReferenceLabel(reference, index) }}
</a>
</div>
</div>
</div>
<div
v-if="
parsedResult(resultContent).chunks.length === 0 &&
!hasGraphData(parsedResult(resultContent))
"
class="no-results"
>
未找到相关知识库内容
</div>
</div>
</template>
</BaseToolCall>
@ -47,18 +119,91 @@ const operationLabel = computed(() => `${toolName.value} 搜索`)
const kbName = computed(() => args.value.kb_name || '')
const queryText = computed(() => args.value.query_text || '')
const parseData = (content) => {
if (typeof content === 'string') {
try {
return JSON.parse(content)
} catch {
return []
}
}
return content || []
const EMPTY_RESULT = Object.freeze({
chunks: [],
entities: [],
relationships: [],
references: []
})
let lastResultContent = null
let lastParsedResult = EMPTY_RESULT
const normalizeChunks = (payload) => {
if (Array.isArray(payload)) return payload
if (!payload || typeof payload !== 'object') return []
if (Array.isArray(payload.chunks)) return payload.chunks
if (Array.isArray(payload.data?.chunks)) return payload.data.chunks
return []
}
const parsedData = (content) => parseData(content)
const parseResult = (content) => {
if (content === lastResultContent) return lastParsedResult
let payload = content
if (typeof content === 'string') {
try {
payload = JSON.parse(content)
} catch {
lastResultContent = content
lastParsedResult = EMPTY_RESULT
return lastParsedResult
}
}
if (!payload || typeof payload !== 'object') {
lastResultContent = content
lastParsedResult = EMPTY_RESULT
return lastParsedResult
}
// Milvus chunks LightRAG graph/all
const nextResult = {
chunks: normalizeChunks(payload),
entities: Array.isArray(payload.entities)
? payload.entities
: Array.isArray(payload.data?.entities)
? payload.data.entities
: [],
relationships: Array.isArray(payload.relationships)
? payload.relationships
: Array.isArray(payload.data?.relationships)
? payload.data.relationships
: [],
references: Array.isArray(payload.references)
? payload.references
: Array.isArray(payload.data?.references)
? payload.data.references
: []
}
lastResultContent = content
lastParsedResult = nextResult
return nextResult
}
const parsedResult = (content) => parseResult(content)
const hasGraphData = (result) =>
result.entities.length > 0 || result.relationships.length > 0 || result.references.length > 0
const getEntityName = (entity) => entity?.entity_name || entity?.name || '未命名实体'
const getEntityType = (entity) => entity?.entity_type || entity?.type || '未分类'
const getPreviewText = (text = '', maxLength = 100) => {
const normalized = String(text)
return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength)}...`
}
const getReferenceUrl = (reference) => reference?.file_path || reference?.url || '#'
const getReferenceLabel = (reference, index) => {
const referenceId = reference?.reference_id || `#${index + 1}`
const url = getReferenceUrl(reference)
return `${referenceId}: ${url}`
}
</script>
<style scoped lang="less">
@ -66,5 +211,130 @@ const parsedData = (content) => parseData(content)
background: var(--gray-0);
border-radius: 8px;
padding: 4px;
.graph-result-card {
border: 1px solid var(--gray-150);
border-radius: 8px;
overflow: hidden;
.graph-summary {
padding: 10px 12px;
background: var(--gray-25);
font-size: 12px;
color: var(--gray-700);
border-bottom: 1px solid var(--gray-100);
}
.graph-section {
padding: 10px 12px;
border-bottom: 1px solid var(--gray-100);
&:last-child {
border-bottom: none;
}
.section-title {
font-size: 12px;
font-weight: 600;
color: var(--gray-700);
margin-bottom: 8px;
}
}
.entity-list {
display: flex;
flex-direction: column;
gap: 8px;
.entity-item {
border: 1px solid var(--gray-150);
border-radius: 6px;
padding: 8px;
.entity-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
.entity-name {
font-size: 13px;
color: var(--gray-700);
font-weight: 600;
}
.entity-type {
font-size: 11px;
color: var(--gray-600);
background: var(--gray-25);
border-radius: 4px;
padding: 1px 6px;
}
}
.entity-description {
font-size: 12px;
line-height: 1.5;
color: var(--gray-700);
white-space: pre-wrap;
}
}
}
.relation-list {
display: flex;
flex-direction: column;
gap: 6px;
.relation-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--gray-700);
.relation-node {
background: var(--gray-25);
border-radius: 4px;
padding: 1px 6px;
}
.relation-arrow {
color: var(--gray-500);
}
.relation-keywords {
color: var(--gray-600);
margin-left: auto;
font-size: 11px;
}
}
}
.reference-list {
display: flex;
flex-direction: column;
gap: 6px;
.reference-item {
font-size: 12px;
color: var(--main-700);
text-decoration: none;
word-break: break-all;
&:hover {
text-decoration: underline;
}
}
}
}
.no-results {
border: 1px solid var(--gray-150);
border-radius: 8px;
padding: 10px 12px;
font-size: 12px;
color: var(--gray-600);
}
}
</style>

View File

@ -68,7 +68,7 @@ import KbChunkDetailModal from './KbChunkDetailModal.vue'
const props = defineProps({
chunks: {
type: Array,
type: [Array, Object],
default: () => []
},
showSummary: {
@ -86,8 +86,18 @@ const modalVisible = ref(false)
const selectedChunk = ref(null)
const selectedChunkIndex = ref(null)
const resolveChunks = (input) => {
if (Array.isArray(input)) return input
if (!input || typeof input !== 'object') return []
if (Array.isArray(input.chunks)) return input.chunks
if (Array.isArray(input.data?.chunks)) return input.data.chunks
return []
}
const normalizedChunks = computed(() =>
(props.chunks || []).filter((item) => item && typeof item === 'object' && item.content)
resolveChunks(props.chunks).filter((item) => item && typeof item === 'object' && item.content)
)
const fileGroupList = computed(() => {