mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-28 19:58:11 +08:00
Feat/configurable metadata display (#13464)
### What problem does this PR solve? Currently, RAGFlow's Search and Chat interfaces display only raw vectorized text chunks during retrieval, without contextual information about their source documents. Users cannot see document titles, page numbers, upload dates, or custom metadata fields that would help them understand and trust the retrieved results. This PR introduces an **optional metadata display feature** that enriches retrieved chunks with document-level metadata in both the Search tab and Chatbot interface. **Key improvements:** - **Search results**: Display document metadata as styled badges beneath chunk snippets - **Chat citations**: Show metadata in citation popovers and reference lists for better source context - **LLM context**: Metadata is injected into the LLM prompt to enable more accurate, citation-aware responses - **External API support**: Applications using RAGFlow's SDK retrieval endpoints (`/v1/retrieval`, `/v1/searchbots/retrieval_test`) can opt-in via request parameters - **User control**: Multi-select dropdown UI allows users to choose which metadata fields to display **Implementation approach:** - ✅ Reuses existing `DocMetadataService` infrastructure (no new database tables or indices) - ✅ Settings stored in existing JSON configuration fields (`search_config.reference_metadata`, `prompt_config.reference_metadata`) - ✅ No database migrations required - ✅ Disabled by default (fully opt-in and backward-compatible) - ✅ Dynamic metadata field selection populated from actual document metadata keys - ✅ Fixed critical bug where Python's builtin `set()` was shadowed by a route handler function **Modified endpoints (all backward-compatible):** - `POST /v1/retrieval` (Public SDK) - `POST /v1/searchbots/retrieval_test` (Searchbots) - `POST /v1/chunk/retrieval_test` (UI/Internal) - Chat completions endpoints (via `extra_body.reference_metadata` or `prompt_config`) ### Type of change - [x] New Feature (non-breaking change which adds functionality) ###Images - <img width="879" height="1275" alt="image" src="https://github.com/user-attachments/assets/95b2d731-31ae-45a1-b081-bf5893f52aeb" /> <br><br> <br><br> <img width="1532" height="362" alt="image" src="https://github.com/user-attachments/assets/9cebc65b-b7a7-459f-b25e-3b13fa9b638e" /> <br><br> <br><br> <img width="2586" height="1320" alt="image" src="https://github.com/user-attachments/assets/2153d493-d899-461f-a7a9-041391e07776" /> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Attili-sys <Attili-sys@users.noreply.github.com> Co-authored-by: Ahmad Intisar <ahmadintisar@Ahmads-MacBook-M4-Pro.local>
This commit is contained in:
@@ -33,6 +33,10 @@ from api.db.services.knowledgebase_service import KnowledgebaseService
|
||||
from api.db.services.langfuse_service import TenantLangfuseService
|
||||
from api.db.services.llm_service import LLMBundle
|
||||
from common.metadata_utils import apply_meta_data_filter
|
||||
from api.utils.reference_metadata_utils import (
|
||||
enrich_chunks_with_document_metadata,
|
||||
resolve_reference_metadata_preferences,
|
||||
)
|
||||
from api.db.services.tenant_llm_service import TenantLLMService
|
||||
from api.db.joint_services.tenant_model_service import get_model_config_by_id, get_model_config_by_type_and_name, get_tenant_default_model_by_type
|
||||
from common.time_utils import current_timestamp, datetime_format
|
||||
@@ -48,6 +52,16 @@ from rag.utils.tavily_conn import Tavily
|
||||
from common.string_utils import remove_redundant_spaces
|
||||
from common import settings
|
||||
|
||||
def _resolve_reference_metadata(request_payload=None, config=None):
|
||||
return resolve_reference_metadata_preferences(request_payload or {}, config)
|
||||
|
||||
def _enrich_chunks_with_document_metadata(chunks, metadata_fields=None):
|
||||
enrich_chunks_with_document_metadata(chunks, metadata_fields)
|
||||
|
||||
def _chunk_kb_id_for_doc(row_dict, kb_ids, doc_id):
|
||||
if len(kb_ids or []) == 1:
|
||||
return kb_ids[0]
|
||||
return row_dict.get("kb_id") or row_dict.get("kb_id_kwd")
|
||||
|
||||
def _normalize_internet_flag(value):
|
||||
if isinstance(value, bool):
|
||||
@@ -70,6 +84,15 @@ def _should_use_web_search(prompt_config, internet=None):
|
||||
return normalized is True
|
||||
|
||||
|
||||
def _resolve_reference_metadata(config, request_payload=None):
|
||||
return resolve_reference_metadata_preferences(request_payload or {}, config)
|
||||
|
||||
|
||||
def _enrich_chunks_with_document_metadata(chunks, metadata_fields=None):
|
||||
enrich_chunks_with_document_metadata(chunks, metadata_fields)
|
||||
|
||||
|
||||
|
||||
class DialogService(CommonService):
|
||||
model = Dialog
|
||||
|
||||
@@ -547,6 +570,7 @@ async def async_chat(dialog, messages, stream=True, **kwargs):
|
||||
attachments_ = "\n\n".join(text_attachments)
|
||||
|
||||
prompt_config = dialog.prompt_config
|
||||
include_reference_metadata, metadata_fields = _resolve_reference_metadata(prompt_config, request_payload=kwargs)
|
||||
field_map = KnowledgebaseService.get_field_map(dialog.kb_ids)
|
||||
logging.debug(f"field_map retrieved: {field_map}")
|
||||
# try to use sql if field mapping is good to go
|
||||
@@ -555,6 +579,14 @@ async def async_chat(dialog, messages, stream=True, **kwargs):
|
||||
ans = await use_sql(questions[-1], field_map, dialog.tenant_id, chat_mdl, prompt_config.get("quote", True), dialog.kb_ids)
|
||||
# For aggregate queries (COUNT, SUM, etc.), chunks may be empty but answer is still valid
|
||||
if ans and (ans.get("reference", {}).get("chunks") or ans.get("answer")):
|
||||
if include_reference_metadata and ans.get("reference", {}).get("chunks"):
|
||||
if len(dialog.kb_ids) != 1 and any(not c.get("kb_id") for c in ans["reference"]["chunks"]):
|
||||
logging.warning(
|
||||
"Skipping some _enrich_chunks_with_document_metadata results because "
|
||||
"dialog.kb_ids has %d entries and use_sql returned chunks without kb_id.",
|
||||
len(dialog.kb_ids),
|
||||
)
|
||||
_enrich_chunks_with_document_metadata(ans["reference"]["chunks"], metadata_fields)
|
||||
yield ans
|
||||
return
|
||||
else:
|
||||
@@ -675,6 +707,14 @@ async def async_chat(dialog, messages, stream=True, **kwargs):
|
||||
if ck["content_with_weight"]:
|
||||
kbinfos["chunks"].insert(0, ck)
|
||||
|
||||
if include_reference_metadata:
|
||||
logging.debug(
|
||||
"reference_metadata enrichment enabled for async_chat: chunk_count=%d metadata_fields=%s",
|
||||
len(kbinfos.get("chunks", [])),
|
||||
metadata_fields,
|
||||
)
|
||||
_enrich_chunks_with_document_metadata(kbinfos.get("chunks", []), metadata_fields)
|
||||
|
||||
knowledges = kb_prompt(kbinfos, max_tokens)
|
||||
logging.debug("{}->{}".format(" ".join(questions), "\n->".join(knowledges)))
|
||||
|
||||
@@ -1121,11 +1161,12 @@ Please correct the error and write SQL again using json_extract_string(chunk_dat
|
||||
|
||||
docid_idx = set([ii for ii, c in enumerate(tbl["columns"]) if c["name"].lower() == "doc_id"])
|
||||
doc_name_idx = set([ii for ii, c in enumerate(tbl["columns"]) if c["name"].lower() in ["docnm_kwd", "docnm"]])
|
||||
kb_id_idx = set([ii for ii, c in enumerate(tbl["columns"]) if c["name"].lower() in ["kb_id", "kb_id_kwd"]])
|
||||
|
||||
logging.debug(f"use_sql: All columns: {[(i, c['name']) for i, c in enumerate(tbl['columns'])]}")
|
||||
logging.debug(f"use_sql: docid_idx={docid_idx}, doc_name_idx={doc_name_idx}")
|
||||
logging.debug(f"use_sql: docid_idx={docid_idx}, doc_name_idx={doc_name_idx}, kb_id_idx={kb_id_idx}")
|
||||
|
||||
column_idx = [ii for ii in range(len(tbl["columns"])) if ii not in (docid_idx | doc_name_idx)]
|
||||
column_idx = [ii for ii in range(len(tbl["columns"])) if ii not in (docid_idx | doc_name_idx | kb_id_idx)]
|
||||
|
||||
logging.debug(f"use_sql: column_idx={column_idx}")
|
||||
logging.debug(f"use_sql: field_map={field_map}")
|
||||
@@ -1221,8 +1262,11 @@ Please correct the error and write SQL again using json_extract_string(chunk_dat
|
||||
where_match = re.search(r"\bwhere\b(.+?)(?:\bgroup by\b|\border by\b|\blimit\b|$)", sql, re.IGNORECASE)
|
||||
if where_match:
|
||||
where_clause = where_match.group(1).strip()
|
||||
# Build a query to get doc_id and docnm_kwd with the same WHERE clause
|
||||
chunks_sql = f"select doc_id, docnm_kwd from {table_name} where {where_clause}"
|
||||
# Build a query to get source fields with the same WHERE clause.
|
||||
# Single-KB queries can derive kb_id from the dialog, while multi-KB
|
||||
# ES/OS queries need the row value for metadata enrichment.
|
||||
chunks_kb_column = ", kb_id" if not (kb_ids and len(kb_ids) == 1) else ""
|
||||
chunks_sql = f"select doc_id, {expected_doc_name_column}{chunks_kb_column} from {table_name} where {where_clause}"
|
||||
# Add LIMIT to avoid fetching too many chunks
|
||||
if "limit" not in chunks_sql.lower():
|
||||
chunks_sql += " limit 20"
|
||||
@@ -1233,8 +1277,18 @@ Please correct the error and write SQL again using json_extract_string(chunk_dat
|
||||
# Build chunks reference - use case-insensitive matching
|
||||
chunks_did_idx = next((i for i, c in enumerate(chunks_tbl["columns"]) if c["name"].lower() == "doc_id"), None)
|
||||
chunks_dn_idx = next((i for i, c in enumerate(chunks_tbl["columns"]) if c["name"].lower() in ["docnm_kwd", "docnm"]), None)
|
||||
chunks_kb_idx = next((i for i, c in enumerate(chunks_tbl["columns"]) if c["name"].lower() in ["kb_id", "kb_id_kwd"]), None)
|
||||
if chunks_did_idx is not None and chunks_dn_idx is not None:
|
||||
chunks = [{"doc_id": r[chunks_did_idx], "docnm_kwd": r[chunks_dn_idx]} for r in chunks_tbl["rows"]]
|
||||
chunks = []
|
||||
for r in chunks_tbl["rows"]:
|
||||
chunk = {"doc_id": r[chunks_did_idx], "docnm_kwd": r[chunks_dn_idx]}
|
||||
row_dict = {chunks_tbl["columns"][i]["name"]: r[i] for i in range(len(chunks_tbl["columns"])) if i < len(r)}
|
||||
kb_id = _chunk_kb_id_for_doc(row_dict, kb_ids, chunk["doc_id"])
|
||||
if kb_id:
|
||||
chunk["kb_id"] = kb_id
|
||||
elif chunks_kb_idx is not None:
|
||||
chunk["kb_id"] = r[chunks_kb_idx]
|
||||
chunks.append(chunk)
|
||||
# Build doc_aggs
|
||||
doc_aggs = {}
|
||||
for r in chunks_tbl["rows"]:
|
||||
@@ -1264,7 +1318,22 @@ Please correct the error and write SQL again using json_extract_string(chunk_dat
|
||||
result = {
|
||||
"answer": "\n".join([columns, line, rows]),
|
||||
"reference": {
|
||||
"chunks": [{"doc_id": r[docid_idx], "docnm_kwd": r[doc_name_idx]} for r in tbl["rows"]],
|
||||
"chunks": [
|
||||
{
|
||||
key: value
|
||||
for key, value in {
|
||||
"doc_id": r[docid_idx],
|
||||
"docnm_kwd": r[doc_name_idx],
|
||||
"kb_id": _chunk_kb_id_for_doc(
|
||||
{tbl["columns"][i]["name"]: r[i] for i in range(len(tbl["columns"])) if i < len(r)},
|
||||
kb_ids,
|
||||
r[docid_idx],
|
||||
),
|
||||
}.items()
|
||||
if value
|
||||
}
|
||||
for r in tbl["rows"]
|
||||
],
|
||||
"doc_aggs": [{"doc_id": did, "doc_name": d["doc_name"], "count": d["count"]} for did, d in doc_aggs.items()],
|
||||
},
|
||||
"prompt": sys_prompt,
|
||||
@@ -1414,6 +1483,7 @@ async def async_ask(question, kb_ids, tenant_id, chat_llm_name=None, search_conf
|
||||
chat_llm_name = search_config.get("chat_id", chat_llm_name)
|
||||
rerank_id = search_config.get("rerank_id", "")
|
||||
meta_data_filter = search_config.get("meta_data_filter")
|
||||
include_reference_metadata, metadata_fields = _resolve_reference_metadata(search_config)
|
||||
|
||||
kbs = KnowledgebaseService.get_by_ids(kb_ids)
|
||||
embedding_list = list(set([kb.embd_id for kb in kbs]))
|
||||
@@ -1450,6 +1520,13 @@ async def async_ask(question, kb_ids, tenant_id, chat_llm_name=None, search_conf
|
||||
rerank_mdl=rerank_mdl,
|
||||
rank_feature=label_question(question, kbs)
|
||||
)
|
||||
if include_reference_metadata:
|
||||
logging.debug(
|
||||
"reference_metadata enrichment enabled for async_ask: chunk_count=%d metadata_fields=%s",
|
||||
len(kbinfos.get("chunks", [])),
|
||||
metadata_fields,
|
||||
)
|
||||
_enrich_chunks_with_document_metadata(kbinfos.get("chunks", []), metadata_fields)
|
||||
|
||||
knowledges = kb_prompt(kbinfos, max_tokens)
|
||||
sys_prompt = PROMPT_JINJA_ENV.from_string(ASK_SUMMARY).render(knowledge="\n".join(knowledges))
|
||||
|
||||
@@ -772,6 +772,36 @@ class DocMetadataService:
|
||||
logging.error(f"Error getting flattened metadata for KBs {kb_ids}: {e}")
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def get_metadata_keys_by_kbs(cls, kb_ids: List[str]) -> List[str]:
|
||||
"""
|
||||
Get unique metadata field names across multiple knowledge bases.
|
||||
|
||||
Args:
|
||||
kb_ids: List of knowledge base IDs
|
||||
|
||||
Returns:
|
||||
Sorted list of unique metadata field names
|
||||
"""
|
||||
if not kb_ids:
|
||||
return []
|
||||
|
||||
logging.debug(f"get_metadata_keys_by_kbs start: n_kbs={len(kb_ids)}")
|
||||
keys: set[str] = set()
|
||||
try:
|
||||
for kb_id in kb_ids:
|
||||
results = cls._search_metadata(kb_id, condition={"kb_id": kb_id})
|
||||
for _doc_id, doc in cls._iter_search_results(results):
|
||||
doc_meta = cls._extract_metadata(doc)
|
||||
if not isinstance(doc_meta, dict):
|
||||
continue
|
||||
keys.update(str(k) for k in doc_meta.keys())
|
||||
logging.debug(f"get_metadata_keys_by_kbs end: n_keys={len(keys)}, kb_ids={kb_ids}")
|
||||
return sorted(keys)
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting metadata keys for KBs {kb_ids}: {e}")
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def get_metadata_for_documents(cls, doc_ids: Optional[List[str]], kb_id: str) -> Dict[str, Dict]:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user