mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-15 18:55:07 +03:00
35d946f276
With Retrieval Query Generation on, an external knowledge base (Qdrant, Milvus or pgvector) ran one search per generated query, one after another, and then kept only the results of the first query. The other searches cost time and added nothing to the answer. The searches now run at the same time and their results are merged by position (the top result of each query, then the second of each, and so on) with duplicates dropped, so every generated query contributes. Results are merged by position because scores cannot be compared the same way across vector databases: for some a higher score is better, for others a lower one. | 3 queries, simulated search taking 0.3 s | before | after | | --- | --- | --- | | time | 0.9 s | 0.3 s | | results used | first query only | all three queries | Fixes #33184
366 lines
13 KiB
Python
366 lines
13 KiB
Python
import asyncio
|
|
import logging
|
|
import re
|
|
import time
|
|
from itertools import zip_longest
|
|
from typing import Any, Optional
|
|
|
|
from open_webui.models.config import Config
|
|
from open_webui.models.knowledge import KnowledgeModel
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
EXTERNAL_KNOWLEDGE_CONNECTIONS_CONFIG_KEY = 'external_knowledge.connections'
|
|
IDENTIFIER_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
|
|
|
|
|
|
async def _get_external_connection(connection_id: str) -> Optional[dict]:
|
|
connections = await Config.get(EXTERNAL_KNOWLEDGE_CONNECTIONS_CONFIG_KEY, []) or []
|
|
return next((connection for connection in connections if connection.get('id') == connection_id), None)
|
|
|
|
|
|
def _get_path(data: Any, path: Optional[str], default=None):
|
|
if not path:
|
|
return default
|
|
value = data
|
|
for part in path.split('.'):
|
|
if isinstance(value, dict):
|
|
value = value.get(part, default)
|
|
else:
|
|
return default
|
|
return value
|
|
|
|
|
|
def _normalize_result(result: dict, mapping: dict, knowledge: KnowledgeModel, distance: Optional[float] = None) -> dict:
|
|
content = _get_path(result, mapping.get('content_field', 'content'), '')
|
|
title = _get_path(result, mapping.get('title_field', 'title'), None)
|
|
source = _get_path(result, mapping.get('source_field', 'source'), None)
|
|
url = _get_path(result, mapping.get('url_field', 'url'), None)
|
|
document_id = _get_path(result, mapping.get('document_id_field', 'document_id'), None)
|
|
page = _get_path(result, mapping.get('page_field', 'page'), None)
|
|
metadata = _get_path(result, mapping.get('metadata_field', 'metadata'), {}) or {}
|
|
score = _get_path(result, mapping.get('score_field', 'score'), distance)
|
|
|
|
if not isinstance(metadata, dict):
|
|
metadata = {'external_metadata': metadata}
|
|
|
|
source_name = source or title or metadata.get('source') or metadata.get('name') or knowledge.name
|
|
metadata.update(
|
|
{
|
|
'name': title or metadata.get('name') or source_name,
|
|
'source': source_name,
|
|
'url': url,
|
|
'knowledge_id': knowledge.id,
|
|
'knowledge_name': knowledge.name,
|
|
'external': True,
|
|
}
|
|
)
|
|
# external results are not Open WebUI files, even when the payload carries a file_id
|
|
metadata.pop('file_id', None)
|
|
if page is not None:
|
|
metadata['page'] = page
|
|
if document_id is not None:
|
|
metadata['document_id'] = document_id
|
|
|
|
return {
|
|
'content': content,
|
|
'metadata': metadata,
|
|
'distance': score,
|
|
}
|
|
|
|
|
|
def _source_config(knowledge: KnowledgeModel) -> dict:
|
|
external = (knowledge.meta or {}).get('external', {})
|
|
source = external.get('source') or {}
|
|
return source.get('config') or {}
|
|
|
|
|
|
def _root_field(path: Optional[str]) -> Optional[str]:
|
|
if not path:
|
|
return None
|
|
return path.split('.')[0]
|
|
|
|
|
|
def _safe_identifier(value: str, label: str) -> str:
|
|
if not value or not IDENTIFIER_RE.match(value):
|
|
raise RuntimeError(f'Invalid {label}')
|
|
return value
|
|
|
|
|
|
async def _retrieve_qdrant(connection, auth_config, knowledge, query, count, embedding_function) -> list[dict]:
|
|
try:
|
|
from qdrant_client import QdrantClient
|
|
except ImportError as exc:
|
|
raise RuntimeError('qdrant-client is not installed') from exc
|
|
|
|
if not embedding_function:
|
|
raise RuntimeError('Embedding function is not configured')
|
|
|
|
config = connection.get('config') or {}
|
|
external = (knowledge.meta or {}).get('external', {})
|
|
source = external.get('source') or {}
|
|
collection_name = source.get('name')
|
|
if not collection_name:
|
|
raise RuntimeError('External source collection is not configured')
|
|
source_config = _source_config(knowledge)
|
|
vector_field = source_config.get('vector_field') or None
|
|
|
|
vector = await embedding_function(query, prefix=await Config.get('rag.embedding_query_prefix'))
|
|
|
|
def _search():
|
|
client = QdrantClient(
|
|
url=connection.get('endpoint'),
|
|
api_key=(auth_config or {}).get('api_key'),
|
|
timeout=config.get('timeout') or 30,
|
|
)
|
|
return client.query_points(
|
|
collection_name=collection_name,
|
|
query=vector,
|
|
using=vector_field,
|
|
limit=count,
|
|
)
|
|
|
|
response = await asyncio.to_thread(_search)
|
|
mapping = {
|
|
'content_field': source_config.get('content_field') or 'payload.text',
|
|
'metadata_field': source_config.get('metadata_field') or 'payload.metadata',
|
|
'document_id_field': source_config.get('document_id_field') or 'id',
|
|
'score_field': 'score',
|
|
}
|
|
|
|
normalized = []
|
|
for point in response.points:
|
|
normalized.append(_normalize_result(point.model_dump(), mapping, knowledge, distance=point.score))
|
|
return normalized
|
|
|
|
|
|
async def _retrieve_milvus(connection, auth_config, knowledge, query, count, embedding_function) -> list[dict]:
|
|
try:
|
|
from pymilvus import MilvusClient
|
|
except ImportError as exc:
|
|
raise RuntimeError('pymilvus is not installed') from exc
|
|
|
|
if not embedding_function:
|
|
raise RuntimeError('Embedding function is not configured')
|
|
|
|
config = connection.get('config') or {}
|
|
external = (knowledge.meta or {}).get('external', {})
|
|
source = external.get('source') or {}
|
|
collection_name = source.get('name')
|
|
if not collection_name:
|
|
raise RuntimeError('Milvus collection is not configured')
|
|
source_config = _source_config(knowledge)
|
|
vector_field = source_config.get('vector_field') or 'vector'
|
|
content_field = source_config.get('content_field') or 'data.text'
|
|
metadata_field = source_config.get('metadata_field') or 'metadata'
|
|
|
|
vector = await embedding_function(query, prefix=await Config.get('rag.embedding_query_prefix'))
|
|
|
|
def _search():
|
|
client_kwargs = {
|
|
'uri': connection.get('endpoint'),
|
|
}
|
|
token = (auth_config or {}).get('api_key') or (auth_config or {}).get('token')
|
|
if token:
|
|
client_kwargs['token'] = token
|
|
if config.get('db_name'):
|
|
client_kwargs['db_name'] = config.get('db_name')
|
|
|
|
client = MilvusClient(**client_kwargs)
|
|
output_fields = {
|
|
field
|
|
for field in (
|
|
_root_field(content_field),
|
|
_root_field(metadata_field),
|
|
_root_field(source_config.get('document_id_field')),
|
|
)
|
|
if field and field != vector_field
|
|
}
|
|
kwargs = {
|
|
'collection_name': collection_name,
|
|
'data': [vector],
|
|
'anns_field': vector_field,
|
|
'limit': count,
|
|
'output_fields': list(output_fields),
|
|
}
|
|
return client.search(**kwargs)
|
|
|
|
response = await asyncio.to_thread(_search)
|
|
mapping = {
|
|
'content_field': content_field,
|
|
'metadata_field': metadata_field,
|
|
'document_id_field': source_config.get('document_id_field') or 'id',
|
|
'score_field': 'distance',
|
|
}
|
|
|
|
normalized = []
|
|
for hit in response[0] if response else []:
|
|
item = dict(hit)
|
|
entity = item.get('entity') or {}
|
|
result = {
|
|
**entity,
|
|
'id': item.get('id') or entity.get('id'),
|
|
'distance': item.get('distance'),
|
|
}
|
|
normalized.append(_normalize_result(result, mapping, knowledge, distance=item.get('distance')))
|
|
return normalized
|
|
|
|
|
|
async def _retrieve_pgvector(connection, auth_config, knowledge, query, count, embedding_function) -> list[dict]:
|
|
try:
|
|
import psycopg
|
|
from pgvector import Vector
|
|
from pgvector.psycopg import register_vector
|
|
from psycopg.rows import dict_row
|
|
except ImportError as exc:
|
|
raise RuntimeError('psycopg and pgvector are required for pgvector retrieval') from exc
|
|
|
|
if not embedding_function:
|
|
raise RuntimeError('Embedding function is not configured')
|
|
|
|
config = connection.get('config') or {}
|
|
external = (knowledge.meta or {}).get('external', {})
|
|
source = external.get('source') or {}
|
|
collection_name = source.get('name')
|
|
if not collection_name:
|
|
raise RuntimeError('pgvector collection is not configured')
|
|
source_config = _source_config(knowledge)
|
|
table_name = source_config.get('table_name') or 'document_chunk'
|
|
collection_field = source_config.get('collection_field') or 'collection_name'
|
|
content_field = source_config.get('content_field') or 'text'
|
|
vector_field = source_config.get('vector_field') or 'vector'
|
|
metadata_field = source_config.get('metadata_field') or 'vmetadata'
|
|
document_id_field = source_config.get('document_id_field') or 'id'
|
|
|
|
vector = await embedding_function(query, prefix=await Config.get('rag.embedding_query_prefix'))
|
|
|
|
def _search():
|
|
from psycopg import sql
|
|
|
|
table_identifier = sql.SQL('.').join(
|
|
sql.Identifier(_safe_identifier(part, 'table name')) for part in table_name.split('.')
|
|
)
|
|
collection_identifier = sql.Identifier(_safe_identifier(collection_field, 'collection field'))
|
|
content_identifier = sql.Identifier(_safe_identifier(content_field, 'content field'))
|
|
vector_identifier = sql.Identifier(_safe_identifier(vector_field, 'vector field'))
|
|
document_id_identifier = sql.Identifier(_safe_identifier(document_id_field, 'document id field'))
|
|
metadata_sql = (
|
|
sql.Identifier(_safe_identifier(metadata_field, 'metadata field'))
|
|
if metadata_field
|
|
else sql.SQL("'{}'::jsonb")
|
|
)
|
|
|
|
with psycopg.connect(
|
|
connection.get('endpoint'),
|
|
row_factory=dict_row,
|
|
connect_timeout=config.get('timeout') or 30,
|
|
) as conn:
|
|
register_vector(conn)
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
sql.SQL(
|
|
"""
|
|
SELECT {document_id} AS id,
|
|
{content} AS content,
|
|
{metadata} AS metadata,
|
|
{vector_column} <=> %s AS distance
|
|
FROM {table_name}
|
|
WHERE {collection} = %s
|
|
ORDER BY distance ASC
|
|
LIMIT %s
|
|
"""
|
|
).format(
|
|
document_id=document_id_identifier,
|
|
content=content_identifier,
|
|
metadata=metadata_sql,
|
|
vector_column=vector_identifier,
|
|
table_name=table_identifier,
|
|
collection=collection_identifier,
|
|
),
|
|
(Vector(vector), collection_name, count),
|
|
)
|
|
return cur.fetchall()
|
|
|
|
rows = await asyncio.to_thread(_search)
|
|
mapping = {
|
|
'content_field': 'content',
|
|
'metadata_field': 'metadata',
|
|
'document_id_field': 'id',
|
|
'score_field': 'distance',
|
|
}
|
|
return [_normalize_result(row, mapping, knowledge, distance=row.get('distance')) for row in rows]
|
|
|
|
|
|
async def retrieve_external_knowledge(
|
|
request,
|
|
knowledge: KnowledgeModel,
|
|
queries: list[str],
|
|
count: int,
|
|
user=None,
|
|
) -> dict:
|
|
external = (knowledge.meta or {}).get('external', {})
|
|
connection_id = external.get('connection_id')
|
|
if not connection_id:
|
|
raise RuntimeError('External knowledge connection is not configured')
|
|
|
|
connection = await _get_external_connection(connection_id)
|
|
if not connection:
|
|
raise RuntimeError('External knowledge connection not found')
|
|
|
|
return await retrieve_external_knowledge_for_connection(request, knowledge, connection, queries, count, user=user)
|
|
|
|
|
|
async def retrieve_external_knowledge_for_connection(
|
|
request,
|
|
knowledge: KnowledgeModel,
|
|
connection: dict,
|
|
queries: list[str],
|
|
count: int,
|
|
user=None,
|
|
) -> dict:
|
|
auth_config = connection.get('auth_config') or {}
|
|
if not connection.get('enabled', True):
|
|
raise RuntimeError('External knowledge connection is disabled')
|
|
|
|
started_at = time.monotonic()
|
|
chunks = []
|
|
provider = (connection.get('provider') or '').lower()
|
|
|
|
retrieve = {
|
|
'qdrant': _retrieve_qdrant,
|
|
'milvus': _retrieve_milvus,
|
|
'pgvector': _retrieve_pgvector,
|
|
}.get(provider)
|
|
if not retrieve:
|
|
raise RuntimeError(f'Unsupported external knowledge provider: {connection.get("provider")}')
|
|
|
|
embedding_function = getattr(request.app.state, 'EMBEDDING_FUNCTION', None)
|
|
query_chunks = await asyncio.gather(
|
|
*(retrieve(connection, auth_config, knowledge, query, count, embedding_function) for query in queries)
|
|
)
|
|
|
|
# Score direction differs per provider and metric, so merge by rank
|
|
seen_contents = set()
|
|
for chunks_at_rank in zip_longest(*query_chunks):
|
|
for chunk in chunks_at_rank:
|
|
if chunk and chunk['content'] not in seen_contents:
|
|
seen_contents.add(chunk['content'])
|
|
chunks.append(chunk)
|
|
|
|
chunks = chunks[:count]
|
|
log.info(
|
|
'external_knowledge_retrieval knowledge_id=%s connection_id=%s provider=%s user_id=%s latency_ms=%s result_count=%s',
|
|
knowledge.id,
|
|
connection.get('id'),
|
|
connection.get('provider'),
|
|
getattr(user, 'id', None),
|
|
round((time.monotonic() - started_at) * 1000),
|
|
len(chunks),
|
|
)
|
|
|
|
return {
|
|
'documents': [[chunk['content'] for chunk in chunks]],
|
|
'metadatas': [[chunk['metadata'] for chunk in chunks]],
|
|
'distances': [[chunk['distance'] for chunk in chunks]],
|
|
}
|