Improve LightRAG knowledge search locator alignment

This commit is contained in:
lix-2026
2026-06-08 20:35:49 +08:00
parent 9551d4c1dc
commit 0e8b03daf8
28 changed files with 5769 additions and 140 deletions
@@ -112,7 +112,8 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
.collect::<Vec<_>>()
})
.unwrap_or_default();
let citations = references
let citation_references = citation_references_for_ui(&references);
let citations = citation_references
.iter()
.filter_map(|reference| {
reference
@@ -122,16 +123,23 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
.map(ToOwned::to_owned)
})
.collect::<Vec<_>>();
let ui_citations = citations
.iter()
.map(|citation| json!({ "citationMarkdown": citation }))
.collect::<Vec<_>>();
json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": "mnote.knowledge_rag.agent_query_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
"answerGuidance": "Final answers must cite at least one returned citationMarkdown verbatim. If locatorDegraded is true, say the source location is degraded instead of inventing page or bbox.",
"answerGuidance": "Final answers should answer the user in plain text. Do not copy citationMarkdown, citationUrl, /documents, mnote://, or search-engine wrapped local links into the answer. MNote UI will render citations[] / references[].citationMarkdown after the answer as clickable source locators. Treat rawMetadata.keywords and entity/relation counts only as query analysis, not proof that a source contains those words. Use references[].quote/contentDiagnostics as evidence. If locatorDegraded is true, say the source location is degraded instead of inventing page or bbox. If contentDiagnostics.ocrTextExposed is false, say OCR text was not exposed in the returned reference instead of claiming OCR succeeded.",
"references": references,
"citations": citations,
"uiCitations": ui_citations,
"citationRendering": "MNote UI appends uiCitations/citations after the final answer; agent must not hand-write citation links.",
"sourceScope": payload.get("sourceScope").cloned().unwrap_or(Value::Array(Vec::new())),
"sourceScopeMode": payload.get("sourceScopeMode").cloned().unwrap_or_else(|| json!("post_filter_mapped_references")),
"rawScopeFiltered": payload.get("rawScopeFiltered").cloned().unwrap_or(Value::Bool(false)),
"rawMetadataMeaning": "provider metadata describes query processing/retrieval bookkeeping; it is not source text evidence unless the same text appears in references[].quote",
"rawStatus": payload.pointer("/raw/status").cloned().unwrap_or(Value::Null),
"rawMessage": payload.pointer("/raw/message").cloned().unwrap_or(Value::Null),
"rawMetadata": payload.pointer("/raw/metadata").cloned().unwrap_or(Value::Null),
@@ -144,6 +152,10 @@ fn compact_reference_for_agent(reference: &Value) -> Value {
.and_then(Value::as_str)
.map(|value| value.chars().take(700).collect::<String>())
.unwrap_or_default();
let quote_diagnostics = reference
.get("contentDiagnostics")
.cloned()
.unwrap_or_else(|| quote_diagnostics(&quote));
json!({
"schema": reference.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.reference.v1")),
"provider": reference.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
@@ -151,13 +163,55 @@ fn compact_reference_for_agent(reference: &Value) -> Value {
"sourceRootRelativePath": reference.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
"chunkId": reference.get("chunkId").cloned().unwrap_or(Value::Null),
"quote": quote,
"quoteSource": reference.get("quoteSource").cloned().unwrap_or(Value::Null),
"locator": reference.get("locator").cloned().unwrap_or(Value::Null),
"locatorDegraded": reference.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
"contentDiagnostics": quote_diagnostics,
"citationMarkdown": reference.get("citationMarkdown").cloned().unwrap_or(Value::Null),
"citationUrl": reference.get("citationUrl").cloned().unwrap_or(Value::Null),
})
}
fn citation_references_for_ui(references: &[Value]) -> Vec<&Value> {
let has_precise = references.iter().any(|reference| {
reference
.get("citationMarkdown")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
&& reference.get("locatorDegraded").and_then(Value::as_bool) != Some(true)
});
references
.iter()
.filter(|reference| {
reference
.get("citationMarkdown")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
&& (!has_precise
|| reference.get("locatorDegraded").and_then(Value::as_bool) != Some(true))
})
.collect()
}
fn quote_diagnostics(quote: &str) -> Value {
let meaningful_lines = quote
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.filter(|line| !line.starts_with('#'))
.filter(|line| !is_markdown_image_line(line))
.collect::<Vec<_>>();
json!({
"quoteEmpty": quote.trim().is_empty(),
"quoteOnlyImagePlaceholder": !quote.trim().is_empty() && meaningful_lines.is_empty(),
"ocrTextExposed": !meaningful_lines.is_empty(),
})
}
fn is_markdown_image_line(line: &str) -> bool {
line.starts_with("![") && line.contains("](") && line.ends_with(')')
}
#[cfg(test)]
mod tests {
use super::*;
@@ -180,6 +234,11 @@ mod tests {
"quote": "scoped quote",
"locatorDegraded": true,
"citationMarkdown": "[来源定位降级:docs/a.md](/documents/local-md:docs~2Fa.md)"
}, {
"sourceRootRelativePath": "docs/b.md",
"quote": "precise quote",
"locatorDegraded": false,
"citationMarkdown": "[docs/b.md · p.2](/documents/local-md:docs~2Fb.md?page=2)"
}]
});
@@ -191,9 +250,47 @@ mod tests {
assert_eq!(compact["rawScopeFiltered"].as_bool(), Some(false));
assert!(compact.get("raw").is_none());
assert!(compact.get("chunks").is_none());
assert_eq!(
compact["rawMetadataMeaning"].as_str(),
Some("provider metadata describes query processing/retrieval bookkeeping; it is not source text evidence unless the same text appears in references[].quote")
);
assert!(compact["answerGuidance"]
.as_str()
.unwrap_or_default()
.contains("Do not copy citationMarkdown"));
assert_eq!(
compact["uiCitations"][0]["citationMarkdown"].as_str(),
Some("[docs/b.md · p.2](/documents/local-md:docs~2Fb.md?page=2)")
);
assert_eq!(compact["uiCitations"].as_array().unwrap().len(), 1);
assert_eq!(
compact["references"][0]["sourceRootRelativePath"].as_str(),
Some("docs/a.md")
);
}
#[test]
fn compact_reference_marks_image_placeholder_as_not_ocr_text() {
let payload = json!({
"references": [{
"sourceRootRelativePath": "docs/image.png",
"quote": "# image.png\n\n![image.png](<image.png>)",
"quoteSource": "chunk",
"locatorDegraded": true,
"citationMarkdown": "[来源定位降级:image.png](/documents/local-md:docs~2FPage.md)"
}]
});
let compact = compact_query_result_for_agent(payload);
let diagnostics = &compact["references"][0]["contentDiagnostics"];
assert_eq!(
diagnostics["quoteOnlyImagePlaceholder"].as_bool(),
Some(true)
);
assert_eq!(diagnostics["ocrTextExposed"].as_bool(), Some(false));
assert_eq!(
compact["references"][0]["quoteSource"].as_str(),
Some("chunk")
);
}
}
@@ -441,7 +441,7 @@ fn knowledge_rag_query_tool() -> Value {
}
json!({
"name": "mnote.knowledge_rag.query",
"description": "向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的引用",
"description": "向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",