fix: 更新知识库检索工具,支持 Dify 和 LightRAG 类型,优化结果处理逻辑 Fix Error: query_kb不支持查询dify知识库了?
Fixes #622
This commit is contained in:
parent
d209c58b5d
commit
63786b7c66
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user