feat: let operators expose chosen file metadata to the model in retrieved sources (#29696)

Custom metadata attached to a file upload now reaches the vector DB, but the model still never sees it. Both prompt-assembly paths build their output from a fixed field set: the classic RAG <source> tag carries only id, name and resource type, and the retrieval tools return only content, source and file id per chunk. A scraper that records where each document came from therefore cannot get that origin in front of the model, so answers cannot state it.

RAG_SOURCE_METADATA_KEYS names the chunk metadata keys allowed through to the model. Configured keys are emitted as extra attributes on the <source> tag and as extra fields on tool result chunks, covering both retrieval paths. It is empty by default, so nothing changes for existing deployments.

An allowlist instead of passing everything through, because chunk metadata also carries file hashes, collection names, embedding config and relevance scores, which would then be added to every retrieved chunk of every request. Values are attacker-controllable through an uploaded file, so they are escaped before they go into the tag, and a configured key can never displace a field the tag or the chunk already defines.

Reported in open-webui/open-webui#29486.
This commit is contained in:
Classic298
2026-09-19 17:02:59 -05:00
committed by GitHub
parent 3a6d0fd203
commit 7fa705f3b8
5 changed files with 24 additions and 2 deletions
+3
View File
@@ -22,6 +22,9 @@ ENABLE_RAG_CSV_SUMMARY=false
# Set to true to preserve backing file records, storage blobs, and per-file vectors when files are removed from knowledge bases.
ENABLE_KNOWLEDGE_FILE_RETENTION=false
# Comma-separated chunk metadata keys to expose to the model alongside retrieved content.
RAG_SOURCE_METADATA_KEYS=''
# Set to false to disable workspace Tools and Functions.
ENABLE_PLUGINS=true
+3
View File
@@ -371,6 +371,9 @@ ENABLE_QUERIES_CACHE = os.getenv('ENABLE_QUERIES_CACHE', 'False').lower() == 'tr
ENABLE_ADMIN_CHAT_ACCESS = os.getenv('ENABLE_ADMIN_CHAT_ACCESS', 'True').lower() == 'true'
RAG_SYSTEM_CONTEXT = os.getenv('RAG_SYSTEM_CONTEXT', 'False').lower() == 'true'
# Empty by default: chunk metadata also holds internal bookkeeping (file hashes, collection names, scores).
RAG_SOURCE_METADATA_KEYS = [key.strip() for key in os.getenv('RAG_SOURCE_METADATA_KEYS', '').split(',') if key.strip()]
####################################
# REDIS
####################################
+6
View File
@@ -34,6 +34,7 @@ from open_webui.env import (
ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS,
MPS_INFERENCE_LOCK,
OFFLINE_MODE,
RAG_SOURCE_METADATA_KEYS,
USE_SLIM,
)
from open_webui.models.access_grants import AccessGrants
@@ -1361,6 +1362,11 @@ async def filter_accessible_collections(
return validated
def filter_source_metadata(metadata: dict) -> dict:
"""Keep only the chunk metadata keys the operator allowed the model to see."""
return {key: metadata[key] for key in RAG_SOURCE_METADATA_KEYS if metadata.get(key) is not None}
async def get_sources_from_items(
request,
items,
+4 -1
View File
@@ -29,7 +29,7 @@ from open_webui.models.memories import Memories
from open_webui.models.messages import Message, Messages
from open_webui.models.notes import Notes
from open_webui.models.users import UserModel
from open_webui.retrieval.utils import get_content_from_url
from open_webui.retrieval.utils import filter_source_metadata, get_content_from_url
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
from open_webui.routers.images import (
CreateImageForm,
@@ -2610,6 +2610,7 @@ async def query_chat_files(
for idx, doc in enumerate(documents):
metadata = metadatas[idx] if idx < len(metadatas) and isinstance(metadatas[idx], dict) else {}
chunk = {
**filter_source_metadata(metadata),
'content': doc,
'source': metadata.get('source', metadata.get('name', source_info.get('name', 'Unknown'))),
'file_id': metadata.get('file_id', source_info.get('id', '')),
@@ -3322,6 +3323,7 @@ async def query_knowledge_files(
for idx, doc in enumerate(documents):
chunk_info = {
**filter_source_metadata(metadatas[idx]),
'content': doc,
'source': metadatas[idx].get('source', metadatas[idx].get('name', 'Unknown')),
'file_id': metadatas[idx].get('file_id', ''),
@@ -3345,6 +3347,7 @@ async def query_knowledge_files(
for idx, doc in enumerate(documents):
metadata = metadatas[idx] if idx < len(metadatas) else {}
chunk_info = {
**filter_source_metadata(metadata),
'content': doc,
'source': metadata.get('source', metadata.get('name', knowledge.name)),
'file_id': metadata.get('file_id', f'external-{knowledge.id}'),
+8 -1
View File
@@ -2,6 +2,7 @@ import ast
import asyncio
import base64
import copy
import html
import inspect
import json
import logging
@@ -52,7 +53,7 @@ from open_webui.models.models import Models
from open_webui.models.notes import Notes
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.users import UserModel, Users
from open_webui.retrieval.utils import get_sources_from_items
from open_webui.retrieval.utils import filter_source_metadata, get_sources_from_items
from open_webui.routers.images import (
CreateImageForm,
EditImageForm,
@@ -952,11 +953,17 @@ def get_source_context(sources: list, source_ids: dict = None, include_content:
src_type = source.get('source', {}).get('type')
src_rid = source.get('source', {}).get('id')
body = doc if include_content else ''
extra_attrs = ''
for key, value in filter_source_metadata(meta).items():
if key in ('id', 'name', 'resource-type', 'resource-id'):
continue
extra_attrs += f' {key}="{html.escape(str(value))}"'
context_string += (
f'<source id="{source_ids[source_id]}"'
+ (f' name="{src_name}"' if src_name else '')
+ (f' resource-type="{src_type}"' if src_type else '')
+ (f' resource-id="{src_rid}"' if src_rid else '')
+ extra_attrs
+ f'>{body}</source>\n'
)
return context_string