fix knowledge rag docx sidecar locator

This commit is contained in:
lix-2026
2026-06-09 18:40:48 +08:00
parent 516121e6f9
commit 6ef233772e
6 changed files with 338 additions and 194 deletions
@@ -1020,7 +1020,8 @@ export const createResourceTabRuntime = (dependencies = {}) => {
if (!(frame instanceof HTMLIFrameElement) || !locator) return;
try {
const url = new URL(frame.getAttribute('src') || frame.src || '', window.location.origin);
if (Number.isFinite(Number(locator.page))) url.searchParams.set('page', String(Number(locator.page)));
const locatorPage = Number(locator.page);
if (Number.isFinite(locatorPage) && locatorPage > 0) url.searchParams.set('page', String(locatorPage));
const bbox = evidenceBBoxParam(locator.bbox);
if (bbox) url.searchParams.set('bbox', bbox);
if (locator.sourceMapPath) url.searchParams.set('sourceMapPath', String(locator.sourceMapPath));
@@ -1129,7 +1130,8 @@ export const createResourceTabRuntime = (dependencies = {}) => {
entry.evidenceLocator = locator;
entry.panel.setAttribute('data-mnote-evidence-locator', JSON.stringify(locator));
entry.panel.setAttribute('data-mnote-evidence-open', 'true');
if (Number.isFinite(Number(locator.page))) entry.panel.setAttribute('data-mnote-evidence-page', String(Number(locator.page)));
const locatorPage = Number(locator.page);
if (Number.isFinite(locatorPage) && locatorPage > 0) entry.panel.setAttribute('data-mnote-evidence-page', String(locatorPage));
if (locator.blockId) entry.panel.setAttribute('data-mnote-evidence-block-id', String(locator.blockId));
if (locator.evidenceText) entry.panel.setAttribute('data-mnote-evidence-text', String(locator.evidenceText).slice(0, 240));
if (locator.sourceMapPath) entry.panel.setAttribute('data-mnote-evidence-source-map-path', String(locator.sourceMapPath));
@@ -2323,7 +2325,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
if (!raw) return '';
try {
const url = new URL(raw, window.location.origin);
['page', 'bbox', 'sourceMapPath', 'blockId', 'lineRange', 'charRange', 'evidenceText', 'query', 'searchQuery', 'mnoteResourceReload'].forEach((key) => {
['page', 'bbox', 'sourceMapPath', 'blockId', 'lineRange', 'charRange', 'paragraphOrdinal', 'paraIdStart', 'paraIdEnd', 'textFingerprint', 'evidenceText', 'query', 'searchQuery', 'mnoteResourceReload'].forEach((key) => {
url.searchParams.delete(key);
});
return url.pathname + '?' + url.searchParams.toString();
@@ -317,6 +317,12 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
sourceMapPath: String(input && input.sourceMapPath || '').trim(),
blockId: String(input && input.blockId || '').trim(),
evidenceText: String(input && input.evidenceText || '').trim(),
query: String(input && input.query || '').trim(),
searchQuery: String(input && input.searchQuery || '').trim(),
paragraphOrdinal: String(input && input.paragraphOrdinal || '').trim(),
paraIdStart: String(input && input.paraIdStart || '').trim(),
paraIdEnd: String(input && input.paraIdEnd || '').trim(),
textFingerprint: String(input && input.textFingerprint || '').trim(),
lineRange: input && input.lineRange,
charRange: input && input.charRange
});
+226 -165
View File
@@ -619,6 +619,7 @@ pub async fn search(
"limit": body.top_k.or(body.chunk_top_k).unwrap_or(50),
"max_per_chunk": 12,
"include_chunk_content": body.include_chunk_content.unwrap_or(true),
"include_sidecar": true,
})),
true,
&context,
@@ -1597,38 +1598,51 @@ async fn lightrag_json(
WebError::internal(format!("LightRAG HTTP client 初始化失败: {error}"))
.with_context(context)
})?;
let mut request = client.request(method, &url);
if use_api_key {
if let Some(api_key) = lightrag_api_key() {
let api_key = use_api_key.then(lightrag_api_key).flatten();
for attempt in 0..2 {
let mut request = client.request(method.clone(), &url);
if let Some(api_key) = api_key.as_deref() {
request = request.header("X-API-Key", api_key);
}
if let Some(body) = body.as_ref() {
request = request.json(body);
}
let response = request.send().await.map_err(|error| {
WebError::bad_gateway_code(
"knowledge_rag_lightrag_unreachable",
format!("无法访问 LightRAG provider: {error}"),
)
.with_context(context)
})?;
let status = response.status();
let text = response.text().await.unwrap_or_default();
if !status.is_success() {
if attempt == 0 && should_retry_lightrag_provider_error(status, &text) {
tokio::time::sleep(Duration::from_millis(700)).await;
continue;
}
return Err(WebError::bad_gateway_code(
"knowledge_rag_lightrag_error",
format!("LightRAG provider 返回 HTTP {status}: {text}"),
)
.with_context(context));
}
return serde_json::from_str(&text).map_err(|error| {
WebError::bad_gateway_code(
"knowledge_rag_lightrag_json_invalid",
format!("LightRAG provider 返回非 JSON 响应: {error}"),
)
.with_context(context)
});
}
if let Some(body) = body {
request = request.json(&body);
}
let response = request.send().await.map_err(|error| {
WebError::bad_gateway_code(
"knowledge_rag_lightrag_unreachable",
format!("无法访问 LightRAG provider: {error}"),
)
.with_context(context)
})?;
let status = response.status();
let text = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(WebError::bad_gateway_code(
"knowledge_rag_lightrag_error",
format!("LightRAG provider 返回 HTTP {status}: {text}"),
)
.with_context(context));
}
serde_json::from_str(&text).map_err(|error| {
WebError::bad_gateway_code(
"knowledge_rag_lightrag_json_invalid",
format!("LightRAG provider 返回非 JSON 响应: {error}"),
)
.with_context(context)
})
unreachable!("lightrag retry loop returns on every branch")
}
fn should_retry_lightrag_provider_error(status: StatusCode, body: &str) -> bool {
status.is_server_error()
&& (body.contains("RetryError")
|| body.contains("InvalidResponseError")
|| body.contains("Received empty content"))
}
fn mapped_references(
@@ -1783,19 +1797,29 @@ fn map_reference_plan(
.or_else(|| primary_chunk.and_then(|chunk| chunk.get("chunk_id")))
.cloned()
.unwrap_or(Value::Null);
let source_chunk_id = reference
.get("source_chunk_id")
.or_else(|| primary_chunk.and_then(|chunk| chunk.get("source_chunk_id")))
.cloned()
.or_else(|| {
chunk_id
.as_str()
.and_then(|value| value.split_once("#match-").map(|(base, _)| json!(base)))
})
.unwrap_or(Value::Null);
let occurrence_index = reference
.get("occurrence_index")
.or_else(|| primary_chunk.and_then(|chunk| chunk.get("occurrence_index")))
.cloned()
.unwrap_or(Value::Null);
let chunk_sidecar =
lightrag_reference_sidecar(reference, primary_chunk, &source_chunk_id, &chunk_id);
let (quote, quote_source) = primary_chunk
.and_then(|chunk| chunk.get("content"))
.and_then(Value::as_str)
.map(|value| (value.to_owned(), "chunk"))
.or_else(|| {
chunk_id
.as_str()
.and_then(lightrag_chunk_content_for_id)
lightrag_chunk_content_for_reference_ids(&source_chunk_id, &chunk_id)
.map(|value| (value, "kv_store"))
})
.map(|(value, source)| {
@@ -1806,9 +1830,7 @@ fn map_reference_plan(
})
.map_or((None, "missing"), |(value, source)| (Some(value), source));
let locator_sidecar_block = entry.and_then(|entry| {
quote
.as_deref()
.and_then(|quote| find_lightrag_sidecar_block(entry, quote, query))
find_lightrag_sidecar_block(entry, query, &occurrence_index, chunk_sidecar.as_ref())
});
let locator_sidecar_text = locator_sidecar_block
.as_ref()
@@ -1824,17 +1846,16 @@ fn map_reference_plan(
);
let content_diagnostics = quote_content_diagnostics(quote.as_deref(), quote_source);
let locator = entry.and_then(|entry| {
quote.as_deref().and_then(|quote| {
lightrag_locator_for_reference(
root_path,
root_uri,
entry,
&chunk_id,
quote,
query,
Some(&text_bundle.locator_evidence_text),
)
})
lightrag_locator_for_reference(
root_path,
root_uri,
entry,
&chunk_id,
query,
Some(&text_bundle.locator_evidence_text),
&occurrence_index,
chunk_sidecar.as_ref(),
)
});
let locator_precision = locator
.as_ref()
@@ -1880,6 +1901,7 @@ fn map_reference_plan(
"reference": reference,
"filePath": file_path,
"chunkId": chunk_id,
"sourceChunkId": source_chunk_id,
"occurrenceIndex": occurrence_index,
"rawQuote": text_bundle.raw_quote.clone(),
"displayQuote": text_bundle.display_quote.clone(),
@@ -2091,7 +2113,10 @@ fn citation_text_bundle(
} else {
locator_raw.chars().take(700).collect::<String>()
};
let display_quote = clean_lightrag_text_for_display(&raw_quote);
let display_source = locator_source_text
.filter(|value| !value.trim().is_empty())
.unwrap_or(&raw_quote);
let display_quote = clean_lightrag_text_for_display(display_source);
let locator_evidence_text = query_centered_clean_window(
&clean_lightrag_text_for_locator(&locator_window),
&search_query,
@@ -2301,11 +2326,12 @@ fn lightrag_locator_for_reference(
root_uri: &str,
entry: &KnowledgeRagSourceRegistryEntry,
chunk_id: &Value,
quote: &str,
query: Option<&str>,
locator_evidence_text: Option<&str>,
occurrence_index: &Value,
chunk_sidecar: Option<&Value>,
) -> Option<EvidenceLocator> {
let block = find_lightrag_sidecar_block(entry, quote, query)?;
let block = find_lightrag_sidecar_block(entry, query, occurrence_index, chunk_sidecar)?;
let positions = block
.get("positions")
.and_then(Value::as_array)
@@ -2324,23 +2350,7 @@ fn lightrag_locator_for_reference(
.and_then(|query| query_centered_quote(block_content, query, 700))
.map(|value| clean_lightrag_text_for_locator(&value))
})
.or_else(|| {
query_centered_quote(block_content, quote, 700)
.map(|value| clean_lightrag_text_for_locator(&value))
})
.unwrap_or_else(|| {
clean_lightrag_text_for_locator(&quote.chars().take(700).collect::<String>())
});
let fallback_evidence_text = block
.get("content")
.and_then(Value::as_str)
.map(|value| clean_lightrag_text_for_locator(&value.chars().take(700).collect::<String>()))
.unwrap_or_default();
let evidence_text = if evidence_text.trim().is_empty() {
fallback_evidence_text
} else {
evidence_text
};
.unwrap_or_else(|| clean_lightrag_text_for_locator(block_content));
let mut open_params = json!({
"rootUri": root_uri,
"resourcePath": resource_path,
@@ -2494,109 +2504,114 @@ fn value_to_u32(value: &Value) -> Option<u32> {
fn find_lightrag_sidecar_block(
entry: &KnowledgeRagSourceRegistryEntry,
quote: &str,
query: Option<&str>,
occurrence_index: &Value,
chunk_sidecar: Option<&Value>,
) -> Option<Value> {
let quote_normalized = normalize_text_for_match(quote);
if quote_normalized.is_empty() {
let ref_ids = lightrag_sidecar_ref_ids(chunk_sidecar?);
if ref_ids.is_empty() {
return None;
}
let query_normalized = query
.map(normalize_text_for_match)
.filter(|value| !value.is_empty());
let query_terms = query_normalized
.as_deref()
.map(query_match_terms)
.unwrap_or_default();
let query_window = query_normalized
.as_deref()
.and_then(|query| query_centered_quote(&quote_normalized, query, 160));
let path = sidecar_blocks_path(entry)?;
let content = fs::read_to_string(path).ok()?;
let mut best_query_block: Option<(usize, Value)> = None;
for line in content.lines() {
let block = serde_json::from_str::<Value>(line).ok()?;
if block.get("positions").and_then(Value::as_array).is_none() {
let blocks = lightrag_sidecar_blocks_by_id(entry, &ref_ids)?;
let query = query
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let Some(query) = query else {
return ref_ids.into_iter().find_map(|id| blocks.get(&id).cloned());
};
let target_occurrence = occurrence_index.as_u64().unwrap_or(0);
let mut seen_occurrences = 0u64;
for id in ref_ids {
let Some(block) = blocks.get(&id) else {
continue;
}
let block_text = block
};
let count = block
.get("content")
.and_then(Value::as_str)
.unwrap_or_default();
let block_normalized = normalize_text_for_match(block_text);
if block_normalized.is_empty() {
.map(|content| exact_match_count(content, &query))
.unwrap_or(0);
if count == 0 {
continue;
}
let block_contains_query = query_normalized.as_deref().is_some_and(|query_normalized| {
block_normalized
.to_ascii_lowercase()
.contains(&query_normalized.to_ascii_lowercase())
|| query_terms
.iter()
.any(|term| block_matches_query_term(&block_normalized, term))
});
if query_normalized.is_some() {
if block_contains_query {
let score = query_window
.as_deref()
.map(|window| {
let window_prefix = match_prefix(window, 40);
let exact_window_bonus = if !window_prefix.trim().is_empty()
&& block_normalized.contains(&window_prefix)
{
100_000
} else {
0
};
exact_window_bonus
+ common_char_substring_score(&block_normalized, window) * 10
})
.unwrap_or(0)
+ common_char_prefix_len(&block_normalized, &quote_normalized)
+ common_char_substring_score(&block_normalized, &quote_normalized);
if score
> best_query_block
.as_ref()
.map(|(score, _)| *score)
.unwrap_or(0)
{
best_query_block = Some((score, block.clone()));
}
} else {
continue;
if seen_occurrences + count > target_occurrence {
return Some(block.clone());
}
seen_occurrences += count;
}
None
}
fn lightrag_sidecar_ref_ids(sidecar: &Value) -> Vec<String> {
let mut ids = Vec::new();
if let Some(id) = sidecar.get("id").and_then(Value::as_str) {
ids.push(id.trim().to_string());
}
if let Some(refs) = sidecar.get("refs").and_then(Value::as_array) {
for item in refs {
if let Some(id) = item.get("id").and_then(Value::as_str) {
ids.push(id.trim().to_string());
}
}
if block_normalized.contains(&quote_normalized)
|| quote_normalized.contains(&block_normalized)
|| quote_normalized.contains(&match_prefix(&block_normalized, 80))
|| block_normalized.contains(&match_prefix(&quote_normalized, 80))
{
return Some(block);
}
ids.retain(|id| !id.is_empty());
ids.dedup();
ids
}
fn lightrag_sidecar_blocks_by_id(
entry: &KnowledgeRagSourceRegistryEntry,
ref_ids: &[String],
) -> Option<BTreeMap<String, Value>> {
let wanted = ref_ids.iter().cloned().collect::<BTreeSet<_>>();
let path = sidecar_blocks_path(entry)?;
let content = fs::read_to_string(path).ok()?;
let mut blocks = BTreeMap::new();
for line in content.lines() {
let block = serde_json::from_str::<Value>(line).ok()?;
let Some(block_id) = block.get("blockid").and_then(Value::as_str) else {
continue;
};
if wanted.contains(block_id) && block.get("positions").and_then(Value::as_array).is_some() {
blocks.insert(block_id.to_string(), block);
}
if blocks.len() == wanted.len() {
break;
}
}
best_query_block.map(|(_, block)| block)
Some(blocks)
}
fn common_char_prefix_len(left: &str, right: &str) -> usize {
left.chars()
.zip(right.chars())
.take_while(|(left, right)| left == right)
.count()
fn exact_match_count(content: &str, query: &str) -> u64 {
if query.is_empty() {
return 0;
}
let content_lower = content.to_lowercase();
let query_lower = query.to_lowercase();
let mut count = 0u64;
let mut start = 0usize;
while let Some(index) = content_lower[start..].find(&query_lower) {
count += 1;
start += index + query_lower.len().max(1);
}
count
}
fn common_char_substring_score(left: &str, right: &str) -> usize {
let right_prefixes = right
.chars()
.collect::<Vec<_>>()
.windows(40.min(right.chars().count()))
.map(|window| window.iter().collect::<String>())
.collect::<Vec<_>>();
right_prefixes
.iter()
.filter(|candidate| !candidate.trim().is_empty() && left.contains(candidate.as_str()))
.map(|candidate| candidate.chars().count())
.max()
.unwrap_or(0)
fn lightrag_reference_sidecar(
reference: &Value,
primary_chunk: Option<&Value>,
source_chunk_id: &Value,
chunk_id: &Value,
) -> Option<Value> {
reference
.get("sidecar")
.cloned()
.or_else(|| {
primary_chunk
.and_then(|chunk| chunk.get("sidecar"))
.cloned()
})
.or_else(|| lightrag_chunk_sidecar_for_reference_ids(source_chunk_id, chunk_id))
}
fn block_matches_query_term(block_normalized: &str, term: &str) -> bool {
@@ -2712,13 +2727,6 @@ fn normalize_text_for_match(value: &str) -> String {
.collect::<String>()
}
fn match_prefix(value: &str, limit: usize) -> String {
value
.chars()
.take(limit.min(value.chars().count()))
.collect()
}
fn sidecar_blocks_path(entry: &KnowledgeRagSourceRegistryEntry) -> Option<PathBuf> {
for file_path in lightrag_sidecar_file_path_candidates(entry) {
let stem = Path::new(&file_path).file_stem()?.to_string_lossy();
@@ -2841,12 +2849,56 @@ fn sidecar_text_stats(path: &Path) -> Result<SidecarTextStats, WebError> {
Ok(stats)
}
fn lightrag_chunk_content_for_id(chunk_id: &str) -> Option<String> {
fn lightrag_chunk_content_for_reference_ids(
source_chunk_id: &Value,
chunk_id: &Value,
) -> Option<String> {
lightrag_chunk_id_candidates(source_chunk_id, chunk_id)
.into_iter()
.find_map(|id| lightrag_chunk_content_for_id(&id))
}
fn lightrag_chunk_sidecar_for_reference_ids(
source_chunk_id: &Value,
chunk_id: &Value,
) -> Option<Value> {
lightrag_chunk_id_candidates(source_chunk_id, chunk_id)
.into_iter()
.find_map(|id| {
lightrag_chunk_value_for_id(&id).and_then(|chunk| chunk.get("sidecar").cloned())
})
}
fn lightrag_chunk_id_candidates(source_chunk_id: &Value, chunk_id: &Value) -> Vec<String> {
let mut ids = Vec::new();
for value in [source_chunk_id, chunk_id] {
let Some(raw) = value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
continue;
};
ids.push(raw.to_string());
if let Some((base, _)) = raw.split_once("#match-") {
ids.push(base.to_string());
}
}
ids.sort();
ids.dedup();
ids
}
fn lightrag_chunk_value_for_id(chunk_id: &str) -> Option<Value> {
let content =
fs::read_to_string(lightrag_working_dir().join("kv_store_text_chunks.json")).ok()?;
let chunks = serde_json::from_str::<Value>(&content).ok()?;
chunks
.get(chunk_id)
chunks.get(chunk_id).cloned()
}
fn lightrag_chunk_content_for_id(chunk_id: &str) -> Option<String> {
lightrag_chunk_value_for_id(chunk_id)
.as_ref()
.and_then(|chunk| chunk.get("content"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
@@ -4588,7 +4640,14 @@ mod tests {
"chunk_id": "doc1-chunk-256",
"chunks": [{
"chunk_id": "doc1-chunk-256",
"content": chunk_content
"content": chunk_content,
"sidecar": {
"type": "block",
"refs": [
{"type": "block", "id": "wrong-index"},
{"type": "block", "id": "tes"}
]
}
}]
}),
&registry,
@@ -4869,9 +4928,10 @@ mod tests {
"file:///tmp/root",
&entry,
&json!("doc1-chunk-000"),
"# START: This is the first image in PDF\nThis is text BEFORE the image.",
None,
None,
&json!(0),
Some(&json!({"type":"block","refs":[{"type":"block","id":"block1"}]})),
)
.expect("locator");
@@ -4927,9 +4987,10 @@ mod tests {
"file:///tmp/root",
&entry,
&json!("doc1-chunk-000"),
"监管政策变革",
Some("监管政策变革"),
None,
&json!(0),
Some(&json!({"type":"block","refs":[{"type":"block","id":"block-docx-1"}]})),
)
.expect("locator");
+11 -8
View File
@@ -1585,7 +1585,7 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
for (const element of elements) {{
const compactElement = compactEvidenceText(element.textContent || '');
if ((compactAnchor.length >= 6 || shortQueryAnchor) && compactElement.includes(compactAnchor)) {{
return markEvidenceTarget(shortQueryAnchor ? shortLeadingAnchorTarget(element, elements, elements.indexOf(element)) : element);
return markEvidenceTarget(element);
}}
if (
compactAnchorWithoutNumbers.length >= 8
@@ -1631,11 +1631,7 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
const text = normalizeEvidenceText(element.textContent || '');
if (!text || text.length < 3 || text.length > 6000) return 0;
const compactText = compactEvidenceText(text);
const compactSearchQuery = compactEvidenceText(evidenceSearchQuery);
let score = 0;
if (compactSearchQuery.length >= 2 && compactText.includes(compactSearchQuery)) {{
score += 2400;
}}
for (const anchor of anchors) {{
const compactAnchor = compactEvidenceText(anchor);
if (!compactAnchor || compactAnchor.length < 4) continue;
@@ -1771,7 +1767,14 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
if (!best || score > best.score) best = {{ element, score }};
if (score >= 2400 && evidenceElementMatchesSearchQuery(element)) break;
}}
return best ? markEvidenceTarget(best.element) : false;
if (!best) return false;
if (!anchors.length) {{
return evidenceElementMatchesSearchQuery(best.element) ? markEvidenceTarget(best.element) : false;
}}
const threshold = Math.max(180, Math.min(800, anchors[0].length * 4));
if (best.score >= threshold) return markEvidenceTarget(best.element);
if (best.score > 0 && evidenceElementMatchesSearchQuery(best.element)) return markEvidenceTarget(best.element);
return false;
}}
function scrollToEvidencePageFallback() {{
@@ -1786,15 +1789,15 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
try {{
const sourceMap = await fetchEvidenceSourceMap();
const block = findEvidenceBlockInSourceMap(sourceMap);
if (evidenceParagraphOrdinal && scrollToEvidenceParagraphOrdinal()) return;
if (evidenceText && scrollToEvidenceParagraph(evidenceText)) return;
if (evidenceParagraphOrdinal && scrollToEvidenceParagraphOrdinal()) return;
if (block && scrollToEvidenceParagraph(block.text)) return;
if (evidenceText && scrollToEvidenceTextCandidates(evidenceText)) return;
if (block && scrollToEvidenceTextCandidates(block.text)) return;
if (scrollToEvidenceCoordinate(sourceMap, block)) return;
}} catch (_) {{}}
if (evidenceParagraphOrdinal && scrollToEvidenceParagraphOrdinal()) return;
if (evidenceText && scrollToEvidenceParagraph(evidenceText)) return;
if (evidenceParagraphOrdinal && scrollToEvidenceParagraphOrdinal()) return;
if (evidenceText && scrollToEvidenceTextCandidates(evidenceText)) return;
scrollToEvidencePageFallback();
}}