2024-08-15 09:17:36 +08:00
#
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
2026-01-13 09:41:35 +08:00
import asyncio
2025-03-24 13:18:47 +08:00
import logging
2024-08-15 09:17:36 +08:00
import re
2025-03-24 13:18:47 +08:00
import time
2026-05-09 11:52:06 +09:00
import uuid
2024-08-15 09:17:36 +08:00
from copy import deepcopy
2026-05-09 11:52:06 +09:00
logger = logging . getLogger ( __name__ )
2025-05-19 19:34:05 +08:00
from datetime import datetime
2025-03-24 13:18:47 +08:00
from functools import partial
2024-09-09 12:08:50 +08:00
from timeit import default_timer as timer
2025-03-24 13:18:47 +08:00
from langfuse import Langfuse
2025-08-06 10:33:52 +08:00
from peewee import fn
2025-11-28 19:25:32 +08:00
from api . db . services . file_service import FileService
2025-11-05 08:01:39 +08:00
from common . constants import LLMType , ParserType , StatusEnum
2025-03-24 13:18:47 +08:00
from api . db . db_models import DB , Dialog
2024-08-15 09:17:36 +08:00
from api . db . services . common_service import CommonService
2026-01-28 13:29:34 +08:00
from api . db . services . doc_metadata_service import DocMetadataService
2024-08-15 09:17:36 +08:00
from api . db . services . knowledgebase_service import KnowledgebaseService
2025-03-24 13:18:47 +08:00
from api . db . services . langfuse_service import TenantLangfuseService
2025-08-13 16:41:01 +08:00
from api . db . services . llm_service import LLMBundle
2025-12-12 17:12:38 +08:00
from common . metadata_utils import apply_meta_data_filter
2026-04-30 18:13:27 +03:00
from api . utils . reference_metadata_utils import (
enrich_chunks_with_document_metadata ,
resolve_reference_metadata_preferences ,
)
2026-05-29 17:39:41 +08:00
from api . db . joint_services . tenant_model_service import get_tenant_default_model_by_type , get_model_config_from_provider_instance , get_model_type_by_name
2025-10-28 19:09:14 +08:00
from common . time_utils import current_timestamp , datetime_format
Feature rtl support (#13118)
### What problem does this PR solve?
This PR adds comprehensive **Right-to-Left (RTL) language support**,
primarily targeting Arabic and other RTL scripts (Hebrew, Persian, Urdu,
etc.).
Previously, RTL content had multiple rendering issues:
- Incorrect sentence splitting for Arabic punctuation in citation logic
- Misaligned text in chat messages and markdown components
- Improper positioning of blockquotes and “think” sections
- Incorrect table alignment
- Citation placement ambiguity in RTL prompts
- UI layout inconsistencies when mixing LTR and RTL text
This PR introduces backend and frontend improvements to properly detect,
render, and style RTL content while preserving existing LTR behavior.
#### Backend
- Updated sentence boundary regex in `rag/nlp/search.py` to include
Arabic punctuation:
- `،` (comma)
- `؛` (semicolon)
- `؟` (question mark)
- `۔` (Arabic full stop)
- Ensures citation insertion works correctly in RTL sentences.
- Updated citation prompt instructions to clarify citation placement
rules for RTL languages.
#### Frontend
- Introduced a new utility: `text-direction.ts`
- Detects text direction based on Unicode ranges.
- Supports Arabic, Hebrew, Syriac, Thaana, and related scripts.
- Provides `getDirAttribute()` for automatic `dir` assignment.
- Applied dynamic `dir` attributes across:
- Markdown rendering
- Chat messages
- Search results
- Tables
- Hover cards and reference popovers
- Added proper RTL styling in LESS:
- Text alignment adjustments
- Blockquote border flipping
- Section indentation correction
- Table direction switching
- Use of `<bdi>` for figure labels to prevent bidirectional conflicts
#### DevOps / Environment
- Added Windows backend launch script with retry handling.
- Updated dependency metadata.
- Adjusted development-only React debugging behavior.
---
### Type of change
- [x] Bug Fix (non-breaking change which fixes RTL rendering and
citation issues)
- [x] New Feature (non-breaking change which adds RTL detection and
dynamic direction handling)
---------
Co-authored-by: 6ba3i <isbaaoui09@gmail.com>
Co-authored-by: Ahmad Intisar <ahmadintisar@Ahmads-MacBook-M4-Pro.local>
Co-authored-by: Ahmad Intisar <168020872+ahmadintisar@users.noreply.github.com>
Co-authored-by: Liu An <asiro@qq.com>
2026-03-02 08:03:44 +03:00
from common . text_utils import normalize_arabic_digits
2026-01-29 14:23:26 +08:00
from rag . graphrag . general . mind_map_extractor import MindMapExtractor
2026-01-13 09:41:35 +08:00
from rag . advanced_rag import DeepResearcher
2025-02-26 15:40:52 +08:00
from rag . app . tag import label_question
2024-08-15 09:17:36 +08:00
from rag . nlp . search import index_name
2026-05-09 11:52:06 +09:00
from rag . prompts . generator import chunks_format , citation_prompt , cross_languages , full_question , kb_prompt , keyword_extraction , message_fit_in , PROMPT_JINJA_ENV , ASK_SUMMARY
2025-11-03 08:50:05 +08:00
from common . token_utils import num_tokens_from_string
2025-02-26 10:21:04 +08:00
from rag . utils . tavily_conn import Tavily
feat(tts): cache synthesized speech in Redis to avoid redundant calls (#14851)
## What problem does this PR solve?
Closes #12017.
TTS output is deterministic for a given `(model, text)` pair, so
re-running the same text through the same TTS model produces the same
bytes — yet `Canvas.tts` and `dialog_service.tts` re-synthesized on
every request. That's slow and wastes provider quota whenever the same
assistant response is replayed, shared across users, or repeated within
a session.
### Change
New helper `rag/utils/tts_cache.py` with `synthesize_with_cache(tts_mdl,
cleaned_text)`:
- **Key:** `tts:cache:{model_id}:{sha256(text)}` — separate namespace
per model, identical cleaned text reuses a single entry across both call
sites.
- **Value:** the hex-encoded audio blob both call sites already
returned. No format change for downstream consumers.
- **TTL:** 7 days by default, configurable via
`RAGFLOW_TTS_CACHE_TTL_SECONDS`.
- **Failure modes:** a Redis hiccup falls back to direct synthesis; a
failed synthesis still returns `None` (existing contract preserved).
[`Canvas.tts`](https://github.com/infiniflow/ragflow/blob/main/agent/canvas.py#L683-L724)
and
[`dialog_service.tts`](https://github.com/infiniflow/ragflow/blob/main/api/db/services/dialog_service.py#L1367-L1380)
now route through the helper; the per-file bytes-accumulation/hex-encode
loop has been removed in favor of one shared implementation.
## Type of change
- [x] New Feature (non-breaking change which adds functionality)
## Test plan
- [ ] **Cache hit, chat path:** Configure a dialog with TTS enabled, ask
the same question twice with `stream=false`. Verify the second response
returns the same `audio_binary` and that the second invocation doesn't
hit the TTS provider (e.g., observe provider-side logs / usage counters;
check no `LLMBundle.tts can't update token usage` log line on the second
run).
- [ ] **Cache hit, agent path:** Same exercise via a Conversational
Agent that includes a Message component playing back the answer.
- [ ] **Cache isolation per model:** Switch tenant's `tts_id` between
two models, run the same text against each — confirm the second model's
first synthesis still happens (no cross-model hits).
- [ ] **TTL override:** Set `RAGFLOW_TTS_CACHE_TTL_SECONDS=120`, confirm
the entry expires after 2 minutes.
- [ ] **Redis unavailable:** Stop Redis (or break the connection).
Verify the TTS endpoint still works — synthesis falls back to direct
calls, with a `TTS cache lookup failed` / `TTS cache store failed`
warning logged.
- [ ] **Failure path:** Configure a TTS model with an invalid API key,
ensure the response still returns successfully with `audio_binary=None`
(no regression vs. current behavior).
2026-05-18 23:20:40 -07:00
from rag . utils . tts_cache import synthesize_with_cache
2025-10-28 09:46:32 +08:00
from common . string_utils import remove_redundant_spaces
2025-11-06 09:36:38 +08:00
from common import settings
2024-08-15 09:17:36 +08:00
2026-04-30 18:13:27 +03:00
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 " )
2024-08-15 09:17:36 +08:00
2026-05-18 14:21:56 +08:00
async def _hydrate_chunk_vectors ( retriever , chunks , tenant_ids , kb_ids ) :
"""
Citation prep : on the ES backend the main retrieval call deliberately
skips fetching the chunk embedding . insert_citations needs it , so we
pull the vectors for just the candidate chunks right before computing
answer - vs - chunk similarity . Chunks without an ES chunk_id ( e . g . web
search results ) keep whatever placeholder they were given . Other
backends still carry vectors in the chunk , so we skip the round - trip .
"""
if settings . DOC_ENGINE_INFINITY or settings . DOC_ENGINE_OCEANBASE :
return
if not chunks :
return
dim = 0
for ck in chunks :
v = ck . get ( " vector " )
if isinstance ( v , list ) and v :
dim = len ( v )
break
if not dim :
return
# Skip chunks that already have a non-zero vector (e.g. parent chunks
# produced by retrieval_by_children copy the child vector inline).
chunk_ids = [ ]
for ck in chunks :
cid = ck . get ( " chunk_id " )
if not cid :
continue
v = ck . get ( " vector " ) or [ ]
if any ( x for x in v ) :
continue
chunk_ids . append ( cid )
if not chunk_ids :
return
try :
vectors = await retriever . fetch_chunk_vectors ( chunk_ids , tenant_ids , kb_ids , dim )
except Exception as e : # noqa: BLE001 - degrade gracefully on hydrate failure
logger . warning ( " fetch_chunk_vectors failed; citations will use placeholders: %s " , e )
return
if not vectors :
return
for ck in chunks :
cid = ck . get ( " chunk_id " )
if cid and cid in vectors :
ck [ " vector " ] = vectors [ cid ]
2026-04-14 04:55:20 -07:00
def _normalize_internet_flag ( value ) :
if isinstance ( value , bool ) :
return value
if isinstance ( value , ( int , float ) ) and value in ( 0 , 1 ) :
return bool ( value )
if isinstance ( value , str ) :
normalized = value . strip ( ) . lower ( )
if normalized in { " true " , " 1 " , " yes " , " on " } :
return True
if normalized in { " false " , " 0 " , " no " , " off " , " " } :
return False
return None
def _should_use_web_search ( prompt_config , internet = None ) :
if not prompt_config . get ( " tavily_api_key " ) :
return False
normalized = _normalize_internet_flag ( internet )
return normalized is True
2026-04-30 18:13:27 +03:00
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 )
2024-08-15 09:17:36 +08:00
class DialogService ( CommonService ) :
model = Dialog
2025-04-15 10:20:33 +08:00
@classmethod
def save ( cls , * * kwargs ) :
""" Save a new record to database.
This method creates a new record in the database with the provided field values ,
forcing an insert operation rather than an update .
Args :
* * kwargs : Record field values as keyword arguments .
Returns :
Model instance : The created record object .
"""
sample_obj = cls . model ( * * kwargs ) . save ( force_insert = True )
return sample_obj
@classmethod
def update_many_by_id ( cls , data_list ) :
""" Update multiple records by their IDs.
This method updates multiple records in the database , identified by their IDs .
It automatically updates the update_time and update_date fields for each record .
Args :
data_list ( list ) : List of dictionaries containing record data to update .
Each dictionary must include an ' id ' field .
"""
with DB . atomic ( ) :
for data in data_list :
data [ " update_time " ] = current_timestamp ( )
data [ " update_date " ] = datetime_format ( datetime . now ( ) )
cls . model . update ( data ) . where ( cls . model . id == data [ " id " ] ) . execute ( )
2024-10-12 13:48:43 +08:00
@classmethod
@DB.connection_context ( )
2025-03-24 13:18:47 +08:00
def get_list ( cls , tenant_id , page_number , items_per_page , orderby , desc , id , name ) :
2024-10-12 13:48:43 +08:00
chats = cls . model . select ( )
if id :
chats = chats . where ( cls . model . id == id )
if name :
chats = chats . where ( cls . model . name == name )
2025-03-24 13:18:47 +08:00
chats = chats . where ( ( cls . model . tenant_id == tenant_id ) & ( cls . model . status == StatusEnum . VALID . value ) )
2024-10-12 13:48:43 +08:00
if desc :
chats = chats . order_by ( cls . model . getter_by ( orderby ) . desc ( ) )
else :
chats = chats . order_by ( cls . model . getter_by ( orderby ) . asc ( ) )
chats = chats . paginate ( page_number , items_per_page )
return list ( chats . dicts ( ) )
2025-08-06 10:33:52 +08:00
@classmethod
@DB.connection_context ( )
2026-04-01 20:10:37 +08:00
def get_by_tenant_ids (
cls ,
joined_tenant_ids ,
user_id ,
page_number ,
items_per_page ,
orderby ,
desc ,
keywords ,
id = None ,
name = None ,
) :
2025-08-06 10:33:52 +08:00
from api . db . db_models import User
fields = [
cls . model . id ,
cls . model . tenant_id ,
cls . model . name ,
cls . model . description ,
cls . model . language ,
cls . model . llm_id ,
cls . model . llm_setting ,
cls . model . prompt_type ,
cls . model . prompt_config ,
cls . model . similarity_threshold ,
cls . model . vector_similarity_weight ,
cls . model . top_n ,
cls . model . top_k ,
cls . model . do_refer ,
cls . model . rerank_id ,
cls . model . kb_ids ,
2025-08-13 10:26:26 +08:00
cls . model . icon ,
2025-08-06 10:33:52 +08:00
cls . model . status ,
User . nickname ,
User . avatar . alias ( " tenant_avatar " ) ,
cls . model . update_time ,
cls . model . create_time ,
]
2026-04-01 20:10:37 +08:00
dialogs = (
cls . model . select ( * fields )
. join ( User , on = ( cls . model . tenant_id == User . id ) )
. where (
2026-05-09 11:52:06 +09:00
( cls . model . tenant_id . in_ ( joined_tenant_ids ) | ( cls . model . tenant_id == user_id ) ) & ( cls . model . status == StatusEnum . VALID . value ) ,
2026-04-01 11:05:29 +08:00
)
2026-04-01 20:10:37 +08:00
)
if id :
dialogs = dialogs . where ( cls . model . id == id )
if name :
dialogs = dialogs . where ( cls . model . name == name )
if keywords :
dialogs = dialogs . where ( fn . LOWER ( cls . model . name ) . contains ( keywords . lower ( ) ) )
2025-08-06 10:33:52 +08:00
if desc :
dialogs = dialogs . order_by ( cls . model . getter_by ( orderby ) . desc ( ) )
else :
dialogs = dialogs . order_by ( cls . model . getter_by ( orderby ) . asc ( ) )
count = dialogs . count ( )
if page_number and items_per_page :
dialogs = dialogs . paginate ( page_number , items_per_page )
return list ( dialogs . dicts ( ) ) , count
2025-09-29 10:16:13 +08:00
@classmethod
@DB.connection_context ( )
def get_all_dialogs_by_tenant_id ( cls , tenant_id ) :
fields = [ cls . model . id ]
dialogs = cls . model . select ( * fields ) . where ( cls . model . tenant_id == tenant_id )
dialogs . order_by ( cls . model . create_time . asc ( ) )
offset , limit = 0 , 100
res = [ ]
while True :
d_batch = dialogs . offset ( offset ) . limit ( limit )
_temp = list ( d_batch . dicts ( ) )
if not _temp :
break
res . extend ( _temp )
offset + = limit
return res
2025-08-06 10:33:52 +08:00
2026-03-05 17:27:17 +08:00
@classmethod
@DB.connection_context ( )
def get_null_tenant_llm_id_row ( cls ) :
2026-05-09 11:52:06 +09:00
fields = [ cls . model . id , cls . model . tenant_id , cls . model . llm_id ]
2026-03-05 17:27:17 +08:00
objs = cls . model . select ( * fields ) . where ( cls . model . tenant_llm_id . is_null ( ) )
return list ( objs )
@classmethod
@DB.connection_context ( )
def get_null_tenant_rerank_id_row ( cls ) :
2026-05-09 11:52:06 +09:00
fields = [ cls . model . id , cls . model . tenant_id , cls . model . rerank_id ]
2026-03-05 17:27:17 +08:00
objs = cls . model . select ( * fields ) . where ( cls . model . tenant_rerank_id . is_null ( ) )
return list ( objs )
2025-12-08 09:43:03 +08:00
2026-06-12 09:18:06 +07:00
async def async_chat_solo ( dialog , messages , stream = True , session_id = None ) :
2026-05-29 17:39:41 +08:00
llm_types = get_model_type_by_name ( dialog . tenant_id , dialog . llm_id )
2025-12-01 16:54:57 +08:00
attachments = " "
2026-02-11 09:47:33 +08:00
image_attachments = [ ]
image_files = [ ]
2025-12-01 16:54:57 +08:00
if " files " in messages [ - 1 ] :
2026-05-29 17:39:41 +08:00
if " chat " in llm_types :
2026-02-11 09:47:33 +08:00
text_attachments , image_attachments = split_file_attachments ( messages [ - 1 ] [ " files " ] )
else :
text_attachments , image_files = split_file_attachments ( messages [ - 1 ] [ " files " ] , raw = True )
attachments = " \n \n " . join ( text_attachments )
2026-05-09 11:52:06 +09:00
2026-04-16 20:37:01 +08:00
if dialog . llm_id :
2026-05-29 17:39:41 +08:00
model_config = get_model_config_from_provider_instance ( dialog . tenant_id , LLMType . CHAT , dialog . llm_id )
2026-04-16 20:37:01 +08:00
else :
model_config = get_tenant_default_model_by_type ( dialog . tenant_id , LLMType . CHAT )
2026-06-12 09:18:06 +07:00
chat_mdl = LLMBundle ( dialog . tenant_id , model_config , langfuse_session_id = session_id )
2026-03-05 17:27:17 +08:00
factory = model_config . get ( " llm_factory " , " " ) if model_config else " "
2025-02-21 12:24:02 +08:00
prompt_config = dialog . prompt_config
tts_mdl = None
if prompt_config . get ( " tts " ) :
2026-03-05 17:27:17 +08:00
default_tts_model = get_tenant_default_model_by_type ( dialog . tenant_id , LLMType . TTS )
2026-06-12 09:18:06 +07:00
tts_mdl = LLMBundle ( dialog . tenant_id , default_tts_model , trace_context = chat_mdl . trace_context , langfuse_session_id = session_id )
2025-03-24 13:18:47 +08:00
msg = [ { " role " : m [ " role " ] , " content " : re . sub ( r " ## \ d+ \ $ \ $ " , " " , m [ " content " ] ) } for m in messages if m [ " role " ] != " system " ]
2025-12-01 16:54:57 +08:00
if attachments and msg :
msg [ - 1 ] [ " content " ] + = attachments
2026-05-29 17:39:41 +08:00
if " chat " in llm_types and image_attachments :
2026-02-11 09:47:33 +08:00
convert_last_user_msg_to_multimodal ( msg , image_attachments , factory )
2025-02-21 12:24:02 +08:00
if stream :
2026-05-29 17:39:41 +08:00
if " chat " in llm_types :
2026-02-11 09:47:33 +08:00
stream_iter = chat_mdl . async_chat_streamly_delta ( prompt_config . get ( " system " , " " ) , msg , dialog . llm_setting )
else :
stream_iter = chat_mdl . async_chat_streamly_delta ( prompt_config . get ( " system " , " " ) , msg , dialog . llm_setting , images = image_files )
2026-01-08 13:34:16 +08:00
async for kind , value , state in _stream_with_think_delta ( stream_iter ) :
if kind == " marker " :
flags = { " start_to_think " : True } if value == " <think> " else { " end_to_think " : True }
yield { " answer " : " " , " reference " : { } , " audio_binary " : None , " prompt " : " " , " created_at " : time . time ( ) , " final " : False , * * flags }
2025-02-21 12:24:02 +08:00
continue
2026-01-08 13:34:16 +08:00
yield { " answer " : value , " reference " : { } , " audio_binary " : tts ( tts_mdl , value ) , " prompt " : " " , " created_at " : time . time ( ) , " final " : False }
2025-02-21 12:24:02 +08:00
else :
2026-05-29 17:39:41 +08:00
if " chat " in llm_types :
2026-02-11 09:47:33 +08:00
answer = await chat_mdl . async_chat ( prompt_config . get ( " system " , " " ) , msg , dialog . llm_setting )
else :
answer = await chat_mdl . async_chat ( prompt_config . get ( " system " , " " ) , msg , dialog . llm_setting , images = image_files )
2025-02-21 12:24:02 +08:00
user_content = msg [ - 1 ] . get ( " content " , " [content not available] " )
logging . debug ( " User: {} |Assistant: {} " . format ( user_content , answer ) )
yield { " answer " : answer , " reference " : { } , " audio_binary " : tts ( tts_mdl , answer ) , " prompt " : " " , " created_at " : time . time ( ) }
2026-06-12 09:18:06 +07:00
def get_models ( dialog , trace_context = None , langfuse_session_id = None ) :
2025-06-05 13:00:43 +08:00
embd_mdl , chat_mdl , rerank_mdl , tts_mdl = None , None , None , None
kbs = KnowledgebaseService . get_by_ids ( dialog . kb_ids )
embedding_list = list ( set ( [ kb . embd_id for kb in kbs ] ) )
if len ( embedding_list ) > 1 :
raise Exception ( " **ERROR**: Knowledge bases use different embedding models. " )
if embedding_list :
2026-03-26 21:01:07 +08:00
embd_owner_tenant_id = kbs [ 0 ] . tenant_id
2026-05-29 17:39:41 +08:00
embd_model_config = get_model_config_from_provider_instance ( embd_owner_tenant_id , LLMType . EMBEDDING , embedding_list [ 0 ] )
2026-06-12 09:18:06 +07:00
embd_mdl = LLMBundle ( embd_owner_tenant_id , embd_model_config , trace_context = trace_context , langfuse_session_id = langfuse_session_id )
2025-06-05 13:00:43 +08:00
if not embd_mdl :
raise LookupError ( " Embedding model( %s ) not found " % embedding_list [ 0 ] )
2026-04-16 20:37:01 +08:00
if dialog . llm_id :
2026-05-29 17:39:41 +08:00
chat_model_config = get_model_config_from_provider_instance ( dialog . tenant_id , LLMType . CHAT , dialog . llm_id )
2025-06-05 13:00:43 +08:00
else :
2026-03-05 17:27:17 +08:00
chat_model_config = get_tenant_default_model_by_type ( dialog . tenant_id , LLMType . CHAT )
2026-06-12 09:18:06 +07:00
chat_mdl = LLMBundle ( dialog . tenant_id , chat_model_config , trace_context = trace_context , langfuse_session_id = langfuse_session_id )
2025-06-05 13:00:43 +08:00
if dialog . rerank_id :
2026-05-29 17:39:41 +08:00
rerank_model_config = get_model_config_from_provider_instance ( dialog . tenant_id , LLMType . RERANK , dialog . rerank_id )
2026-06-12 09:18:06 +07:00
rerank_mdl = LLMBundle ( dialog . tenant_id , rerank_model_config , trace_context = trace_context , langfuse_session_id = langfuse_session_id )
2025-06-05 13:00:43 +08:00
if dialog . prompt_config . get ( " tts " ) :
2026-03-05 17:27:17 +08:00
default_tts_model_config = get_tenant_default_model_by_type ( dialog . tenant_id , LLMType . TTS )
2026-06-12 09:18:06 +07:00
tts_mdl = LLMBundle ( dialog . tenant_id , default_tts_model_config , trace_context = trace_context , langfuse_session_id = langfuse_session_id )
2025-06-05 13:00:43 +08:00
return kbs , embd_mdl , rerank_mdl , chat_mdl , tts_mdl
2026-02-11 09:47:33 +08:00
def split_file_attachments ( files : list [ dict ] | None , raw : bool = False ) - > tuple [ list [ str ] , list [ str ] | list [ dict ] ] :
if not files :
return [ ] , [ ]
text_attachments = [ ]
if raw :
file_contents , image_files = FileService . get_files ( files , raw = True )
for content in file_contents :
if not isinstance ( content , str ) :
content = str ( content )
text_attachments . append ( content )
return text_attachments , image_files
image_attachments = [ ]
for content in FileService . get_files ( files , raw = False ) :
if not isinstance ( content , str ) :
content = str ( content )
if content . strip ( ) . startswith ( " data: " ) :
image_attachments . append ( content . strip ( ) )
continue
text_attachments . append ( content )
return text_attachments , image_attachments
_DATA_URI_RE = re . compile ( r " ^data:(?P<mime>[^;]+);base64,(?P<b64>[A-Za-z0-9+/= \ s]+)$ " )
def _parse_data_uri_or_b64 ( s : str , default_mime : str = " image/png " ) - > tuple [ str , str ] :
s = ( s or " " ) . strip ( )
match = _DATA_URI_RE . match ( s )
if match :
mime = match . group ( " mime " ) . strip ( )
b64 = match . group ( " b64 " ) . strip ( )
return mime , b64
return default_mime , s
def _normalize_text_from_content ( content ) - > str :
if content is None :
return " "
if isinstance ( content , str ) :
return content
if isinstance ( content , list ) :
texts = [ ]
for blk in content :
if isinstance ( blk , dict ) :
if blk . get ( " type " ) in { " text " , " input_text " } :
txt = blk . get ( " text " )
if txt :
texts . append ( str ( txt ) )
elif " text " in blk and isinstance ( blk . get ( " text " ) , ( str , int , float ) ) :
texts . append ( str ( blk [ " text " ] ) )
return " \n " . join ( texts ) . strip ( )
return str ( content )
def convert_last_user_msg_to_multimodal ( msg : list [ dict ] , image_data_uris : list [ str ] , factory : str ) - > None :
if not msg or not image_data_uris :
return
factory_norm = ( factory or " " ) . strip ( ) . lower ( )
for idx in range ( len ( msg ) - 1 , - 1 , - 1 ) :
if msg [ idx ] . get ( " role " ) != " user " :
continue
original_content = msg [ idx ] . get ( " content " , " " )
text = _normalize_text_from_content ( original_content )
if factory_norm == " gemini " :
parts = [ ]
if text :
parts . append ( { " text " : text } )
for image in image_data_uris :
mime , b64 = _parse_data_uri_or_b64 ( str ( image ) , default_mime = " image/png " )
parts . append ( { " inline_data " : { " mime_type " : mime , " data " : b64 } } )
msg [ idx ] [ " content " ] = parts
return
if factory_norm == " anthropic " :
blocks = [ ]
if text :
blocks . append ( { " type " : " text " , " text " : text } )
for image in image_data_uris :
mime , b64 = _parse_data_uri_or_b64 ( str ( image ) , default_mime = " image/png " )
blocks . append (
{
" type " : " image " ,
" source " : { " type " : " base64 " , " media_type " : mime , " data " : b64 } ,
}
)
msg [ idx ] [ " content " ] = blocks
return
multimodal_content = [ ]
if isinstance ( original_content , list ) :
multimodal_content = deepcopy ( original_content )
else :
text_content = " " if original_content is None else str ( original_content )
if text_content :
multimodal_content . append ( { " type " : " text " , " text " : text_content } )
for data_uri in image_data_uris :
image_url = data_uri
if not isinstance ( image_url , str ) :
image_url = str ( image_url )
if not image_url . startswith ( " data: " ) :
image_url = f " data:image/png;base64, { image_url } "
multimodal_content . append ( { " type " : " image_url " , " image_url " : { " url " : image_url } } )
msg [ idx ] [ " content " ] = multimodal_content
return
2025-05-29 10:03:51 +08:00
BAD_CITATION_PATTERNS = [
re . compile ( r " \ ( \ s*ID \ s*[: ]* \ s*( \ d+) \ s* \ ) " ) , # (ID: 12)
re . compile ( r " \ [ \ s*ID \ s*[: ]* \ s*( \ d+) \ s* \ ] " ) , # [ID: 12]
re . compile ( r " 【 \ s*ID \ s*[: ]* \ s*( \ d+) \ s*】 " ) , # 【ID: 12】
re . compile ( r " ref \ s*( \ d+) " , flags = re . IGNORECASE ) , # ref12、REF 12
]
Feature rtl support (#13118)
### What problem does this PR solve?
This PR adds comprehensive **Right-to-Left (RTL) language support**,
primarily targeting Arabic and other RTL scripts (Hebrew, Persian, Urdu,
etc.).
Previously, RTL content had multiple rendering issues:
- Incorrect sentence splitting for Arabic punctuation in citation logic
- Misaligned text in chat messages and markdown components
- Improper positioning of blockquotes and “think” sections
- Incorrect table alignment
- Citation placement ambiguity in RTL prompts
- UI layout inconsistencies when mixing LTR and RTL text
This PR introduces backend and frontend improvements to properly detect,
render, and style RTL content while preserving existing LTR behavior.
#### Backend
- Updated sentence boundary regex in `rag/nlp/search.py` to include
Arabic punctuation:
- `،` (comma)
- `؛` (semicolon)
- `؟` (question mark)
- `۔` (Arabic full stop)
- Ensures citation insertion works correctly in RTL sentences.
- Updated citation prompt instructions to clarify citation placement
rules for RTL languages.
#### Frontend
- Introduced a new utility: `text-direction.ts`
- Detects text direction based on Unicode ranges.
- Supports Arabic, Hebrew, Syriac, Thaana, and related scripts.
- Provides `getDirAttribute()` for automatic `dir` assignment.
- Applied dynamic `dir` attributes across:
- Markdown rendering
- Chat messages
- Search results
- Tables
- Hover cards and reference popovers
- Added proper RTL styling in LESS:
- Text alignment adjustments
- Blockquote border flipping
- Section indentation correction
- Table direction switching
- Use of `<bdi>` for figure labels to prevent bidirectional conflicts
#### DevOps / Environment
- Added Windows backend launch script with retry handling.
- Updated dependency metadata.
- Adjusted development-only React debugging behavior.
---
### Type of change
- [x] Bug Fix (non-breaking change which fixes RTL rendering and
citation issues)
- [x] New Feature (non-breaking change which adds RTL detection and
dynamic direction handling)
---------
Co-authored-by: 6ba3i <isbaaoui09@gmail.com>
Co-authored-by: Ahmad Intisar <ahmadintisar@Ahmads-MacBook-M4-Pro.local>
Co-authored-by: Ahmad Intisar <168020872+ahmadintisar@users.noreply.github.com>
Co-authored-by: Liu An <asiro@qq.com>
2026-03-02 08:03:44 +03:00
CITATION_MARKER_PATTERN = re . compile ( r " \ [(?:ID:)?([0-9 \ u0660- \ u0669 \ u06F0- \ u06F9]+) \ ] " )
2025-05-29 10:03:51 +08:00
2025-06-18 16:45:42 +08:00
2025-06-05 13:00:43 +08:00
def repair_bad_citation_formats ( answer : str , kbinfos : dict , idx : set ) :
max_index = len ( kbinfos [ " chunks " ] )
Feature rtl support (#13118)
### What problem does this PR solve?
This PR adds comprehensive **Right-to-Left (RTL) language support**,
primarily targeting Arabic and other RTL scripts (Hebrew, Persian, Urdu,
etc.).
Previously, RTL content had multiple rendering issues:
- Incorrect sentence splitting for Arabic punctuation in citation logic
- Misaligned text in chat messages and markdown components
- Improper positioning of blockquotes and “think” sections
- Incorrect table alignment
- Citation placement ambiguity in RTL prompts
- UI layout inconsistencies when mixing LTR and RTL text
This PR introduces backend and frontend improvements to properly detect,
render, and style RTL content while preserving existing LTR behavior.
#### Backend
- Updated sentence boundary regex in `rag/nlp/search.py` to include
Arabic punctuation:
- `،` (comma)
- `؛` (semicolon)
- `؟` (question mark)
- `۔` (Arabic full stop)
- Ensures citation insertion works correctly in RTL sentences.
- Updated citation prompt instructions to clarify citation placement
rules for RTL languages.
#### Frontend
- Introduced a new utility: `text-direction.ts`
- Detects text direction based on Unicode ranges.
- Supports Arabic, Hebrew, Syriac, Thaana, and related scripts.
- Provides `getDirAttribute()` for automatic `dir` assignment.
- Applied dynamic `dir` attributes across:
- Markdown rendering
- Chat messages
- Search results
- Tables
- Hover cards and reference popovers
- Added proper RTL styling in LESS:
- Text alignment adjustments
- Blockquote border flipping
- Section indentation correction
- Table direction switching
- Use of `<bdi>` for figure labels to prevent bidirectional conflicts
#### DevOps / Environment
- Added Windows backend launch script with retry handling.
- Updated dependency metadata.
- Adjusted development-only React debugging behavior.
---
### Type of change
- [x] Bug Fix (non-breaking change which fixes RTL rendering and
citation issues)
- [x] New Feature (non-breaking change which adds RTL detection and
dynamic direction handling)
---------
Co-authored-by: 6ba3i <isbaaoui09@gmail.com>
Co-authored-by: Ahmad Intisar <ahmadintisar@Ahmads-MacBook-M4-Pro.local>
Co-authored-by: Ahmad Intisar <168020872+ahmadintisar@users.noreply.github.com>
Co-authored-by: Liu An <asiro@qq.com>
2026-03-02 08:03:44 +03:00
normalized_answer = normalize_arabic_digits ( answer ) or " "
2025-06-05 13:00:43 +08:00
def safe_add ( i ) :
if 0 < = i < max_index :
idx . add ( i )
return True
return False
Feature rtl support (#13118)
### What problem does this PR solve?
This PR adds comprehensive **Right-to-Left (RTL) language support**,
primarily targeting Arabic and other RTL scripts (Hebrew, Persian, Urdu,
etc.).
Previously, RTL content had multiple rendering issues:
- Incorrect sentence splitting for Arabic punctuation in citation logic
- Misaligned text in chat messages and markdown components
- Improper positioning of blockquotes and “think” sections
- Incorrect table alignment
- Citation placement ambiguity in RTL prompts
- UI layout inconsistencies when mixing LTR and RTL text
This PR introduces backend and frontend improvements to properly detect,
render, and style RTL content while preserving existing LTR behavior.
#### Backend
- Updated sentence boundary regex in `rag/nlp/search.py` to include
Arabic punctuation:
- `،` (comma)
- `؛` (semicolon)
- `؟` (question mark)
- `۔` (Arabic full stop)
- Ensures citation insertion works correctly in RTL sentences.
- Updated citation prompt instructions to clarify citation placement
rules for RTL languages.
#### Frontend
- Introduced a new utility: `text-direction.ts`
- Detects text direction based on Unicode ranges.
- Supports Arabic, Hebrew, Syriac, Thaana, and related scripts.
- Provides `getDirAttribute()` for automatic `dir` assignment.
- Applied dynamic `dir` attributes across:
- Markdown rendering
- Chat messages
- Search results
- Tables
- Hover cards and reference popovers
- Added proper RTL styling in LESS:
- Text alignment adjustments
- Blockquote border flipping
- Section indentation correction
- Table direction switching
- Use of `<bdi>` for figure labels to prevent bidirectional conflicts
#### DevOps / Environment
- Added Windows backend launch script with retry handling.
- Updated dependency metadata.
- Adjusted development-only React debugging behavior.
---
### Type of change
- [x] Bug Fix (non-breaking change which fixes RTL rendering and
citation issues)
- [x] New Feature (non-breaking change which adds RTL detection and
dynamic direction handling)
---------
Co-authored-by: 6ba3i <isbaaoui09@gmail.com>
Co-authored-by: Ahmad Intisar <ahmadintisar@Ahmads-MacBook-M4-Pro.local>
Co-authored-by: Ahmad Intisar <168020872+ahmadintisar@users.noreply.github.com>
Co-authored-by: Liu An <asiro@qq.com>
2026-03-02 08:03:44 +03:00
def find_and_replace ( pattern , group_index = 1 , repl = lambda digits : f " ID: { digits } " ) :
2025-06-05 13:00:43 +08:00
nonlocal answer
Feature rtl support (#13118)
### What problem does this PR solve?
This PR adds comprehensive **Right-to-Left (RTL) language support**,
primarily targeting Arabic and other RTL scripts (Hebrew, Persian, Urdu,
etc.).
Previously, RTL content had multiple rendering issues:
- Incorrect sentence splitting for Arabic punctuation in citation logic
- Misaligned text in chat messages and markdown components
- Improper positioning of blockquotes and “think” sections
- Incorrect table alignment
- Citation placement ambiguity in RTL prompts
- UI layout inconsistencies when mixing LTR and RTL text
This PR introduces backend and frontend improvements to properly detect,
render, and style RTL content while preserving existing LTR behavior.
#### Backend
- Updated sentence boundary regex in `rag/nlp/search.py` to include
Arabic punctuation:
- `،` (comma)
- `؛` (semicolon)
- `؟` (question mark)
- `۔` (Arabic full stop)
- Ensures citation insertion works correctly in RTL sentences.
- Updated citation prompt instructions to clarify citation placement
rules for RTL languages.
#### Frontend
- Introduced a new utility: `text-direction.ts`
- Detects text direction based on Unicode ranges.
- Supports Arabic, Hebrew, Syriac, Thaana, and related scripts.
- Provides `getDirAttribute()` for automatic `dir` assignment.
- Applied dynamic `dir` attributes across:
- Markdown rendering
- Chat messages
- Search results
- Tables
- Hover cards and reference popovers
- Added proper RTL styling in LESS:
- Text alignment adjustments
- Blockquote border flipping
- Section indentation correction
- Table direction switching
- Use of `<bdi>` for figure labels to prevent bidirectional conflicts
#### DevOps / Environment
- Added Windows backend launch script with retry handling.
- Updated dependency metadata.
- Adjusted development-only React debugging behavior.
---
### Type of change
- [x] Bug Fix (non-breaking change which fixes RTL rendering and
citation issues)
- [x] New Feature (non-breaking change which adds RTL detection and
dynamic direction handling)
---------
Co-authored-by: 6ba3i <isbaaoui09@gmail.com>
Co-authored-by: Ahmad Intisar <ahmadintisar@Ahmads-MacBook-M4-Pro.local>
Co-authored-by: Ahmad Intisar <168020872+ahmadintisar@users.noreply.github.com>
Co-authored-by: Liu An <asiro@qq.com>
2026-03-02 08:03:44 +03:00
nonlocal normalized_answer
2025-06-05 13:00:43 +08:00
Feature rtl support (#13118)
### What problem does this PR solve?
This PR adds comprehensive **Right-to-Left (RTL) language support**,
primarily targeting Arabic and other RTL scripts (Hebrew, Persian, Urdu,
etc.).
Previously, RTL content had multiple rendering issues:
- Incorrect sentence splitting for Arabic punctuation in citation logic
- Misaligned text in chat messages and markdown components
- Improper positioning of blockquotes and “think” sections
- Incorrect table alignment
- Citation placement ambiguity in RTL prompts
- UI layout inconsistencies when mixing LTR and RTL text
This PR introduces backend and frontend improvements to properly detect,
render, and style RTL content while preserving existing LTR behavior.
#### Backend
- Updated sentence boundary regex in `rag/nlp/search.py` to include
Arabic punctuation:
- `،` (comma)
- `؛` (semicolon)
- `؟` (question mark)
- `۔` (Arabic full stop)
- Ensures citation insertion works correctly in RTL sentences.
- Updated citation prompt instructions to clarify citation placement
rules for RTL languages.
#### Frontend
- Introduced a new utility: `text-direction.ts`
- Detects text direction based on Unicode ranges.
- Supports Arabic, Hebrew, Syriac, Thaana, and related scripts.
- Provides `getDirAttribute()` for automatic `dir` assignment.
- Applied dynamic `dir` attributes across:
- Markdown rendering
- Chat messages
- Search results
- Tables
- Hover cards and reference popovers
- Added proper RTL styling in LESS:
- Text alignment adjustments
- Blockquote border flipping
- Section indentation correction
- Table direction switching
- Use of `<bdi>` for figure labels to prevent bidirectional conflicts
#### DevOps / Environment
- Added Windows backend launch script with retry handling.
- Updated dependency metadata.
- Adjusted development-only React debugging behavior.
---
### Type of change
- [x] Bug Fix (non-breaking change which fixes RTL rendering and
citation issues)
- [x] New Feature (non-breaking change which adds RTL detection and
dynamic direction handling)
---------
Co-authored-by: 6ba3i <isbaaoui09@gmail.com>
Co-authored-by: Ahmad Intisar <ahmadintisar@Ahmads-MacBook-M4-Pro.local>
Co-authored-by: Ahmad Intisar <168020872+ahmadintisar@users.noreply.github.com>
Co-authored-by: Liu An <asiro@qq.com>
2026-03-02 08:03:44 +03:00
matches = list ( pattern . finditer ( normalized_answer ) )
if not matches :
return
parts = [ ]
last_idx = 0
for match in matches :
2026-05-09 11:52:06 +09:00
parts . append ( answer [ last_idx : match . start ( ) ] )
2025-06-05 13:00:43 +08:00
try :
i = int ( match . group ( group_index ) )
except Exception :
2026-05-09 11:52:06 +09:00
parts . append ( answer [ match . start ( ) : match . end ( ) ] )
Feature rtl support (#13118)
### What problem does this PR solve?
This PR adds comprehensive **Right-to-Left (RTL) language support**,
primarily targeting Arabic and other RTL scripts (Hebrew, Persian, Urdu,
etc.).
Previously, RTL content had multiple rendering issues:
- Incorrect sentence splitting for Arabic punctuation in citation logic
- Misaligned text in chat messages and markdown components
- Improper positioning of blockquotes and “think” sections
- Incorrect table alignment
- Citation placement ambiguity in RTL prompts
- UI layout inconsistencies when mixing LTR and RTL text
This PR introduces backend and frontend improvements to properly detect,
render, and style RTL content while preserving existing LTR behavior.
#### Backend
- Updated sentence boundary regex in `rag/nlp/search.py` to include
Arabic punctuation:
- `،` (comma)
- `؛` (semicolon)
- `؟` (question mark)
- `۔` (Arabic full stop)
- Ensures citation insertion works correctly in RTL sentences.
- Updated citation prompt instructions to clarify citation placement
rules for RTL languages.
#### Frontend
- Introduced a new utility: `text-direction.ts`
- Detects text direction based on Unicode ranges.
- Supports Arabic, Hebrew, Syriac, Thaana, and related scripts.
- Provides `getDirAttribute()` for automatic `dir` assignment.
- Applied dynamic `dir` attributes across:
- Markdown rendering
- Chat messages
- Search results
- Tables
- Hover cards and reference popovers
- Added proper RTL styling in LESS:
- Text alignment adjustments
- Blockquote border flipping
- Section indentation correction
- Table direction switching
- Use of `<bdi>` for figure labels to prevent bidirectional conflicts
#### DevOps / Environment
- Added Windows backend launch script with retry handling.
- Updated dependency metadata.
- Adjusted development-only React debugging behavior.
---
### Type of change
- [x] Bug Fix (non-breaking change which fixes RTL rendering and
citation issues)
- [x] New Feature (non-breaking change which adds RTL detection and
dynamic direction handling)
---------
Co-authored-by: 6ba3i <isbaaoui09@gmail.com>
Co-authored-by: Ahmad Intisar <ahmadintisar@Ahmads-MacBook-M4-Pro.local>
Co-authored-by: Ahmad Intisar <168020872+ahmadintisar@users.noreply.github.com>
Co-authored-by: Liu An <asiro@qq.com>
2026-03-02 08:03:44 +03:00
last_idx = match . end ( )
continue
if safe_add ( i ) :
digit_start , digit_end = match . span ( group_index )
digits_original = answer [ digit_start : digit_end ]
parts . append ( f " [ { repl ( digits_original ) } ] " )
else :
2026-05-09 11:52:06 +09:00
parts . append ( answer [ match . start ( ) : match . end ( ) ] )
Feature rtl support (#13118)
### What problem does this PR solve?
This PR adds comprehensive **Right-to-Left (RTL) language support**,
primarily targeting Arabic and other RTL scripts (Hebrew, Persian, Urdu,
etc.).
Previously, RTL content had multiple rendering issues:
- Incorrect sentence splitting for Arabic punctuation in citation logic
- Misaligned text in chat messages and markdown components
- Improper positioning of blockquotes and “think” sections
- Incorrect table alignment
- Citation placement ambiguity in RTL prompts
- UI layout inconsistencies when mixing LTR and RTL text
This PR introduces backend and frontend improvements to properly detect,
render, and style RTL content while preserving existing LTR behavior.
#### Backend
- Updated sentence boundary regex in `rag/nlp/search.py` to include
Arabic punctuation:
- `،` (comma)
- `؛` (semicolon)
- `؟` (question mark)
- `۔` (Arabic full stop)
- Ensures citation insertion works correctly in RTL sentences.
- Updated citation prompt instructions to clarify citation placement
rules for RTL languages.
#### Frontend
- Introduced a new utility: `text-direction.ts`
- Detects text direction based on Unicode ranges.
- Supports Arabic, Hebrew, Syriac, Thaana, and related scripts.
- Provides `getDirAttribute()` for automatic `dir` assignment.
- Applied dynamic `dir` attributes across:
- Markdown rendering
- Chat messages
- Search results
- Tables
- Hover cards and reference popovers
- Added proper RTL styling in LESS:
- Text alignment adjustments
- Blockquote border flipping
- Section indentation correction
- Table direction switching
- Use of `<bdi>` for figure labels to prevent bidirectional conflicts
#### DevOps / Environment
- Added Windows backend launch script with retry handling.
- Updated dependency metadata.
- Adjusted development-only React debugging behavior.
---
### Type of change
- [x] Bug Fix (non-breaking change which fixes RTL rendering and
citation issues)
- [x] New Feature (non-breaking change which adds RTL detection and
dynamic direction handling)
---------
Co-authored-by: 6ba3i <isbaaoui09@gmail.com>
Co-authored-by: Ahmad Intisar <ahmadintisar@Ahmads-MacBook-M4-Pro.local>
Co-authored-by: Ahmad Intisar <168020872+ahmadintisar@users.noreply.github.com>
Co-authored-by: Liu An <asiro@qq.com>
2026-03-02 08:03:44 +03:00
last_idx = match . end ( )
2025-06-05 13:00:43 +08:00
Feature rtl support (#13118)
### What problem does this PR solve?
This PR adds comprehensive **Right-to-Left (RTL) language support**,
primarily targeting Arabic and other RTL scripts (Hebrew, Persian, Urdu,
etc.).
Previously, RTL content had multiple rendering issues:
- Incorrect sentence splitting for Arabic punctuation in citation logic
- Misaligned text in chat messages and markdown components
- Improper positioning of blockquotes and “think” sections
- Incorrect table alignment
- Citation placement ambiguity in RTL prompts
- UI layout inconsistencies when mixing LTR and RTL text
This PR introduces backend and frontend improvements to properly detect,
render, and style RTL content while preserving existing LTR behavior.
#### Backend
- Updated sentence boundary regex in `rag/nlp/search.py` to include
Arabic punctuation:
- `،` (comma)
- `؛` (semicolon)
- `؟` (question mark)
- `۔` (Arabic full stop)
- Ensures citation insertion works correctly in RTL sentences.
- Updated citation prompt instructions to clarify citation placement
rules for RTL languages.
#### Frontend
- Introduced a new utility: `text-direction.ts`
- Detects text direction based on Unicode ranges.
- Supports Arabic, Hebrew, Syriac, Thaana, and related scripts.
- Provides `getDirAttribute()` for automatic `dir` assignment.
- Applied dynamic `dir` attributes across:
- Markdown rendering
- Chat messages
- Search results
- Tables
- Hover cards and reference popovers
- Added proper RTL styling in LESS:
- Text alignment adjustments
- Blockquote border flipping
- Section indentation correction
- Table direction switching
- Use of `<bdi>` for figure labels to prevent bidirectional conflicts
#### DevOps / Environment
- Added Windows backend launch script with retry handling.
- Updated dependency metadata.
- Adjusted development-only React debugging behavior.
---
### Type of change
- [x] Bug Fix (non-breaking change which fixes RTL rendering and
citation issues)
- [x] New Feature (non-breaking change which adds RTL detection and
dynamic direction handling)
---------
Co-authored-by: 6ba3i <isbaaoui09@gmail.com>
Co-authored-by: Ahmad Intisar <ahmadintisar@Ahmads-MacBook-M4-Pro.local>
Co-authored-by: Ahmad Intisar <168020872+ahmadintisar@users.noreply.github.com>
Co-authored-by: Liu An <asiro@qq.com>
2026-03-02 08:03:44 +03:00
parts . append ( answer [ last_idx : ] )
answer = " " . join ( parts )
normalized_answer = normalize_arabic_digits ( answer ) or " "
2025-06-05 13:00:43 +08:00
for pattern in BAD_CITATION_PATTERNS :
find_and_replace ( pattern )
return answer , idx
2025-05-29 10:03:51 +08:00
2025-12-08 09:43:03 +08:00
async def async_chat ( dialog , messages , stream = True , * * kwargs ) :
2026-01-19 19:35:14 +08:00
logging . debug ( " Begin async_chat " )
2024-08-15 09:17:36 +08:00
assert messages [ - 1 ] [ " role " ] == " user " , " The last content of this conversation is not from user. "
2026-06-12 09:18:06 +07:00
session_id = kwargs . get ( " session_id " )
2026-04-14 04:55:20 -07:00
use_web_search = _should_use_web_search ( dialog . prompt_config , kwargs . get ( " internet " ) )
logging . debug ( " web_search kb= %s tavily= %s internet= %r enabled= %s " , bool ( dialog . kb_ids ) , bool ( dialog . prompt_config . get ( " tavily_api_key " ) ) , kwargs . get ( " internet " ) , use_web_search )
if not dialog . kb_ids and not use_web_search :
2026-06-12 09:18:06 +07:00
async for ans in async_chat_solo ( dialog , messages , stream , session_id = session_id ) :
2025-02-21 12:24:02 +08:00
yield ans
2025-12-08 09:43:03 +08:00
return
2024-12-19 18:13:33 +08:00
chat_start_ts = timer ( )
2026-05-29 17:39:41 +08:00
if dialog . llm_id :
llm_types = get_model_type_by_name ( dialog . tenant_id , dialog . llm_id )
if " image2text " in llm_types :
llm_model_config = get_model_config_from_provider_instance ( dialog . tenant_id , LLMType . IMAGE2TEXT , dialog . llm_id )
else :
llm_model_config = get_model_config_from_provider_instance ( dialog . tenant_id , LLMType . CHAT , dialog . llm_id )
2024-08-15 09:17:36 +08:00
else :
2026-05-29 17:39:41 +08:00
llm_model_config = get_tenant_default_model_by_type ( dialog . tenant_id , LLMType . CHAT )
2025-02-18 13:42:22 +08:00
2026-02-11 09:47:33 +08:00
factory = llm_model_config . get ( " llm_factory " , " " ) if llm_model_config else " "
2025-02-18 13:42:22 +08:00
max_tokens = llm_model_config . get ( " max_tokens " , 8192 )
2024-12-19 18:13:33 +08:00
check_llm_ts = timer ( )
2025-03-24 13:18:47 +08:00
langfuse_tracer = None
2026-05-20 04:01:19 -03:00
langfuse_generation = None
2025-08-04 14:45:43 +08:00
trace_context = { }
2025-03-24 13:18:47 +08:00
langfuse_keys = TenantLangfuseService . filter_by_tenant ( tenant_id = dialog . tenant_id )
if langfuse_keys :
langfuse = Langfuse ( public_key = langfuse_keys . public_key , secret_key = langfuse_keys . secret_key , host = langfuse_keys . host )
2026-01-14 19:23:15 -08:00
try :
if langfuse . auth_check ( ) :
langfuse_tracer = langfuse
trace_id = langfuse_tracer . create_trace_id ( )
trace_context = { " trace_id " : trace_id }
except Exception :
# Skip langfuse tracing if connection fails
pass
2025-03-24 13:18:47 +08:00
check_langfuse_tracer_ts = timer ( )
2026-06-12 09:18:06 +07:00
kbs , embd_mdl , rerank_mdl , chat_mdl , tts_mdl = get_models ( dialog , trace_context = trace_context , langfuse_session_id = session_id )
2025-06-05 13:00:43 +08:00
toolcall_session , tools = kwargs . get ( " toolcall_session " ) , kwargs . get ( " tools " )
if toolcall_session and tools :
chat_mdl . bind_tools ( toolcall_session , tools )
bind_models_ts = timer ( )
2024-12-19 18:13:33 +08:00
2025-11-06 09:36:38 +08:00
retriever = settings . retriever
2024-08-15 09:17:36 +08:00
questions = [ m [ " content " ] for m in messages if m [ " role " ] == " user " ] [ - 3 : ]
2026-03-12 09:03:30 +01:00
attachments = None
if " doc_ids " in kwargs :
attachments = [ doc_id for doc_id in kwargs [ " doc_ids " ] . split ( " , " ) if doc_id ]
2026-05-09 11:52:06 +09:00
attachments_ = " "
2026-02-11 09:47:33 +08:00
image_attachments = [ ]
image_files = [ ]
2024-08-15 09:17:36 +08:00
if " doc_ids " in messages [ - 1 ] :
2026-03-12 09:03:30 +01:00
attachments = [ doc_id for doc_id in messages [ - 1 ] [ " doc_ids " ] if doc_id ]
2025-11-28 19:25:32 +08:00
if " files " in messages [ - 1 ] :
2026-05-29 17:39:41 +08:00
if llm_model_config [ " model_type " ] == " chat " :
2026-02-11 09:47:33 +08:00
text_attachments , image_attachments = split_file_attachments ( messages [ - 1 ] [ " files " ] )
else :
text_attachments , image_files = split_file_attachments ( messages [ - 1 ] [ " files " ] , raw = True )
attachments_ = " \n \n " . join ( text_attachments )
2025-08-12 14:12:56 +08:00
2024-08-15 09:17:36 +08:00
prompt_config = dialog . prompt_config
2026-04-30 18:13:27 +03:00
include_reference_metadata , metadata_fields = _resolve_reference_metadata ( prompt_config , request_payload = kwargs )
2024-08-15 09:17:36 +08:00
field_map = KnowledgebaseService . get_field_map ( dialog . kb_ids )
2026-01-19 19:35:14 +08:00
logging . debug ( f " field_map retrieved: { field_map } " )
2024-08-15 09:17:36 +08:00
# try to use sql if field mapping is good to go
if field_map :
2024-11-14 17:13:48 +08:00
logging . debug ( " Use SQL to retrieval: {} " . format ( questions [ - 1 ] ) )
2025-12-11 17:38:17 +08:00
ans = await use_sql ( questions [ - 1 ] , field_map , dialog . tenant_id , chat_mdl , prompt_config . get ( " quote " , True ) , dialog . kb_ids )
2026-01-19 19:35:14 +08:00
# 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 " ) ) :
2026-04-30 18:13:27 +03:00
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 )
2024-08-15 09:17:36 +08:00
yield ans
2025-12-08 09:43:03 +08:00
return
2026-01-19 19:35:14 +08:00
else :
logging . debug ( " SQL failed or returned no results, falling back to vector search " )
param_keys = [ p [ " key " ] for p in prompt_config . get ( " parameters " , [ ] ) ]
2026-04-15 17:31:31 +08:00
if dialog . kb_ids and " knowledge " not in param_keys and " {knowledge} " in prompt_config . get ( " system " , " " ) :
logging . warning ( " prompt_config[ ' parameters ' ] is missing ' knowledge ' entry despite kb_ids being set; auto-fixing. " )
prompt_config . setdefault ( " parameters " , [ ] ) . append ( { " key " : " knowledge " , " optional " : False } )
param_keys . append ( " knowledge " )
2026-01-19 19:35:14 +08:00
logging . debug ( f " attachments= { attachments } , param_keys= { param_keys } , embd_mdl= { embd_mdl } " )
2024-08-15 09:17:36 +08:00
2026-04-15 17:31:31 +08:00
for p in prompt_config . get ( " parameters " , [ ] ) :
2024-08-15 09:17:36 +08:00
if p [ " key " ] == " knowledge " :
continue
if p [ " key " ] not in kwargs and not p [ " optional " ] :
raise KeyError ( " Miss parameter: " + p [ " key " ] )
if p [ " key " ] not in kwargs :
2025-03-24 13:18:47 +08:00
prompt_config [ " system " ] = prompt_config [ " system " ] . replace ( " { %s } " % p [ " key " ] , " " )
2024-08-15 09:17:36 +08:00
2024-09-20 17:25:55 +08:00
if len ( questions ) > 1 and prompt_config . get ( " refine_multiturn " ) :
2025-12-11 17:38:17 +08:00
questions = [ await full_question ( dialog . tenant_id , dialog . llm_id , messages ) ]
2024-09-20 17:25:55 +08:00
else :
questions = questions [ - 1 : ]
2024-12-19 18:13:33 +08:00
2025-05-09 15:32:02 +08:00
if prompt_config . get ( " cross_languages " ) :
2025-12-11 17:38:17 +08:00
questions = [ await cross_languages ( dialog . tenant_id , dialog . llm_id , questions [ 0 ] , prompt_config [ " cross_languages " ] ) ]
2025-05-09 15:32:02 +08:00
2025-08-12 14:12:56 +08:00
if dialog . meta_data_filter :
2025-12-12 17:12:38 +08:00
attachments = await apply_meta_data_filter (
dialog . meta_data_filter ,
Perf: push metadata filters down to Elasticsearch (#14576)
### What problem does this PR solve?
Fixes #14412.
`common.metadata_utils.meta_filter` evaluates user-defined metadata
conditions in Python after `DocMetadataService.get_flatted_meta_by_kbs`
loads the entire `meta_fields` table into memory. Past a few thousand
documents per knowledge base this becomes a memory bottleneck and a
wasted ES round-trip — every filter request currently fetches up to
10000 metadata rows even when the resulting `doc_ids` list is tiny.
This PR adds an ES push-down path that translates the same filter
language into a `bool` query and returns just the matching document IDs.
**Changes**
- `common/metadata_es_filter.py` *(new)*: pure-Python translator from
the RAGflow filter list to ES DSL. Covers every operator the in-memory
path supports (`=`, `≠`, `>`, `<`, `≥`, `≤`, `in`, `not in`, `contains`,
`not contains`, `start with`, `end with`, `empty`, `not empty`) with
`case_insensitive: true` on `prefix` and `wildcard` for parity with the
existing lower-cased Python comparisons. User wildcard metacharacters
are escaped before being injected into `wildcard` patterns. Negative
operators (`≠`, `not in`, `not contains`, ranges) are wrapped with an
`exists` guard so they do not accidentally match documents missing the
key, matching the legacy `if k not in metas` behaviour.
- `api/db/services/doc_metadata_service.py`: new
`DocMetadataService.filter_doc_ids_by_meta_pushdown(kb_ids, filters,
logic)` that returns the doc IDs ES matched, or `None` to signal the
caller should fall back to the in-memory path. Returns `None` when the
active doc store is Infinity (`meta_fields` is a JSON column, not a
dotted-object mapping), when any filter cannot be expressed in DSL
(`UnsupportedMetaFilter`), or when the ES request or metadata index
lookup errors.
- `common/metadata_utils.py`: `apply_meta_data_filter` accepts an
optional `kb_ids` argument. When supplied, conditions go through
push-down first via a new `_try_meta_pushdown` helper; on `None` the
function falls back to the original `meta_filter` call. Default
behaviour is unchanged for callers that don't pass `kb_ids`.
- Updated all four callers (`agent/tools/retrieval.py`,
`api/db/services/dialog_service.py` ×2,
`api/apps/services/dataset_api_service.py`, `api/apps/sdk/session.py`)
to forward `kb_ids` so the push-down path is exercised in production.
- `test/unit_test/common/test_metadata_es_filter.py` *(new)*: 35 unit
tests covering every operator's DSL shape, value coercion
(`ast.literal_eval`, lowercasing, ISO-date pass-through), wildcard
escaping, OR-logic wrapping that protects negative clauses, and the
doc-ID extractor.
**Behaviour preserved**
- The in-memory `meta_filter` is untouched and still services every
fallback case (Infinity backend, unknown operators, ES outages).
- The eligibility / credibility / issue-multiplier semantics described
in the LLM-driven `auto` and `semi_auto` modes still hand the LLM the
full in-memory `metas` dict to choose conditions from. Only the
*evaluation* of those generated conditions is pushed down.
- Existing tests in
`test/unit_test/common/test_metadata_filter_operators.py` continue to
pass (14/14).
**Test plan**
- `pytest test/unit_test/common/test_metadata_es_filter.py` — 35 passed.
- `pytest test/unit_test/common/test_metadata_filter_operators.py` — 14
passed.
- `ruff check` clean on every modified file.
- Reviewer please validate the ES query shapes against a live cluster —
particularly `case_insensitive` on `wildcard` and `prefix` (requires ES
7.10+) and the `exists` + `must_not` pairing for `≠`.
**Notes**
- The first cut caps each push-down request at 10000 results, matching
the existing `get_flatted_meta_by_kbs` limit, and logs a warning when
the cap is hit. A `search_after` follow-up would let us drop the cap
entirely once the push-down path is validated.
- Operator parity with the in-memory path is exact for the canonical
unicode operators (`≥`, `≤`, `≠`) used internally; the ASCII aliases
(`>=`, `<=`, `!=`) are normalised by `convert_conditions` before they
reach the translator.
### Type of change
- [x] Performance Improvement
---------
Co-authored-by: sxxtony <sxxtony@users.noreply.github.com>
2026-05-07 16:23:43 +03:00
None ,
2025-12-12 17:12:38 +08:00
questions [ - 1 ] ,
chat_mdl ,
attachments ,
Perf: push metadata filters down to Elasticsearch (#14576)
### What problem does this PR solve?
Fixes #14412.
`common.metadata_utils.meta_filter` evaluates user-defined metadata
conditions in Python after `DocMetadataService.get_flatted_meta_by_kbs`
loads the entire `meta_fields` table into memory. Past a few thousand
documents per knowledge base this becomes a memory bottleneck and a
wasted ES round-trip — every filter request currently fetches up to
10000 metadata rows even when the resulting `doc_ids` list is tiny.
This PR adds an ES push-down path that translates the same filter
language into a `bool` query and returns just the matching document IDs.
**Changes**
- `common/metadata_es_filter.py` *(new)*: pure-Python translator from
the RAGflow filter list to ES DSL. Covers every operator the in-memory
path supports (`=`, `≠`, `>`, `<`, `≥`, `≤`, `in`, `not in`, `contains`,
`not contains`, `start with`, `end with`, `empty`, `not empty`) with
`case_insensitive: true` on `prefix` and `wildcard` for parity with the
existing lower-cased Python comparisons. User wildcard metacharacters
are escaped before being injected into `wildcard` patterns. Negative
operators (`≠`, `not in`, `not contains`, ranges) are wrapped with an
`exists` guard so they do not accidentally match documents missing the
key, matching the legacy `if k not in metas` behaviour.
- `api/db/services/doc_metadata_service.py`: new
`DocMetadataService.filter_doc_ids_by_meta_pushdown(kb_ids, filters,
logic)` that returns the doc IDs ES matched, or `None` to signal the
caller should fall back to the in-memory path. Returns `None` when the
active doc store is Infinity (`meta_fields` is a JSON column, not a
dotted-object mapping), when any filter cannot be expressed in DSL
(`UnsupportedMetaFilter`), or when the ES request or metadata index
lookup errors.
- `common/metadata_utils.py`: `apply_meta_data_filter` accepts an
optional `kb_ids` argument. When supplied, conditions go through
push-down first via a new `_try_meta_pushdown` helper; on `None` the
function falls back to the original `meta_filter` call. Default
behaviour is unchanged for callers that don't pass `kb_ids`.
- Updated all four callers (`agent/tools/retrieval.py`,
`api/db/services/dialog_service.py` ×2,
`api/apps/services/dataset_api_service.py`, `api/apps/sdk/session.py`)
to forward `kb_ids` so the push-down path is exercised in production.
- `test/unit_test/common/test_metadata_es_filter.py` *(new)*: 35 unit
tests covering every operator's DSL shape, value coercion
(`ast.literal_eval`, lowercasing, ISO-date pass-through), wildcard
escaping, OR-logic wrapping that protects negative clauses, and the
doc-ID extractor.
**Behaviour preserved**
- The in-memory `meta_filter` is untouched and still services every
fallback case (Infinity backend, unknown operators, ES outages).
- The eligibility / credibility / issue-multiplier semantics described
in the LLM-driven `auto` and `semi_auto` modes still hand the LLM the
full in-memory `metas` dict to choose conditions from. Only the
*evaluation* of those generated conditions is pushed down.
- Existing tests in
`test/unit_test/common/test_metadata_filter_operators.py` continue to
pass (14/14).
**Test plan**
- `pytest test/unit_test/common/test_metadata_es_filter.py` — 35 passed.
- `pytest test/unit_test/common/test_metadata_filter_operators.py` — 14
passed.
- `ruff check` clean on every modified file.
- Reviewer please validate the ES query shapes against a live cluster —
particularly `case_insensitive` on `wildcard` and `prefix` (requires ES
7.10+) and the `exists` + `must_not` pairing for `≠`.
**Notes**
- The first cut caps each push-down request at 10000 results, matching
the existing `get_flatted_meta_by_kbs` limit, and logs a warning when
the cap is hit. A `search_after` follow-up would let us drop the cap
entirely once the push-down path is validated.
- Operator parity with the in-memory path is exact for the canonical
unicode operators (`≥`, `≤`, `≠`) used internally; the ASCII aliases
(`>=`, `<=`, `!=`) are normalised by `convert_conditions` before they
reach the translator.
### Type of change
- [x] Performance Improvement
---------
Co-authored-by: sxxtony <sxxtony@users.noreply.github.com>
2026-05-07 16:23:43 +03:00
kb_ids = dialog . kb_ids ,
metas_loader = lambda : DocMetadataService . get_flatted_meta_by_kbs ( dialog . kb_ids ) ,
2025-12-12 17:12:38 +08:00
)
2025-08-12 14:12:56 +08:00
2025-06-05 13:00:43 +08:00
if prompt_config . get ( " keyword " , False ) :
2026-04-17 11:32:48 +08:00
questions [ - 1 ] = questions [ - 1 ] + " , " + await keyword_extraction ( chat_mdl , questions [ - 1 ] )
2025-06-05 13:00:43 +08:00
refine_question_ts = timer ( )
2024-08-15 09:17:36 +08:00
2025-02-20 17:41:01 +08:00
thought = " "
kbinfos = { " total " : 0 , " chunks " : [ ] , " doc_aggs " : [ ] }
2025-08-13 12:43:31 +08:00
knowledges = [ ]
2024-12-19 18:13:33 +08:00
2026-03-12 09:03:30 +01:00
if " knowledge " in param_keys :
2026-01-19 19:35:14 +08:00
logging . debug ( " Proceeding with retrieval " )
2024-10-29 13:19:01 +08:00
tenant_ids = list ( set ( [ kb . tenant_id for kb in kbs ] ) )
2025-02-20 17:41:01 +08:00
knowledges = [ ]
2026-01-22 15:34:08 +08:00
if prompt_config . get ( " reasoning " , False ) or kwargs . get ( " reasoning " ) :
2025-03-24 13:18:47 +08:00
reasoner = DeepResearcher (
chat_mdl ,
prompt_config ,
2025-08-15 17:44:58 +08:00
partial (
retriever . retrieval ,
embd_mdl = embd_mdl ,
tenant_ids = tenant_ids ,
kb_ids = dialog . kb_ids ,
page = 1 ,
page_size = dialog . top_n ,
similarity_threshold = 0.2 ,
vector_similarity_weight = 0.3 ,
doc_ids = attachments ,
) ,
2026-04-14 04:55:20 -07:00
internet_enabled = use_web_search ,
2025-03-24 13:18:47 +08:00
)
2026-01-13 09:41:35 +08:00
queue = asyncio . Queue ( )
2026-05-09 11:52:06 +09:00
async def callback ( msg : str ) :
2026-01-13 09:41:35 +08:00
nonlocal queue
await queue . put ( msg + " <br/> " )
await callback ( " <START_DEEP_RESEARCH> " )
task = asyncio . create_task ( reasoner . research ( kbinfos , questions [ - 1 ] , questions [ - 1 ] , callback = callback ) )
while True :
msg = await queue . get ( )
if msg . find ( " <START_DEEP_RESEARCH> " ) == 0 :
Fix: collapsible thinking display and separate deep research retrieval tag (#14613)
## Summary
- **Collapsible thinking**: Replace `<section>` with `<details>` for
`<think>` content, so model thinking output is collapsed by default
(click to expand). Works for all models that output `<think>` tags
(Qwen3, DeepSeek, Gemini, Claude, etc.).
- **Fix double thinking tags**: When reasoning/deep research mode is
enabled in knowledge base chat, both the retrieval progress and model
thinking were wrapped in `<think>` tags, producing two "Thinking..."
blocks. Now retrieval progress uses a dedicated `<retrieving>` tag
rendered as a separate "Retrieving..." collapsible with a distinct green
accent.
### Before
- Thinking content displayed as flat gray-bordered `<section>`,
occupying significant screen space
- Deep research + model thinking both use `<think>` → two identical
"Thinking..." blocks
### After
- Thinking content collapsed by default in a `<details>` element, click
"Thinking..." to expand
- Deep research shows "Retrieving..." (green border), model thinking
shows "Thinking..." (gray border)
## Changes
**Backend (`api/db/services/dialog_service.py`)**
- Deep research callback: replace `start_to_think`/`end_to_think` marker
flags with direct `<retrieving>`/`</retrieving>` answer text
**Frontend**
- `web/src/utils/chat.ts`: `replaceThinkToSection()` now uses
`<details>` instead of `<section>`; add new
`replaceRetrievingToSection()`
- 4 tsx files: import and pipe `replaceRetrievingToSection`, whitelist
`details`, `summary`, `retrieving` in DOMPurify `ADD_TAGS`
- 4 less files: `section.think` → `details.think` with `<summary>`
styles; add `details.retrieving` with green accent; dark mode and RTL
variants
## Test plan
- [ ] Open a chat WITHOUT knowledge base, ask a question to a model with
thinking (e.g. Qwen3) → thinking content should be collapsed by default,
click "Thinking..." to expand
- [ ] Open a chat WITH knowledge base and reasoning enabled, ask a
question → "Retrieving..." (green) shows retrieval progress,
"Thinking..." (gray) shows model thinking, each independently
collapsible
- [ ] Verify dark mode renders correctly for both collapsible blocks
- [ ] Verify RTL layout renders correctly
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: wanghualoong <wanghualoong@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 14:40:00 +08:00
yield { " answer " : " <retrieving> " , " reference " : { } , " audio_binary " : None , " final " : False }
2026-01-13 09:41:35 +08:00
elif msg . find ( " <END_DEEP_RESEARCH> " ) == 0 :
Fix: collapsible thinking display and separate deep research retrieval tag (#14613)
## Summary
- **Collapsible thinking**: Replace `<section>` with `<details>` for
`<think>` content, so model thinking output is collapsed by default
(click to expand). Works for all models that output `<think>` tags
(Qwen3, DeepSeek, Gemini, Claude, etc.).
- **Fix double thinking tags**: When reasoning/deep research mode is
enabled in knowledge base chat, both the retrieval progress and model
thinking were wrapped in `<think>` tags, producing two "Thinking..."
blocks. Now retrieval progress uses a dedicated `<retrieving>` tag
rendered as a separate "Retrieving..." collapsible with a distinct green
accent.
### Before
- Thinking content displayed as flat gray-bordered `<section>`,
occupying significant screen space
- Deep research + model thinking both use `<think>` → two identical
"Thinking..." blocks
### After
- Thinking content collapsed by default in a `<details>` element, click
"Thinking..." to expand
- Deep research shows "Retrieving..." (green border), model thinking
shows "Thinking..." (gray border)
## Changes
**Backend (`api/db/services/dialog_service.py`)**
- Deep research callback: replace `start_to_think`/`end_to_think` marker
flags with direct `<retrieving>`/`</retrieving>` answer text
**Frontend**
- `web/src/utils/chat.ts`: `replaceThinkToSection()` now uses
`<details>` instead of `<section>`; add new
`replaceRetrievingToSection()`
- 4 tsx files: import and pipe `replaceRetrievingToSection`, whitelist
`details`, `summary`, `retrieving` in DOMPurify `ADD_TAGS`
- 4 less files: `section.think` → `details.think` with `<summary>`
styles; add `details.retrieving` with green accent; dark mode and RTL
variants
## Test plan
- [ ] Open a chat WITHOUT knowledge base, ask a question to a model with
thinking (e.g. Qwen3) → thinking content should be collapsed by default,
click "Thinking..." to expand
- [ ] Open a chat WITH knowledge base and reasoning enabled, ask a
question → "Retrieving..." (green) shows retrieval progress,
"Thinking..." (gray) shows model thinking, each independently
collapsible
- [ ] Verify dark mode renders correctly for both collapsible blocks
- [ ] Verify RTL layout renders correctly
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: wanghualoong <wanghualoong@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 14:40:00 +08:00
yield { " answer " : " </retrieving> " , " reference " : { } , " audio_binary " : None , " final " : False }
2026-01-13 09:41:35 +08:00
break
else :
yield { " answer " : msg , " reference " : { } , " audio_binary " : None , " final " : False }
await task
2026-01-15 12:28:49 +08:00
2025-02-20 17:41:01 +08:00
else :
2025-06-05 13:00:43 +08:00
if embd_mdl :
2026-01-15 12:28:49 +08:00
kbinfos = await retriever . retrieval (
2025-06-05 13:00:43 +08:00
" " . join ( questions ) ,
embd_mdl ,
tenant_ids ,
dialog . kb_ids ,
1 ,
dialog . top_n ,
dialog . similarity_threshold ,
dialog . vector_similarity_weight ,
doc_ids = attachments ,
top = dialog . top_k ,
2025-12-23 15:57:27 +08:00
aggs = True ,
2025-06-05 13:00:43 +08:00
rerank_mdl = rerank_mdl ,
rank_feature = label_question ( " " . join ( questions ) , kbs ) ,
)
2025-10-10 17:07:55 +08:00
if prompt_config . get ( " toc_enhance " ) :
2026-01-07 15:35:30 +08:00
cks = await retriever . retrieval_by_toc ( " " . join ( questions ) , kbinfos [ " chunks " ] , tenant_ids , chat_mdl , dialog . top_n )
2025-10-10 17:07:55 +08:00
if cks :
kbinfos [ " chunks " ] = cks
2025-12-01 14:03:09 +08:00
kbinfos [ " chunks " ] = retriever . retrieval_by_children ( kbinfos [ " chunks " ] , tenant_ids )
2026-04-14 04:55:20 -07:00
if use_web_search :
2025-02-26 10:21:04 +08:00
tav = Tavily ( prompt_config [ " tavily_api_key " ] )
tav_res = tav . retrieve_chunks ( " " . join ( questions ) )
kbinfos [ " chunks " ] . extend ( tav_res [ " chunks " ] )
kbinfos [ " doc_aggs " ] . extend ( tav_res [ " doc_aggs " ] )
2025-02-20 17:41:01 +08:00
if prompt_config . get ( " use_kg " ) :
2026-03-05 17:27:17 +08:00
default_chat_model = get_tenant_default_model_by_type ( dialog . tenant_id , LLMType . CHAT )
2026-06-12 09:18:06 +07:00
ck = await settings . kg_retriever . retrieval ( " " . join ( questions ) , tenant_ids , dialog . kb_ids , embd_mdl , LLMBundle ( dialog . tenant_id , default_chat_model , trace_context = trace_context , langfuse_session_id = session_id ) )
2025-02-20 17:41:01 +08:00
if ck [ " content_with_weight " ] :
kbinfos [ " chunks " ] . insert ( 0 , ck )
2026-04-30 18:13:27 +03:00
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 )
2026-01-13 09:41:35 +08:00
knowledges = kb_prompt ( kbinfos , max_tokens )
2025-03-24 13:18:47 +08:00
logging . debug ( " {} -> {} " . format ( " " . join ( questions ) , " \n -> " . join ( knowledges ) ) )
2024-08-15 09:17:36 +08:00
2025-02-20 17:41:01 +08:00
retrieval_ts = timer ( )
2024-08-15 09:17:36 +08:00
if not knowledges and prompt_config . get ( " empty_response " ) :
2024-09-03 19:49:14 +08:00
empty_res = prompt_config [ " empty_response " ]
2026-05-09 11:52:06 +09:00
yield { " answer " : empty_res , " reference " : kbinfos , " prompt " : " \n \n ### Query: \n %s " % " " . join ( questions ) , " audio_binary " : tts ( tts_mdl , empty_res ) , " final " : True }
2025-12-08 09:43:03 +08:00
return
2024-08-15 09:17:36 +08:00
2025-01-13 14:35:24 +08:00
kwargs [ " knowledge " ] = " \n ------ \n " + " \n \n ------ \n \n " . join ( knowledges )
2024-08-15 09:17:36 +08:00
gen_conf = dialog . llm_setting
2026-05-09 11:52:06 +09:00
msg = [ { " role " : " system " , " content " : prompt_config [ " system " ] . format ( * * kwargs ) + attachments_ } ]
2025-03-11 19:56:21 +08:00
prompt4citation = " "
if knowledges and ( prompt_config . get ( " quote " , True ) and kwargs . get ( " quote " , True ) ) :
prompt4citation = citation_prompt ( )
2025-03-24 13:18:47 +08:00
msg . extend ( [ { " role " : m [ " role " ] , " content " : re . sub ( r " ## \ d+ \ $ \ $ " , " " , m [ " content " ] ) } for m in messages if m [ " role " ] != " system " ] )
2025-03-11 19:56:21 +08:00
used_token_count , msg = message_fit_in ( msg , int ( max_tokens * 0.95 ) )
2026-05-29 17:39:41 +08:00
if llm_model_config [ " model_type " ] == " chat " and image_attachments :
2026-02-11 09:47:33 +08:00
convert_last_user_msg_to_multimodal ( msg , image_attachments , factory )
2024-08-15 09:17:36 +08:00
assert len ( msg ) > = 2 , f " message_fit_in has bug: { msg } "
2024-08-26 16:14:15 +08:00
prompt = msg [ 0 ] [ " content " ]
2024-08-15 09:17:36 +08:00
if " max_tokens " in gen_conf :
2025-03-24 13:18:47 +08:00
gen_conf [ " max_tokens " ] = min ( gen_conf [ " max_tokens " ] , max_tokens - used_token_count )
2024-08-15 09:17:36 +08:00
2026-05-18 14:21:56 +08:00
async def decorate_answer ( answer ) :
2026-05-20 04:01:19 -03:00
nonlocal embd_mdl , prompt_config , knowledges , kwargs , kbinfos , prompt , retrieval_ts , questions , langfuse_generation
2024-12-19 18:13:33 +08:00
2024-08-15 09:17:36 +08:00
refs = [ ]
2025-02-20 17:41:01 +08:00
ans = answer . split ( " </think> " )
think = " "
if len ( ans ) == 2 :
think = ans [ 0 ] + " </think> "
answer = ans [ 1 ]
2025-04-15 09:33:53 +08:00
2024-08-15 09:17:36 +08:00
if knowledges and ( prompt_config . get ( " quote " , True ) and kwargs . get ( " quote " , True ) ) :
2025-04-15 09:33:53 +08:00
idx = set ( [ ] )
Feature rtl support (#13118)
### What problem does this PR solve?
This PR adds comprehensive **Right-to-Left (RTL) language support**,
primarily targeting Arabic and other RTL scripts (Hebrew, Persian, Urdu,
etc.).
Previously, RTL content had multiple rendering issues:
- Incorrect sentence splitting for Arabic punctuation in citation logic
- Misaligned text in chat messages and markdown components
- Improper positioning of blockquotes and “think” sections
- Incorrect table alignment
- Citation placement ambiguity in RTL prompts
- UI layout inconsistencies when mixing LTR and RTL text
This PR introduces backend and frontend improvements to properly detect,
render, and style RTL content while preserving existing LTR behavior.
#### Backend
- Updated sentence boundary regex in `rag/nlp/search.py` to include
Arabic punctuation:
- `،` (comma)
- `؛` (semicolon)
- `؟` (question mark)
- `۔` (Arabic full stop)
- Ensures citation insertion works correctly in RTL sentences.
- Updated citation prompt instructions to clarify citation placement
rules for RTL languages.
#### Frontend
- Introduced a new utility: `text-direction.ts`
- Detects text direction based on Unicode ranges.
- Supports Arabic, Hebrew, Syriac, Thaana, and related scripts.
- Provides `getDirAttribute()` for automatic `dir` assignment.
- Applied dynamic `dir` attributes across:
- Markdown rendering
- Chat messages
- Search results
- Tables
- Hover cards and reference popovers
- Added proper RTL styling in LESS:
- Text alignment adjustments
- Blockquote border flipping
- Section indentation correction
- Table direction switching
- Use of `<bdi>` for figure labels to prevent bidirectional conflicts
#### DevOps / Environment
- Added Windows backend launch script with retry handling.
- Updated dependency metadata.
- Adjusted development-only React debugging behavior.
---
### Type of change
- [x] Bug Fix (non-breaking change which fixes RTL rendering and
citation issues)
- [x] New Feature (non-breaking change which adds RTL detection and
dynamic direction handling)
---------
Co-authored-by: 6ba3i <isbaaoui09@gmail.com>
Co-authored-by: Ahmad Intisar <ahmadintisar@Ahmads-MacBook-M4-Pro.local>
Co-authored-by: Ahmad Intisar <168020872+ahmadintisar@users.noreply.github.com>
Co-authored-by: Liu An <asiro@qq.com>
2026-03-02 08:03:44 +03:00
normalized_answer = normalize_arabic_digits ( answer ) or " "
if embd_mdl and not CITATION_MARKER_PATTERN . search ( normalized_answer ) :
2026-05-18 14:21:56 +08:00
# Main retrieval no longer ships chunk vectors back from ES.
# Pull them on demand for the chunks we are about to cite.
await _hydrate_chunk_vectors ( retriever , kbinfos . get ( " chunks " , [ ] ) , tenant_ids , dialog . kb_ids )
2025-03-24 13:18:47 +08:00
answer , idx = retriever . insert_citations (
answer ,
[ ck [ " content_ltks " ] for ck in kbinfos [ " chunks " ] ] ,
[ ck [ " vector " ] for ck in kbinfos [ " chunks " ] ] ,
embd_mdl ,
tkweight = 1 - dialog . vector_similarity_weight ,
vtweight = dialog . vector_similarity_weight ,
)
2025-03-11 19:56:21 +08:00
else :
Feature rtl support (#13118)
### What problem does this PR solve?
This PR adds comprehensive **Right-to-Left (RTL) language support**,
primarily targeting Arabic and other RTL scripts (Hebrew, Persian, Urdu,
etc.).
Previously, RTL content had multiple rendering issues:
- Incorrect sentence splitting for Arabic punctuation in citation logic
- Misaligned text in chat messages and markdown components
- Improper positioning of blockquotes and “think” sections
- Incorrect table alignment
- Citation placement ambiguity in RTL prompts
- UI layout inconsistencies when mixing LTR and RTL text
This PR introduces backend and frontend improvements to properly detect,
render, and style RTL content while preserving existing LTR behavior.
#### Backend
- Updated sentence boundary regex in `rag/nlp/search.py` to include
Arabic punctuation:
- `،` (comma)
- `؛` (semicolon)
- `؟` (question mark)
- `۔` (Arabic full stop)
- Ensures citation insertion works correctly in RTL sentences.
- Updated citation prompt instructions to clarify citation placement
rules for RTL languages.
#### Frontend
- Introduced a new utility: `text-direction.ts`
- Detects text direction based on Unicode ranges.
- Supports Arabic, Hebrew, Syriac, Thaana, and related scripts.
- Provides `getDirAttribute()` for automatic `dir` assignment.
- Applied dynamic `dir` attributes across:
- Markdown rendering
- Chat messages
- Search results
- Tables
- Hover cards and reference popovers
- Added proper RTL styling in LESS:
- Text alignment adjustments
- Blockquote border flipping
- Section indentation correction
- Table direction switching
- Use of `<bdi>` for figure labels to prevent bidirectional conflicts
#### DevOps / Environment
- Added Windows backend launch script with retry handling.
- Updated dependency metadata.
- Adjusted development-only React debugging behavior.
---
### Type of change
- [x] Bug Fix (non-breaking change which fixes RTL rendering and
citation issues)
- [x] New Feature (non-breaking change which adds RTL detection and
dynamic direction handling)
---------
Co-authored-by: 6ba3i <isbaaoui09@gmail.com>
Co-authored-by: Ahmad Intisar <ahmadintisar@Ahmads-MacBook-M4-Pro.local>
Co-authored-by: Ahmad Intisar <168020872+ahmadintisar@users.noreply.github.com>
Co-authored-by: Liu An <asiro@qq.com>
2026-03-02 08:03:44 +03:00
for match in CITATION_MARKER_PATTERN . finditer ( normalized_answer ) :
2025-04-15 09:33:53 +08:00
i = int ( match . group ( 1 ) )
if i < len ( kbinfos [ " chunks " ] ) :
idx . add ( i )
2025-05-19 19:34:05 +08:00
answer , idx = repair_bad_citation_formats ( answer , kbinfos , idx )
2025-03-11 19:56:21 +08:00
2024-08-15 09:17:36 +08:00
idx = set ( [ kbinfos [ " chunks " ] [ int ( i ) ] [ " doc_id " ] for i in idx ] )
2025-03-24 13:18:47 +08:00
recall_docs = [ d for d in kbinfos [ " doc_aggs " ] if d [ " doc_id " ] in idx ]
2024-12-08 14:21:12 +08:00
if not recall_docs :
recall_docs = kbinfos [ " doc_aggs " ]
2024-08-15 09:17:36 +08:00
kbinfos [ " doc_aggs " ] = recall_docs
refs = deepcopy ( kbinfos )
for c in refs [ " chunks " ] :
if c . get ( " vector " ) :
del c [ " vector " ]
if answer . lower ( ) . find ( " invalid key " ) > = 0 or answer . lower ( ) . find ( " invalid api " ) > = 0 :
2024-12-07 11:04:36 +08:00
answer + = " Please set LLM API-Key in ' User Setting -> Model providers -> API-Key ' "
2024-12-19 18:13:33 +08:00
finish_chat_ts = timer ( )
total_time_cost = ( finish_chat_ts - chat_start_ts ) * 1000
check_llm_time_cost = ( check_llm_ts - chat_start_ts ) * 1000
2025-03-24 13:18:47 +08:00
check_langfuse_tracer_cost = ( check_langfuse_tracer_ts - check_llm_ts ) * 1000
2025-06-05 13:00:43 +08:00
bind_embedding_time_cost = ( bind_models_ts - check_langfuse_tracer_ts ) * 1000
refine_question_time_cost = ( refine_question_ts - bind_models_ts ) * 1000
retrieval_time_cost = ( retrieval_ts - refine_question_ts ) * 1000
2024-12-19 18:13:33 +08:00
generate_result_time_cost = ( finish_chat_ts - retrieval_ts ) * 1000
2025-03-24 13:18:47 +08:00
tk_num = num_tokens_from_string ( think + answer )
2025-03-03 13:12:38 +08:00
prompt + = " \n \n ### Query: \n %s " % " " . join ( questions )
2025-03-14 13:37:31 +08:00
prompt = (
2025-03-24 13:18:47 +08:00
f " { prompt } \n \n "
" ## Time elapsed: \n "
f " - Total: { total_time_cost : .1f } ms \n "
f " - Check LLM: { check_llm_time_cost : .1f } ms \n "
f " - Check Langfuse tracer: { check_langfuse_tracer_cost : .1f } ms \n "
2025-06-05 13:00:43 +08:00
f " - Bind models: { bind_embedding_time_cost : .1f } ms \n "
f " - Query refinement(LLM): { refine_question_time_cost : .1f } ms \n "
2025-03-24 13:18:47 +08:00
f " - Retrieval: { retrieval_time_cost : .1f } ms \n "
f " - Generate answer: { generate_result_time_cost : .1f } ms \n \n "
" ## Token usage: \n "
f " - Generated tokens(approximately): { tk_num } \n "
f " - Token speed: { int ( tk_num / ( generate_result_time_cost / 1000.0 ) ) } /s "
2025-03-14 13:37:31 +08:00
)
2025-03-24 13:18:47 +08:00
2026-05-20 04:01:19 -03:00
# Add a condition check to call the end method only if langfuse_generation exists
if langfuse_generation is not None :
2025-08-04 14:45:43 +08:00
langfuse_output = " \n " + re . sub ( r " ^.*?(### Query:.*) " , r " \ 1 " , prompt , flags = re . DOTALL )
langfuse_output = { " time_elapsed: " : re . sub ( r " \ n " , " \n " , langfuse_output ) , " created_at " : time . time ( ) }
2026-05-14 13:11:37 +08:00
langfuse_generation . update (
output = langfuse_output ,
usage_details = {
" input " : used_token_count ,
" output " : tk_num ,
" total " : used_token_count + tk_num ,
} ,
)
2025-08-04 14:45:43 +08:00
langfuse_generation . end ( )
2025-03-24 13:18:47 +08:00
return { " answer " : think + answer , " reference " : refs , " prompt " : re . sub ( r " \ n " , " \n " , prompt ) , " created_at " : time . time ( ) }
if langfuse_tracer :
2026-05-20 04:01:19 -03:00
try :
2026-06-12 09:18:06 +07:00
observation_kwargs = {
" as_type " : " generation " ,
" trace_context " : trace_context ,
" name " : " chat " ,
" model " : llm_model_config [ " llm_name " ] ,
" input " : { " prompt " : prompt , " prompt4citation " : prompt4citation , " messages " : msg } ,
}
if session_id :
observation_kwargs [ " session_id " ] = session_id
langfuse_generation = langfuse_tracer . start_observation ( * * observation_kwargs )
2026-05-20 04:01:19 -03:00
except Exception as e : # noqa: BLE001 - tracing must not break chat flow
logger . warning ( " Langfuse start_observation failed; continuing without tracing: %s " , e )
langfuse_tracer = None
langfuse_generation = None
2024-08-15 09:17:36 +08:00
if stream :
2026-05-29 17:39:41 +08:00
if llm_model_config [ " model_type " ] == " chat " :
2026-02-11 09:47:33 +08:00
stream_iter = chat_mdl . async_chat_streamly_delta ( prompt + prompt4citation , msg [ 1 : ] , gen_conf )
else :
stream_iter = chat_mdl . async_chat_streamly_delta ( prompt + prompt4citation , msg [ 1 : ] , gen_conf , images = image_files )
2026-01-08 13:34:16 +08:00
last_state = None
async for kind , value , state in _stream_with_think_delta ( stream_iter ) :
last_state = state
if kind == " marker " :
flags = { " start_to_think " : True } if value == " <think> " else { " end_to_think " : True }
yield { " answer " : " " , " reference " : { } , " audio_binary " : None , " final " : False , * * flags }
2024-09-03 19:49:14 +08:00
continue
2026-01-08 13:34:16 +08:00
yield { " answer " : value , " reference " : { } , " audio_binary " : tts ( tts_mdl , value ) , " final " : False }
full_answer = last_state . full_text if last_state else " "
if full_answer :
2026-05-18 14:21:56 +08:00
final = await decorate_answer ( _extract_visible_answer ( thought + full_answer ) )
2026-01-08 13:34:16 +08:00
final [ " final " ] = True
final [ " audio_binary " ] = None
yield final
2024-08-15 09:17:36 +08:00
else :
2026-05-29 17:39:41 +08:00
if llm_model_config [ " model_type " ] == " chat " :
2026-02-11 09:47:33 +08:00
answer = await chat_mdl . async_chat ( prompt + prompt4citation , msg [ 1 : ] , gen_conf )
else :
answer = await chat_mdl . async_chat ( prompt + prompt4citation , msg [ 1 : ] , gen_conf , images = image_files )
2025-02-13 23:27:01 -03:00
user_content = msg [ - 1 ] . get ( " content " , " [content not available] " )
logging . debug ( " User: {} |Assistant: {} " . format ( user_content , answer ) )
2026-05-18 14:21:56 +08:00
res = await decorate_answer ( answer )
2024-09-03 19:49:14 +08:00
res [ " audio_binary " ] = tts ( tts_mdl , answer )
yield res
2024-08-15 09:17:36 +08:00
2025-12-08 09:43:03 +08:00
return
2025-11-16 19:29:20 +08:00
2024-08-15 09:17:36 +08:00
2025-12-11 17:38:17 +08:00
async def use_sql ( question , field_map , tenant_id , chat_mdl , quota = True , kb_ids = None ) :
2026-05-09 11:52:06 +09:00
""" Answer a natural-language question by generating and executing SQL against the document index.
Detects the active document engine ( Infinity , OceanBase , or Elasticsearch ) , asks the
chat model to produce the appropriate SQL , injects a validated kb_id filter , executes
the query , and returns formatted results with optional source citations .
Args :
question : Natural - language question from the user .
field_map : Mapping of field names to types describing the indexed document schema .
tenant_id : Tenant identifier used to derive the target index / table name .
chat_mdl : LLM bundle used to generate SQL from the question .
quota : Whether to enforce token - quota checks ( default True ) .
kb_ids : Optional list of knowledge - base UUIDs to restrict the query scope .
Returns :
A dict with keys ` ` answer ` ` ( formatted response string ) , ` ` reference ` `
( dict of supporting document chunks and doc_aggs ) , and ` ` prompt ` `
( the system prompt used ) , or ` ` None ` ` if SQL generation or execution fails .
"""
2026-01-19 19:35:14 +08:00
logging . debug ( f " use_sql: Question: { question } " )
# Determine which document engine we're using
2026-01-31 15:11:54 +08:00
if settings . DOC_ENGINE_INFINITY :
doc_engine = " infinity "
elif settings . DOC_ENGINE_OCEANBASE :
doc_engine = " oceanbase "
else :
doc_engine = " es "
2026-01-19 19:35:14 +08:00
2026-05-09 11:52:06 +09:00
def _assert_valid_uuid ( value : str , label : str = " id " ) - > None :
try :
uuid . UUID ( str ( value ) )
except ( ValueError , AttributeError , TypeError ) :
logger . warning ( " SQL injection guard rejected invalid %s value (length= %d ) " , label , len ( str ( value ) ) )
raise ValueError ( f " Invalid { label } format: { value !r} " )
2026-01-19 19:35:14 +08:00
# Construct the full table name
# For Elasticsearch: ragflow_{tenant_id} (kb_id is in WHERE clause)
# For Infinity: ragflow_{tenant_id}_{kb_id} (each KB has its own table)
base_table = index_name ( tenant_id )
if doc_engine == " infinity " and kb_ids and len ( kb_ids ) == 1 :
2026-05-09 11:52:06 +09:00
# Infinity: append kb_id to table name — validate before interpolating
_assert_valid_uuid ( kb_ids [ 0 ] , " kb_id " )
2026-01-19 19:35:14 +08:00
table_name = f " { base_table } _ { kb_ids [ 0 ] } "
logging . debug ( f " use_sql: Using Infinity table name: { table_name } " )
else :
# Elasticsearch/OpenSearch: use base index name
table_name = base_table
logging . debug ( f " use_sql: Using ES/OS table name: { table_name } " )
2026-03-04 19:10:06 +08:00
expected_doc_name_column = " docnm " if doc_engine == " infinity " else " docnm_kwd "
def has_source_columns ( columns ) :
2026-05-09 11:52:06 +09:00
""" Return True if the result set contains the columns needed to build source citations. """
2026-03-04 19:10:06 +08:00
normalized_names = { str ( col . get ( " name " , " " ) ) . lower ( ) for col in columns }
return " doc_id " in normalized_names and bool ( { " docnm_kwd " , " docnm " } & normalized_names )
def is_aggregate_sql ( sql_text ) :
2026-05-09 11:52:06 +09:00
""" Return True if *sql_text* contains an aggregate function (COUNT, SUM, AVG, MAX, MIN, DISTINCT). """
2026-03-04 19:10:06 +08:00
return bool ( re . search ( r " (count|sum|avg|max|min|distinct) \ s* \ ( " , ( sql_text or " " ) . lower ( ) ) )
def normalize_sql ( sql ) :
2026-05-09 11:52:06 +09:00
""" Strip LLM artefacts from *sql* and return a clean, executable SQL string.
Removes ` ` < think > ` ` reasoning blocks , Chinese reasoning markers , markdown
code fences , and trailing semicolons that some engines reject .
"""
2026-03-04 19:10:06 +08:00
logging . debug ( f " use_sql: Raw SQL from LLM: { repr ( sql [ : 500 ] ) } " )
# Remove think blocks if present (format: </think>...)
sql = re . sub ( r " </think> \ n.*? \ n \ s* " , " " , sql , flags = re . DOTALL )
sql = re . sub ( r " 思考 \ n.*? \ n " , " " , sql , flags = re . DOTALL )
# Remove markdown code blocks (```sql ... ```)
sql = re . sub ( r " ```(?:sql)? \ s* " , " " , sql , flags = re . IGNORECASE )
sql = re . sub ( r " ``` \ s*$ " , " " , sql , flags = re . IGNORECASE )
# Remove trailing semicolon that ES SQL parser doesn't like
2026-05-09 11:52:06 +09:00
return sql . rstrip ( ) . rstrip ( " ; " ) . strip ( )
2026-03-04 19:10:06 +08:00
def add_kb_filter ( sql ) :
2026-05-09 11:52:06 +09:00
""" Inject a validated kb_id WHERE filter into *sql* for ES/OceanBase engines.
Infinity encodes the knowledge - base scope in the table name , so this
function is a no - op for that engine . All kb_id values are validated as
canonical UUIDs before interpolation to prevent SQL injection .
"""
2026-03-04 19:10:06 +08:00
# Add kb_id filter for ES/OS only (Infinity already has it in table name)
if doc_engine == " infinity " or not kb_ids :
return sql
2026-05-09 11:52:06 +09:00
# Validate all kb_ids are UUIDs before interpolating into SQL
for kid in kb_ids :
_assert_valid_uuid ( kid , " kb_id " )
2026-03-04 19:10:06 +08:00
# Build kb_filter: single KB or multiple KBs with OR
if len ( kb_ids ) == 1 :
kb_filter = f " kb_id = ' { kb_ids [ 0 ] } ' "
else :
2026-05-09 11:52:06 +09:00
kb_filter = " ( " + " OR " . join ( [ f " kb_id = ' { kid } ' " for kid in kb_ids ] ) + " ) "
2026-03-04 19:10:06 +08:00
if " where " not in sql . lower ( ) :
o = sql . lower ( ) . split ( " order by " )
if len ( o ) > 1 :
sql = o [ 0 ] + f " WHERE { kb_filter } order by " + o [ 1 ]
else :
sql + = f " WHERE { kb_filter } "
elif " kb_id = " not in sql . lower ( ) and " kb_id= " not in sql . lower ( ) :
sql = re . sub ( r " \ bwhere \ b " , f " where { kb_filter } and " , sql , flags = re . IGNORECASE )
return sql
2026-02-09 14:56:10 +08:00
def is_row_count_question ( q : str ) - > bool :
2026-05-09 11:52:06 +09:00
""" Return True if *q* is asking for a total row count of a dataset or table. """
2026-02-09 14:56:10 +08:00
q = ( q or " " ) . lower ( )
if not re . search ( r " \ bhow many rows \ b| \ bnumber of rows \ b| \ brow count \ b " , q ) :
return False
return bool ( re . search ( r " \ bdataset \ b| \ btable \ b| \ bspreadsheet \ b| \ bexcel \ b " , q ) )
2026-01-19 19:35:14 +08:00
# Generate engine-specific SQL prompts
if doc_engine == " infinity " :
# Build Infinity prompts with JSON extraction context
json_field_names = list ( field_map . keys ( ) )
2026-05-09 11:52:06 +09:00
row_count_override = f " SELECT COUNT(*) AS rows FROM { table_name } " if is_row_count_question ( question ) else None
2026-01-19 19:35:14 +08:00
sys_prompt = """ You are a Database Administrator. Write SQL for a table with JSON ' chunk_data ' column.
JSON Extraction : json_extract_string ( chunk_data , ' $.FieldName ' )
Numeric Cast : CAST ( json_extract_string ( chunk_data , ' $.FieldName ' ) AS INTEGER / FLOAT )
NULL Check : json_extract_isnull ( chunk_data , ' $.FieldName ' ) == false
RULES :
1. Use EXACT field names ( case - sensitive ) from the list below
2. For SELECT : include doc_id , docnm , and json_extract_string ( ) for requested fields
3. For COUNT : use COUNT ( * ) or COUNT ( DISTINCT json_extract_string ( . . . ) )
4. Add AS alias for extracted field names
5. DO NOT select ' content ' field
6. Only add NULL check ( json_extract_isnull ( ) == false ) in WHERE clause when :
- Question asks to " show me " or " display " specific columns
- Question mentions " not null " or " excluding null "
- Add NULL check for count specific column
- DO NOT add NULL check for COUNT ( * ) queries ( COUNT ( * ) counts all rows including nulls )
7. Output ONLY the SQL , no explanations """
user_prompt = """ Table: {}
Fields ( EXACT case ) : { }
2024-08-15 09:17:36 +08:00
{ }
2026-01-19 19:35:14 +08:00
Question : { }
Write SQL using json_extract_string ( ) with exact field names . Include doc_id , docnm for data queries . Only SQL . """ .format(
2026-05-09 11:52:06 +09:00
table_name , " , " . join ( json_field_names ) , " \n " . join ( [ f " - { field } " for field in json_field_names ] ) , question
2026-01-19 19:35:14 +08:00
)
2026-01-31 15:11:54 +08:00
elif doc_engine == " oceanbase " :
# Build OceanBase prompts with JSON extraction context
json_field_names = list ( field_map . keys ( ) )
2026-05-09 11:52:06 +09:00
row_count_override = f " SELECT COUNT(*) AS rows FROM { table_name } " if is_row_count_question ( question ) else None
2026-01-31 15:11:54 +08:00
sys_prompt = """ You are a Database Administrator. Write SQL for a table with JSON ' chunk_data ' column.
JSON Extraction : json_extract_string ( chunk_data , ' $.FieldName ' )
Numeric Cast : CAST ( json_extract_string ( chunk_data , ' $.FieldName ' ) AS INTEGER / FLOAT )
NULL Check : json_extract_isnull ( chunk_data , ' $.FieldName ' ) == false
RULES :
1. Use EXACT field names ( case - sensitive ) from the list below
2. For SELECT : include doc_id , docnm_kwd , and json_extract_string ( ) for requested fields
3. For COUNT : use COUNT ( * ) or COUNT ( DISTINCT json_extract_string ( . . . ) )
4. Add AS alias for extracted field names
5. DO NOT select ' content ' field
6. Only add NULL check ( json_extract_isnull ( ) == false ) in WHERE clause when :
- Question asks to " show me " or " display " specific columns
- Question mentions " not null " or " excluding null "
- Add NULL check for count specific column
- DO NOT add NULL check for COUNT ( * ) queries ( COUNT ( * ) counts all rows including nulls )
7. Output ONLY the SQL , no explanations """
user_prompt = """ Table: {}
Fields ( EXACT case ) : { }
{ }
Question : { }
Write SQL using json_extract_string ( ) with exact field names . Include doc_id , docnm_kwd for data queries . Only SQL . """ .format(
2026-05-09 11:52:06 +09:00
table_name , " , " . join ( json_field_names ) , " \n " . join ( [ f " - { field } " for field in json_field_names ] ) , question
2026-01-31 15:11:54 +08:00
)
2026-01-19 19:35:14 +08:00
else :
# Build ES/OS prompts with direct field access
2026-02-09 14:56:10 +08:00
row_count_override = None
2026-01-19 19:35:14 +08:00
sys_prompt = """ You are a Database Administrator. Write SQL queries.
RULES :
1. Use EXACT field names from the schema below ( e . g . , product_tks , not product )
2. Quote field names starting with digit : " 123_field "
3. Add IS NOT NULL in WHERE clause when :
- Question asks to " show me " or " display " specific columns
4. Include doc_id / docnm in non - aggregate statement
5. Output ONLY the SQL , no explanations """
user_prompt = """ Table: {}
Available fields :
2024-08-15 09:17:36 +08:00
{ }
2026-01-19 19:35:14 +08:00
Question : { }
2026-05-09 11:52:06 +09:00
Write SQL using exact field names above . Include doc_id , docnm_kwd for data queries . Only SQL . """ .format(table_name, " \n " .join([f " - {k} ( {v} ) " for k, v in field_map.items()]), question)
2026-01-19 19:35:14 +08:00
2024-08-15 09:17:36 +08:00
tried_times = 0
2026-03-04 19:10:06 +08:00
async def get_table ( custom_user_prompt = None ) :
2026-02-09 14:56:10 +08:00
nonlocal sys_prompt , user_prompt , question , tried_times , row_count_override
2026-03-04 19:10:06 +08:00
if row_count_override and custom_user_prompt is None :
2026-02-09 14:56:10 +08:00
sql = row_count_override
else :
2026-03-04 19:10:06 +08:00
prompt = custom_user_prompt if custom_user_prompt is not None else user_prompt
sql = await chat_mdl . async_chat ( sys_prompt , [ { " role " : " user " , " content " : prompt } ] , { " temperature " : 0.06 } )
sql = normalize_sql ( sql )
sql = add_kb_filter ( sql )
2025-09-04 16:51:13 +08:00
2024-11-14 17:13:48 +08:00
logging . debug ( f " { question } get SQL(refined): { sql } " )
2024-08-15 09:17:36 +08:00
tried_times + = 1
2026-01-19 19:35:14 +08:00
logging . debug ( f " use_sql: Executing SQL retrieval (attempt { tried_times } ) " )
tbl = settings . retriever . sql_retrieval ( sql , format = " json " )
if tbl is None :
logging . debug ( " use_sql: SQL retrieval returned None " )
return None , sql
logging . debug ( f " use_sql: SQL retrieval completed, got { len ( tbl . get ( ' rows ' , [ ] ) ) } rows " )
return tbl , sql
2024-08-15 09:17:36 +08:00
2026-03-04 19:10:06 +08:00
async def repair_table_for_missing_source_columns ( previous_sql ) :
if doc_engine in ( " infinity " , " oceanbase " ) :
json_field_names = list ( field_map . keys ( ) )
repair_prompt = """ Table name: {} ;
JSON fields available in ' chunk_data ' column ( use exact names ) :
{ }
Question : { }
Previous SQL :
{ }
The previous SQL result is missing required source columns for citations .
Rewrite SQL to keep the same query intent and include doc_id and { } in the SELECT list .
For extracted JSON fields , use json_extract_string ( chunk_data , ' $.field_name ' ) .
2026-05-09 11:52:06 +09:00
Return ONLY SQL . """ .format(table_name, " \n " .join([f " - {field} " for field in json_field_names]), question, previous_sql, expected_doc_name_column)
2026-03-04 19:10:06 +08:00
else :
repair_prompt = """ Table name: {}
Available fields :
{ }
Question : { }
Previous SQL :
{ }
The previous SQL result is missing required source columns for citations .
Rewrite SQL to keep the same query intent and include doc_id and docnm_kwd in the SELECT list .
2026-05-09 11:52:06 +09:00
Return ONLY SQL . """ .format(table_name, " \n " .join([f " - {k} ( {v} ) " for k, v in field_map.items()]), question, previous_sql)
2026-03-04 19:10:06 +08:00
return await get_table ( custom_user_prompt = repair_prompt )
2025-12-01 12:42:35 +08:00
try :
2025-12-11 17:38:17 +08:00
tbl , sql = await get_table ( )
2026-01-19 19:35:14 +08:00
logging . debug ( f " use_sql: Initial SQL execution SUCCESS. SQL: { sql } " )
logging . debug ( f " use_sql: Retrieved { len ( tbl . get ( ' rows ' , [ ] ) ) } rows, columns: { [ c [ ' name ' ] for c in tbl . get ( ' columns ' , [ ] ) ] } " )
2025-12-01 12:42:35 +08:00
except Exception as e :
2026-01-19 19:35:14 +08:00
logging . warning ( f " use_sql: Initial SQL execution FAILED with error: { e } " )
# Build retry prompt with error information
2026-01-31 15:11:54 +08:00
if doc_engine in ( " infinity " , " oceanbase " ) :
2026-01-19 19:35:14 +08:00
# Build Infinity error retry prompt
json_field_names = list ( field_map . keys ( ) )
user_prompt = """
Table name : { } ;
JSON fields available in ' chunk_data ' column ( use these exact names in json_extract_string ) :
{ }
Question : { }
Please write the SQL using json_extract_string ( chunk_data , ' $.field_name ' ) with the field names from the list above . Only SQL , no explanations .
The SQL error you provided last time is as follows :
{ }
Please correct the error and write SQL again using json_extract_string ( chunk_data , ' $.field_name ' ) syntax with the correct field names . Only SQL , no explanations .
""" .format(table_name, " \n " .join([f " - {field} " for field in json_field_names]), question, e)
else :
# Build ES/OS error retry prompt
user_prompt = """
2024-12-19 18:13:33 +08:00
Table name : { } ;
2026-01-19 19:35:14 +08:00
Table of database fields are as follows ( use the field names directly in SQL ) :
2024-08-15 09:17:36 +08:00
{ }
2025-04-15 10:20:33 +08:00
2024-12-19 18:13:33 +08:00
Question are as follows :
2024-08-15 09:17:36 +08:00
{ }
2026-01-19 19:35:14 +08:00
Please write the SQL using the exact field names above , only SQL , without any other explanations or text .
2025-04-15 10:20:33 +08:00
2024-08-15 09:17:36 +08:00
2024-12-19 18:13:33 +08:00
The SQL error you provided last time is as follows :
2024-08-15 09:17:36 +08:00
{ }
2026-01-19 19:35:14 +08:00
Please correct the error and write SQL again using the exact field names above , only SQL , without any other explanations or text .
""" .format(table_name, " \n " .join([f " {k} ( {v} ) " for k, v in field_map.items()]), question, e)
2025-12-01 12:42:35 +08:00
try :
2025-12-11 17:38:17 +08:00
tbl , sql = await get_table ( )
2026-01-19 19:35:14 +08:00
logging . debug ( f " use_sql: Retry SQL execution SUCCESS. SQL: { sql } " )
logging . debug ( f " use_sql: Retrieved { len ( tbl . get ( ' rows ' , [ ] ) ) } rows on retry " )
2025-12-01 12:42:35 +08:00
except Exception :
2026-01-19 19:35:14 +08:00
logging . error ( " use_sql: Retry SQL execution also FAILED, returning None " )
2025-12-01 12:42:35 +08:00
return
2024-08-15 09:17:36 +08:00
2025-12-01 12:42:35 +08:00
if len ( tbl [ " rows " ] ) == 0 :
2026-01-19 19:35:14 +08:00
logging . warning ( f " use_sql: No rows returned from SQL query, returning None. SQL: { sql } " )
2024-08-15 09:17:36 +08:00
return None
2026-03-04 19:10:06 +08:00
if not is_aggregate_sql ( sql ) and not has_source_columns ( tbl . get ( " columns " , [ ] ) ) :
logging . warning ( f " use_sql: Non-aggregate SQL missing required source columns; retrying once. SQL: { sql } " )
try :
repaired_tbl , repaired_sql = await repair_table_for_missing_source_columns ( sql )
2026-05-09 11:52:06 +09:00
if repaired_tbl and len ( repaired_tbl . get ( " rows " , [ ] ) ) > 0 and has_source_columns ( repaired_tbl . get ( " columns " , [ ] ) ) :
2026-03-04 19:10:06 +08:00
tbl , sql = repaired_tbl , repaired_sql
logging . info ( f " use_sql: Source-column SQL repair succeeded. SQL: { sql } " )
else :
logging . warning ( f " use_sql: Source-column SQL repair did not provide required columns. Repaired SQL: { repaired_sql } " )
except Exception as e :
logging . warning ( f " use_sql: Source-column SQL repair failed, returning best-effort answer. Error: { e } " )
2026-01-19 19:35:14 +08:00
logging . debug ( f " use_sql: Proceeding with { len ( tbl [ ' rows ' ] ) } rows to build answer " )
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 " ] ] )
2026-04-30 18:13:27 +03:00
kb_id_idx = set ( [ ii for ii , c in enumerate ( tbl [ " columns " ] ) if c [ " name " ] . lower ( ) in [ " kb_id " , " kb_id_kwd " ] ] )
2026-01-19 19:35:14 +08:00
logging . debug ( f " use_sql: All columns: { [ ( i , c [ ' name ' ] ) for i , c in enumerate ( tbl [ ' columns ' ] ) ] } " )
2026-04-30 18:13:27 +03:00
logging . debug ( f " use_sql: docid_idx= { docid_idx } , doc_name_idx= { doc_name_idx } , kb_id_idx= { kb_id_idx } " )
2026-01-19 19:35:14 +08:00
2026-04-30 18:13:27 +03:00
column_idx = [ ii for ii in range ( len ( tbl [ " columns " ] ) ) if ii not in ( docid_idx | doc_name_idx | kb_id_idx ) ]
2024-08-15 09:17:36 +08:00
2026-01-19 19:35:14 +08:00
logging . debug ( f " use_sql: column_idx= { column_idx } " )
logging . debug ( f " use_sql: field_map= { field_map } " )
# Helper function to map column names to display names
def map_column_name ( col_name ) :
if col_name . lower ( ) == " count(star) " :
return " COUNT(*) "
# First, try to extract AS alias from any expression (aggregate functions, json_extract_string, etc.)
# Pattern: anything AS alias_name
2026-05-09 11:52:06 +09:00
as_match = re . search ( r " \ s+AS \ s+([^ \ s,)]+) " , col_name , re . IGNORECASE )
2026-01-19 19:35:14 +08:00
if as_match :
2026-05-09 11:52:06 +09:00
alias = as_match . group ( 1 ) . strip ( " \" ' " )
2026-01-19 19:35:14 +08:00
# Use the alias for display name lookup
if alias in field_map :
display = field_map [ alias ]
return re . sub ( r " (/.*|( [^( ) ]+) ) " , " " , display )
# If alias not in field_map, try to match case-insensitively
for field_key , display_value in field_map . items ( ) :
if field_key . lower ( ) == alias . lower ( ) :
return re . sub ( r " (/.*|( [^( ) ]+) ) " , " " , display_value )
# Return alias as-is if no mapping found
return alias
# Try direct mapping first (for simple column names)
if col_name in field_map :
display = field_map [ col_name ]
# Clean up any suffix patterns
return re . sub ( r " (/.*|( [^( ) ]+) ) " , " " , display )
# Try case-insensitive match for simple column names
col_lower = col_name . lower ( )
for field_key , display_value in field_map . items ( ) :
if field_key . lower ( ) == col_lower :
return re . sub ( r " (/.*|( [^( ) ]+) ) " , " " , display_value )
# For aggregate expressions or complex expressions without AS alias,
# try to replace field names with display names
result = col_name
for field_name , display_name in field_map . items ( ) :
# Replace field_name with display_name in the expression
result = result . replace ( field_name , display_name )
# Clean up any suffix patterns
result = re . sub ( r " (/.*|( [^( ) ]+) ) " , " " , result )
return result
2024-12-19 18:13:33 +08:00
# compose Markdown table
2026-05-09 11:52:06 +09:00
columns = " | " + " | " . join ( [ map_column_name ( tbl [ " columns " ] [ i ] [ " name " ] ) for i in column_idx ] ) + ( " |Source| " if docid_idx and doc_name_idx else " | " )
2024-08-15 09:17:36 +08:00
2025-03-24 13:18:47 +08:00
line = " | " + " | " . join ( [ " ------ " for _ in range ( len ( column_idx ) ) ] ) + ( " |------| " if docid_idx and docid_idx else " " )
2024-08-15 09:17:36 +08:00
2026-01-19 19:35:14 +08:00
# Build rows ensuring column names match values - create a dict for each row
# keyed by column name to handle any SQL column order
rows = [ ]
for row_idx , r in enumerate ( tbl [ " rows " ] ) :
row_dict = { tbl [ " columns " ] [ i ] [ " name " ] : r [ i ] for i in range ( len ( tbl [ " columns " ] ) ) if i < len ( r ) }
if row_idx == 0 :
logging . debug ( f " use_sql: First row data: { row_dict } " )
row_values = [ ]
for col_idx in column_idx :
col_name = tbl [ " columns " ] [ col_idx ] [ " name " ]
value = row_dict . get ( col_name , " " )
row_values . append ( remove_redundant_spaces ( str ( value ) ) . replace ( " None " , " " ) )
# Add Source column with citation marker if Source column exists
if docid_idx and doc_name_idx :
row_values . append ( f " ## { row_idx } $$ " )
row_str = " | " + " | " . join ( row_values ) + " | "
if re . sub ( r " [ |]+ " , " " , row_str ) :
rows . append ( row_str )
2024-08-15 09:17:36 +08:00
if quota :
2026-01-19 19:35:14 +08:00
rows = " \n " . join ( rows )
2024-08-15 09:17:36 +08:00
else :
2026-01-19 19:35:14 +08:00
rows = " \n " . join ( rows )
2024-08-15 09:17:36 +08:00
rows = re . sub ( r " T[0-9] {2} :[0-9] {2} :[0-9] {2} ( \ .[0-9]+Z)? \ | " , " | " , rows )
2024-12-19 18:13:33 +08:00
if not docid_idx or not doc_name_idx :
2026-01-19 19:35:14 +08:00
logging . warning ( f " use_sql: SQL missing required doc_id or docnm_kwd field. docid_idx= { docid_idx } , doc_name_idx= { doc_name_idx } . SQL: { sql } " )
# For aggregate queries (COUNT, SUM, AVG, MAX, MIN, DISTINCT), fetch doc_id, docnm_kwd separately
# to provide source chunks, but keep the original table format answer
2026-03-04 19:10:06 +08:00
if is_aggregate_sql ( sql ) :
2026-01-19 19:35:14 +08:00
# Keep original table format as answer
answer = " \n " . join ( [ columns , line , rows ] )
# Now fetch doc_id, docnm_kwd to provide source chunks
# Extract WHERE clause from the original SQL
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 ( )
2026-04-30 18:13:27 +03:00
# 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 } "
2026-01-19 19:35:14 +08:00
# Add LIMIT to avoid fetching too many chunks
if " limit " not in chunks_sql . lower ( ) :
chunks_sql + = " limit 20 "
logging . debug ( f " use_sql: Fetching chunks with SQL: { chunks_sql } " )
try :
chunks_tbl = settings . retriever . sql_retrieval ( chunks_sql , format = " json " )
if chunks_tbl . get ( " rows " ) and len ( chunks_tbl [ " rows " ] ) > 0 :
# 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 )
2026-04-30 18:13:27 +03:00
chunks_kb_idx = next ( ( i for i , c in enumerate ( chunks_tbl [ " columns " ] ) if c [ " name " ] . lower ( ) in [ " kb_id " , " kb_id_kwd " ] ) , None )
2026-01-19 19:35:14 +08:00
if chunks_did_idx is not None and chunks_dn_idx is not None :
2026-04-30 18:13:27 +03:00
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 )
2026-01-19 19:35:14 +08:00
# Build doc_aggs
doc_aggs = { }
for r in chunks_tbl [ " rows " ] :
doc_id = r [ chunks_did_idx ]
doc_name = r [ chunks_dn_idx ]
if doc_id not in doc_aggs :
doc_aggs [ doc_id ] = { " doc_name " : doc_name , " count " : 0 }
doc_aggs [ doc_id ] [ " count " ] + = 1
doc_aggs_list = [ { " doc_id " : did , " doc_name " : d [ " doc_name " ] , " count " : d [ " count " ] } for did , d in doc_aggs . items ( ) ]
logging . debug ( f " use_sql: Returning aggregate answer with { len ( chunks ) } chunks from { len ( doc_aggs ) } documents " )
return { " answer " : answer , " reference " : { " chunks " : chunks , " doc_aggs " : doc_aggs_list } , " prompt " : sys_prompt }
except Exception as e :
logging . warning ( f " use_sql: Failed to fetch chunks: { e } " )
# Fallback: return answer without chunks
return { " answer " : answer , " reference " : { " chunks " : [ ] , " doc_aggs " : [ ] } , " prompt " : sys_prompt }
# Fallback to table format for other cases
2025-03-24 13:18:47 +08:00
return { " answer " : " \n " . join ( [ columns , line , rows ] ) , " reference " : { " chunks " : [ ] , " doc_aggs " : [ ] } , " prompt " : sys_prompt }
2024-08-15 09:17:36 +08:00
docid_idx = list ( docid_idx ) [ 0 ]
2024-12-19 18:13:33 +08:00
doc_name_idx = list ( doc_name_idx ) [ 0 ]
2024-08-15 09:17:36 +08:00
doc_aggs = { }
for r in tbl [ " rows " ] :
if r [ docid_idx ] not in doc_aggs :
2024-12-19 18:13:33 +08:00
doc_aggs [ r [ docid_idx ] ] = { " doc_name " : r [ doc_name_idx ] , " count " : 0 }
2024-08-15 09:17:36 +08:00
doc_aggs [ r [ docid_idx ] ] [ " count " ] + = 1
2026-01-19 19:35:14 +08:00
result = {
2024-12-19 18:13:33 +08:00
" answer " : " \n " . join ( [ columns , line , rows ] ) ,
2025-03-24 13:18:47 +08:00
" reference " : {
2026-04-30 18:13:27 +03:00
" 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 " ]
] ,
2025-03-24 13:18:47 +08:00
" doc_aggs " : [ { " doc_id " : did , " doc_name " : d [ " doc_name " ] , " count " : d [ " count " ] } for did , d in doc_aggs . items ( ) ] ,
} ,
" prompt " : sys_prompt ,
2024-08-15 09:17:36 +08:00
}
2026-01-19 19:35:14 +08:00
logging . debug ( f " use_sql: Returning answer with { len ( result [ ' reference ' ] [ ' chunks ' ] ) } chunks from { len ( doc_aggs ) } documents " )
return result
2024-08-15 09:17:36 +08:00
2026-05-09 11:52:06 +09:00
2025-12-03 12:03:59 +08:00
def clean_tts_text ( text : str ) - > str :
if not text :
return " "
text = text . encode ( " utf-8 " , " ignore " ) . decode ( " utf-8 " , " ignore " )
text = re . sub ( r " [ \ x00- \ x08 \ x0B- \ x0C \ x0E- \ x1F \ x7F] " , " " , text )
emoji_pattern = re . compile (
2026-05-09 11:52:06 +09:00
" [ \U0001f600 - \U0001f64f \U0001f300 - \U0001f5ff \U0001f680 - \U0001f6ff \U0001f1e0 - \U0001f1ff \U00002700 - \U000027bf \U0001f900 - \U0001f9ff \U0001fa70 - \U0001faff \U0001fad0 - \U0001faff ]+ " , flags = re . UNICODE
2025-12-03 12:03:59 +08:00
)
text = emoji_pattern . sub ( " " , text )
text = re . sub ( r " \ s+ " , " " , text ) . strip ( )
MAX_LEN = 500
if len ( text ) > MAX_LEN :
text = text [ : MAX_LEN ]
return text
2024-08-15 09:17:36 +08:00
2026-05-09 11:52:06 +09:00
2024-09-03 19:49:14 +08:00
def tts ( tts_mdl , text ) :
2024-12-08 14:21:12 +08:00
if not tts_mdl or not text :
2025-11-16 19:29:20 +08:00
return None
2025-12-03 12:03:59 +08:00
text = clean_tts_text ( text )
if not text :
return None
feat(tts): cache synthesized speech in Redis to avoid redundant calls (#14851)
## What problem does this PR solve?
Closes #12017.
TTS output is deterministic for a given `(model, text)` pair, so
re-running the same text through the same TTS model produces the same
bytes — yet `Canvas.tts` and `dialog_service.tts` re-synthesized on
every request. That's slow and wastes provider quota whenever the same
assistant response is replayed, shared across users, or repeated within
a session.
### Change
New helper `rag/utils/tts_cache.py` with `synthesize_with_cache(tts_mdl,
cleaned_text)`:
- **Key:** `tts:cache:{model_id}:{sha256(text)}` — separate namespace
per model, identical cleaned text reuses a single entry across both call
sites.
- **Value:** the hex-encoded audio blob both call sites already
returned. No format change for downstream consumers.
- **TTL:** 7 days by default, configurable via
`RAGFLOW_TTS_CACHE_TTL_SECONDS`.
- **Failure modes:** a Redis hiccup falls back to direct synthesis; a
failed synthesis still returns `None` (existing contract preserved).
[`Canvas.tts`](https://github.com/infiniflow/ragflow/blob/main/agent/canvas.py#L683-L724)
and
[`dialog_service.tts`](https://github.com/infiniflow/ragflow/blob/main/api/db/services/dialog_service.py#L1367-L1380)
now route through the helper; the per-file bytes-accumulation/hex-encode
loop has been removed in favor of one shared implementation.
## Type of change
- [x] New Feature (non-breaking change which adds functionality)
## Test plan
- [ ] **Cache hit, chat path:** Configure a dialog with TTS enabled, ask
the same question twice with `stream=false`. Verify the second response
returns the same `audio_binary` and that the second invocation doesn't
hit the TTS provider (e.g., observe provider-side logs / usage counters;
check no `LLMBundle.tts can't update token usage` log line on the second
run).
- [ ] **Cache hit, agent path:** Same exercise via a Conversational
Agent that includes a Message component playing back the answer.
- [ ] **Cache isolation per model:** Switch tenant's `tts_id` between
two models, run the same text against each — confirm the second model's
first synthesis still happens (no cross-model hits).
- [ ] **TTL override:** Set `RAGFLOW_TTS_CACHE_TTL_SECONDS=120`, confirm
the entry expires after 2 minutes.
- [ ] **Redis unavailable:** Stop Redis (or break the connection).
Verify the TTS endpoint still works — synthesis falls back to direct
calls, with a `TTS cache lookup failed` / `TTS cache store failed`
warning logged.
- [ ] **Failure path:** Configure a TTS model with an invalid API key,
ensure the response still returns successfully with `audio_binary=None`
(no regression vs. current behavior).
2026-05-18 23:20:40 -07:00
return synthesize_with_cache ( tts_mdl , text )
2024-09-09 12:08:50 +08:00
2026-01-08 13:34:16 +08:00
class _ThinkStreamState :
def __init__ ( self ) - > None :
self . full_text = " "
self . last_idx = 0
self . last_model_full = " "
self . in_think = False
2026-06-08 16:53:22 +08:00
self . close_pending = False
self . pending_after_close = " "
self . think_buffer = " "
self . answer_buffer = " "
2026-01-08 13:34:16 +08:00
2026-04-22 11:15:08 +08:00
def _extract_visible_answer ( text : str ) - > str :
text = text or " "
if " </think> " not in text :
return re . sub ( r " </?think> " , " " , text )
thought , answer = text . rsplit ( " </think> " , 1 )
thought = re . sub ( r " </?think> " , " " , thought ) . strip ( )
answer = re . sub ( r " </?think> " , " " , answer )
if not thought :
return answer
return f " <think> { thought } </think> { answer } "
2026-01-08 13:34:16 +08:00
async def _stream_with_think_delta ( stream_iter , min_tokens : int = 16 ) :
state = _ThinkStreamState ( )
2026-06-08 16:53:22 +08:00
def _emit_text ( section : str , text : str ) :
if not text :
return None
if section == " think " :
2026-06-10 16:55:57 +08:00
return text
2026-06-08 16:53:22 +08:00
state . answer_buffer + = text
if num_tokens_from_string ( state . answer_buffer ) > = min_tokens :
out = state . answer_buffer
state . answer_buffer = " "
return out
return None
def _flush_think_buffer ( ) :
if not state . think_buffer :
return None
out = state . think_buffer
state . think_buffer = " "
return out
def _flush_answer_buffer ( ) :
if not state . answer_buffer :
return None
out = state . answer_buffer
state . answer_buffer = " "
return out
2026-01-08 13:34:16 +08:00
async for chunk in stream_iter :
if not chunk :
continue
if chunk . startswith ( state . last_model_full ) :
2026-05-09 11:52:06 +09:00
new_part = chunk [ len ( state . last_model_full ) : ]
2026-01-08 13:34:16 +08:00
state . last_model_full = chunk
else :
new_part = chunk
state . last_model_full + = chunk
if not new_part :
continue
state . full_text + = new_part
2026-06-08 16:53:22 +08:00
pending = new_part
if state . close_pending and " </think> " not in pending :
state . close_pending = False
think_piece = _flush_think_buffer ( )
if think_piece is not None :
yield ( " text " , think_piece , state )
state . in_think = False
yield ( " marker " , " </think> " , state )
if state . pending_after_close :
answer_piece = state . pending_after_close
state . pending_after_close = " "
out = _emit_text ( " answer " , answer_piece )
if out is not None :
yield ( " text " , out , state )
answer_piece = re . sub ( r " </?think> " , " " , pending or " " )
if answer_piece :
out = _emit_text ( " answer " , answer_piece )
if out is not None :
yield ( " text " , out , state )
2026-01-08 13:34:16 +08:00
continue
2026-06-08 16:53:22 +08:00
while pending :
open_idx = pending . find ( " <think> " )
close_idx = pending . find ( " </think> " )
if open_idx == - 1 and close_idx == - 1 :
piece = re . sub ( r " </?think> " , " " , pending or " " )
if piece :
section = " think " if state . in_think else " answer "
out = _emit_text ( section , piece )
if out is not None :
yield ( " text " , out , state )
break
if open_idx != - 1 and ( close_idx == - 1 or open_idx < close_idx ) :
before = pending [ : open_idx ]
if before :
piece = re . sub ( r " </?think> " , " " , before or " " )
section = " think " if state . in_think else " answer "
out = _emit_text ( section , piece )
if out is not None :
yield ( " text " , out , state )
pending = pending [ open_idx + len ( " <think> " ) : ]
if not state . in_think :
answer_piece = _flush_answer_buffer ( )
if answer_piece is not None :
yield ( " text " , answer_piece , state )
think_piece = _flush_think_buffer ( )
if think_piece is not None :
yield ( " text " , think_piece , state )
state . in_think = True
yield ( " marker " , " <think> " , state )
2026-01-08 13:34:16 +08:00
continue
2026-06-08 16:53:22 +08:00
before = pending [ : close_idx ]
after = pending [ close_idx + len ( " </think> " ) : ]
if before :
piece = re . sub ( r " </?think> " , " " , before or " " )
section = " think " if state . in_think else " answer "
out = _emit_text ( section , piece )
if out is not None :
yield ( " text " , out , state )
after_visible = re . sub ( r " </?think> " , " " , after or " " )
if after_visible . strip ( ) :
think_piece = _flush_think_buffer ( )
if think_piece is not None :
yield ( " text " , think_piece , state )
state . in_think = False
yield ( " marker " , " </think> " , state )
pending = after_visible
2026-01-08 13:34:16 +08:00
continue
2026-06-08 16:53:22 +08:00
state . close_pending = True
if after_visible :
state . pending_after_close + = after_visible
pending = " "
break
if state . think_buffer :
yield ( " text " , state . think_buffer , state )
state . think_buffer = " "
if state . close_pending :
state . in_think = False
2026-01-08 13:34:16 +08:00
yield ( " marker " , " </think> " , state )
2026-06-08 16:53:22 +08:00
if state . answer_buffer :
yield ( " text " , state . answer_buffer , state )
state . answer_buffer = " "
if state . pending_after_close :
yield ( " text " , state . pending_after_close , state )
state . pending_after_close = " "
2026-01-08 13:34:16 +08:00
2026-05-09 11:52:06 +09:00
2026-05-22 16:05:13 +08:00
async def async_ask ( question , kb_ids , tenant_id , chat_llm_name = None , search_config = { } , search_id = None ) :
2025-08-19 17:25:44 +08:00
doc_ids = search_config . get ( " doc_ids " , [ ] )
2025-08-19 09:33:33 +08:00
rerank_mdl = None
2025-08-19 17:25:44 +08:00
kb_ids = search_config . get ( " kb_ids " , kb_ids )
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 " )
2026-04-30 18:13:27 +03:00
include_reference_metadata , metadata_fields = _resolve_reference_metadata ( search_config )
2025-08-19 09:33:33 +08:00
2024-09-09 12:08:50 +08:00
kbs = KnowledgebaseService . get_by_ids ( kb_ids )
2026-06-11 15:52:59 +08:00
if not kbs :
if not kb_ids :
error = " **ERROR**: No KB selected "
else :
error = " **ERROR**: The selected KB is not valid "
yield { " answer " : error , " reference " : { } , " final " : True }
return
2024-12-19 18:13:33 +08:00
embedding_list = list ( set ( [ kb . embd_id for kb in kbs ] ) )
2024-09-09 12:08:50 +08:00
2024-12-19 18:13:33 +08:00
is_knowledge_graph = all ( [ kb . parser_id == ParserType . KG for kb in kbs ] )
2025-11-06 09:36:38 +08:00
retriever = settings . retriever if not is_knowledge_graph else settings . kg_retriever
2026-03-26 21:01:07 +08:00
embd_owner_tenant_id = kbs [ 0 ] . tenant_id
2026-05-29 17:39:41 +08:00
embd_model_config = get_model_config_from_provider_instance ( embd_owner_tenant_id , LLMType . EMBEDDING , embedding_list [ 0 ] )
2026-03-26 21:01:07 +08:00
embd_mdl = LLMBundle ( embd_owner_tenant_id , embd_model_config )
2026-05-29 17:39:41 +08:00
chat_model_config = get_model_config_from_provider_instance ( tenant_id , LLMType . CHAT , chat_llm_name )
2026-03-05 17:27:17 +08:00
chat_mdl = LLMBundle ( tenant_id , chat_model_config )
2025-08-19 09:33:33 +08:00
if rerank_id :
2026-05-29 17:39:41 +08:00
rerank_model_config = get_model_config_from_provider_instance ( tenant_id , LLMType . RERANK , rerank_id )
2026-03-05 17:27:17 +08:00
rerank_mdl = LLMBundle ( tenant_id , rerank_model_config )
2024-09-09 12:08:50 +08:00
max_tokens = chat_mdl . max_length
2024-12-10 17:03:24 +08:00
tenant_ids = list ( set ( [ kb . tenant_id for kb in kbs ] ) )
2025-08-19 10:27:24 +08:00
2025-08-19 17:25:44 +08:00
if meta_data_filter :
Perf: push metadata filters down to Elasticsearch (#14576)
### What problem does this PR solve?
Fixes #14412.
`common.metadata_utils.meta_filter` evaluates user-defined metadata
conditions in Python after `DocMetadataService.get_flatted_meta_by_kbs`
loads the entire `meta_fields` table into memory. Past a few thousand
documents per knowledge base this becomes a memory bottleneck and a
wasted ES round-trip — every filter request currently fetches up to
10000 metadata rows even when the resulting `doc_ids` list is tiny.
This PR adds an ES push-down path that translates the same filter
language into a `bool` query and returns just the matching document IDs.
**Changes**
- `common/metadata_es_filter.py` *(new)*: pure-Python translator from
the RAGflow filter list to ES DSL. Covers every operator the in-memory
path supports (`=`, `≠`, `>`, `<`, `≥`, `≤`, `in`, `not in`, `contains`,
`not contains`, `start with`, `end with`, `empty`, `not empty`) with
`case_insensitive: true` on `prefix` and `wildcard` for parity with the
existing lower-cased Python comparisons. User wildcard metacharacters
are escaped before being injected into `wildcard` patterns. Negative
operators (`≠`, `not in`, `not contains`, ranges) are wrapped with an
`exists` guard so they do not accidentally match documents missing the
key, matching the legacy `if k not in metas` behaviour.
- `api/db/services/doc_metadata_service.py`: new
`DocMetadataService.filter_doc_ids_by_meta_pushdown(kb_ids, filters,
logic)` that returns the doc IDs ES matched, or `None` to signal the
caller should fall back to the in-memory path. Returns `None` when the
active doc store is Infinity (`meta_fields` is a JSON column, not a
dotted-object mapping), when any filter cannot be expressed in DSL
(`UnsupportedMetaFilter`), or when the ES request or metadata index
lookup errors.
- `common/metadata_utils.py`: `apply_meta_data_filter` accepts an
optional `kb_ids` argument. When supplied, conditions go through
push-down first via a new `_try_meta_pushdown` helper; on `None` the
function falls back to the original `meta_filter` call. Default
behaviour is unchanged for callers that don't pass `kb_ids`.
- Updated all four callers (`agent/tools/retrieval.py`,
`api/db/services/dialog_service.py` ×2,
`api/apps/services/dataset_api_service.py`, `api/apps/sdk/session.py`)
to forward `kb_ids` so the push-down path is exercised in production.
- `test/unit_test/common/test_metadata_es_filter.py` *(new)*: 35 unit
tests covering every operator's DSL shape, value coercion
(`ast.literal_eval`, lowercasing, ISO-date pass-through), wildcard
escaping, OR-logic wrapping that protects negative clauses, and the
doc-ID extractor.
**Behaviour preserved**
- The in-memory `meta_filter` is untouched and still services every
fallback case (Infinity backend, unknown operators, ES outages).
- The eligibility / credibility / issue-multiplier semantics described
in the LLM-driven `auto` and `semi_auto` modes still hand the LLM the
full in-memory `metas` dict to choose conditions from. Only the
*evaluation* of those generated conditions is pushed down.
- Existing tests in
`test/unit_test/common/test_metadata_filter_operators.py` continue to
pass (14/14).
**Test plan**
- `pytest test/unit_test/common/test_metadata_es_filter.py` — 35 passed.
- `pytest test/unit_test/common/test_metadata_filter_operators.py` — 14
passed.
- `ruff check` clean on every modified file.
- Reviewer please validate the ES query shapes against a live cluster —
particularly `case_insensitive` on `wildcard` and `prefix` (requires ES
7.10+) and the `exists` + `must_not` pairing for `≠`.
**Notes**
- The first cut caps each push-down request at 10000 results, matching
the existing `get_flatted_meta_by_kbs` limit, and logs a warning when
the cap is hit. A `search_after` follow-up would let us drop the cap
entirely once the push-down path is validated.
- Operator parity with the in-memory path is exact for the canonical
unicode operators (`≥`, `≤`, `≠`) used internally; the ASCII aliases
(`>=`, `<=`, `!=`) are normalised by `convert_conditions` before they
reach the translator.
### Type of change
- [x] Performance Improvement
---------
Co-authored-by: sxxtony <sxxtony@users.noreply.github.com>
2026-05-07 16:23:43 +03:00
doc_ids = await apply_meta_data_filter (
meta_data_filter ,
None ,
question ,
chat_mdl ,
doc_ids ,
kb_ids = kb_ids ,
metas_loader = lambda : DocMetadataService . get_flatted_meta_by_kbs ( kb_ids ) ,
)
2025-08-19 17:25:44 +08:00
2026-05-22 16:05:13 +08:00
vector_similarity_weight = search_config . get ( " vector_similarity_weight " , 0.3 )
try :
full_text_weight = 1 - vector_similarity_weight
except TypeError :
full_text_weight = None
logger . debug (
" Search async_ask retrieval weight: search_id= %s tenant_id= %s kb_count= %s "
" vector_similarity_weight= %s full_text_weight= %s " ,
search_id ,
tenant_id ,
len ( kb_ids ) ,
vector_similarity_weight ,
full_text_weight ,
)
2026-01-15 12:28:49 +08:00
kbinfos = await retriever . retrieval (
2025-09-23 10:19:25 +08:00
question = question ,
2025-08-19 09:33:33 +08:00
embd_mdl = embd_mdl ,
tenant_ids = tenant_ids ,
kb_ids = kb_ids ,
page = 1 ,
page_size = 12 ,
2025-08-19 17:25:44 +08:00
similarity_threshold = search_config . get ( " similarity_threshold " , 0.1 ) ,
2026-05-22 16:05:13 +08:00
vector_similarity_weight = vector_similarity_weight ,
2025-08-19 17:25:44 +08:00
top = search_config . get ( " top_k " , 1024 ) ,
2025-08-19 09:33:33 +08:00
doc_ids = doc_ids ,
2025-12-23 15:57:27 +08:00
aggs = True ,
2025-08-19 09:33:33 +08:00
rerank_mdl = rerank_mdl ,
2026-05-09 11:52:06 +09:00
rank_feature = label_question ( question , kbs ) ,
2026-05-22 16:05:13 +08:00
trace_id = search_id ,
2025-08-19 09:33:33 +08:00
)
2026-04-30 18:13:27 +03:00
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 )
2025-08-19 09:33:33 +08:00
2024-12-10 17:03:24 +08:00
knowledges = kb_prompt ( kbinfos , max_tokens )
2025-08-19 10:27:24 +08:00
sys_prompt = PROMPT_JINJA_ENV . from_string ( ASK_SUMMARY ) . render ( knowledge = " \n " . join ( knowledges ) )
2024-09-09 12:08:50 +08:00
msg = [ { " role " : " user " , " content " : question } ]
2026-05-18 14:21:56 +08:00
async def decorate_answer ( answer ) :
2025-08-19 10:27:24 +08:00
nonlocal knowledges , kbinfos , sys_prompt
2026-05-18 14:21:56 +08:00
# Main retrieval no longer ships chunk vectors back from ES. Pull
# them on demand for the chunks we are about to cite.
await _hydrate_chunk_vectors ( retriever , kbinfos . get ( " chunks " , [ ] ) , tenant_ids , kb_ids )
2026-05-09 11:52:06 +09:00
answer , idx = retriever . insert_citations ( answer , [ ck [ " content_ltks " ] for ck in kbinfos [ " chunks " ] ] , [ ck [ " vector " ] for ck in kbinfos [ " chunks " ] ] , embd_mdl , tkweight = 0.7 , vtweight = 0.3 )
2024-09-09 12:08:50 +08:00
idx = set ( [ kbinfos [ " chunks " ] [ int ( i ) ] [ " doc_id " ] for i in idx ] )
2025-03-24 13:18:47 +08:00
recall_docs = [ d for d in kbinfos [ " doc_aggs " ] if d [ " doc_id " ] in idx ]
2024-12-08 14:21:12 +08:00
if not recall_docs :
recall_docs = kbinfos [ " doc_aggs " ]
2024-09-09 12:08:50 +08:00
kbinfos [ " doc_aggs " ] = recall_docs
refs = deepcopy ( kbinfos )
for c in refs [ " chunks " ] :
if c . get ( " vector " ) :
del c [ " vector " ]
if answer . lower ( ) . find ( " invalid key " ) > = 0 or answer . lower ( ) . find ( " invalid api " ) > = 0 :
2024-12-10 17:03:24 +08:00
answer + = " Please set LLM API-Key in ' User Setting -> Model Providers -> API-Key ' "
2025-03-05 17:25:47 +08:00
refs [ " chunks " ] = chunks_format ( refs )
return { " answer " : answer , " reference " : refs }
2024-09-09 12:08:50 +08:00
2026-01-08 13:34:16 +08:00
stream_iter = chat_mdl . async_chat_streamly_delta ( sys_prompt , msg , { " temperature " : 0.1 } )
last_state = None
async for kind , value , state in _stream_with_think_delta ( stream_iter ) :
last_state = state
if kind == " marker " :
flags = { " start_to_think " : True } if value == " <think> " else { " end_to_think " : True }
yield { " answer " : " " , " reference " : { } , " final " : False , * * flags }
continue
yield { " answer " : value , " reference " : { } , " final " : False }
full_answer = last_state . full_text if last_state else " "
2026-05-18 14:21:56 +08:00
final = await decorate_answer ( _extract_visible_answer ( full_answer ) )
2026-01-08 13:34:16 +08:00
final [ " final " ] = True
yield final
2025-08-19 17:25:44 +08:00
2025-12-11 09:47:44 +08:00
async def gen_mindmap ( question , kb_ids , tenant_id , search_config = { } ) :
2025-08-19 17:25:44 +08:00
meta_data_filter = search_config . get ( " meta_data_filter " , { } )
doc_ids = search_config . get ( " doc_ids " , [ ] )
rerank_id = search_config . get ( " rerank_id " , " " )
rerank_mdl = None
kbs = KnowledgebaseService . get_by_ids ( kb_ids )
2025-08-19 18:57:35 +08:00
if not kbs :
return { " error " : " No KB selected " }
2025-08-19 17:25:44 +08:00
tenant_ids = list ( set ( [ kb . tenant_id for kb in kbs ] ) )
2026-05-29 17:39:41 +08:00
embd_owner_tenant_id = kbs [ 0 ] . tenant_id
embd_model_config = get_model_config_from_provider_instance ( embd_owner_tenant_id , LLMType . EMBEDDING , kbs [ 0 ] . embd_id )
2026-03-26 21:01:07 +08:00
embd_mdl = LLMBundle ( embd_owner_tenant_id , embd_model_config )
2026-03-05 17:27:17 +08:00
chat_id = search_config . get ( " chat_id " , " " )
if chat_id :
2026-05-29 17:39:41 +08:00
chat_model_config = get_model_config_from_provider_instance ( tenant_id , LLMType . CHAT , chat_id )
2026-03-05 17:27:17 +08:00
else :
chat_model_config = get_tenant_default_model_by_type ( tenant_id , LLMType . CHAT )
chat_mdl = LLMBundle ( tenant_id , chat_model_config )
2025-08-19 17:25:44 +08:00
if rerank_id :
2026-05-29 17:39:41 +08:00
rerank_model_config = get_model_config_from_provider_instance ( tenant_id , LLMType . RERANK , rerank_id )
2026-03-05 17:27:17 +08:00
rerank_mdl = LLMBundle ( tenant_id , rerank_model_config )
2025-08-19 17:25:44 +08:00
if meta_data_filter :
Perf: push metadata filters down to Elasticsearch (#14576)
### What problem does this PR solve?
Fixes #14412.
`common.metadata_utils.meta_filter` evaluates user-defined metadata
conditions in Python after `DocMetadataService.get_flatted_meta_by_kbs`
loads the entire `meta_fields` table into memory. Past a few thousand
documents per knowledge base this becomes a memory bottleneck and a
wasted ES round-trip — every filter request currently fetches up to
10000 metadata rows even when the resulting `doc_ids` list is tiny.
This PR adds an ES push-down path that translates the same filter
language into a `bool` query and returns just the matching document IDs.
**Changes**
- `common/metadata_es_filter.py` *(new)*: pure-Python translator from
the RAGflow filter list to ES DSL. Covers every operator the in-memory
path supports (`=`, `≠`, `>`, `<`, `≥`, `≤`, `in`, `not in`, `contains`,
`not contains`, `start with`, `end with`, `empty`, `not empty`) with
`case_insensitive: true` on `prefix` and `wildcard` for parity with the
existing lower-cased Python comparisons. User wildcard metacharacters
are escaped before being injected into `wildcard` patterns. Negative
operators (`≠`, `not in`, `not contains`, ranges) are wrapped with an
`exists` guard so they do not accidentally match documents missing the
key, matching the legacy `if k not in metas` behaviour.
- `api/db/services/doc_metadata_service.py`: new
`DocMetadataService.filter_doc_ids_by_meta_pushdown(kb_ids, filters,
logic)` that returns the doc IDs ES matched, or `None` to signal the
caller should fall back to the in-memory path. Returns `None` when the
active doc store is Infinity (`meta_fields` is a JSON column, not a
dotted-object mapping), when any filter cannot be expressed in DSL
(`UnsupportedMetaFilter`), or when the ES request or metadata index
lookup errors.
- `common/metadata_utils.py`: `apply_meta_data_filter` accepts an
optional `kb_ids` argument. When supplied, conditions go through
push-down first via a new `_try_meta_pushdown` helper; on `None` the
function falls back to the original `meta_filter` call. Default
behaviour is unchanged for callers that don't pass `kb_ids`.
- Updated all four callers (`agent/tools/retrieval.py`,
`api/db/services/dialog_service.py` ×2,
`api/apps/services/dataset_api_service.py`, `api/apps/sdk/session.py`)
to forward `kb_ids` so the push-down path is exercised in production.
- `test/unit_test/common/test_metadata_es_filter.py` *(new)*: 35 unit
tests covering every operator's DSL shape, value coercion
(`ast.literal_eval`, lowercasing, ISO-date pass-through), wildcard
escaping, OR-logic wrapping that protects negative clauses, and the
doc-ID extractor.
**Behaviour preserved**
- The in-memory `meta_filter` is untouched and still services every
fallback case (Infinity backend, unknown operators, ES outages).
- The eligibility / credibility / issue-multiplier semantics described
in the LLM-driven `auto` and `semi_auto` modes still hand the LLM the
full in-memory `metas` dict to choose conditions from. Only the
*evaluation* of those generated conditions is pushed down.
- Existing tests in
`test/unit_test/common/test_metadata_filter_operators.py` continue to
pass (14/14).
**Test plan**
- `pytest test/unit_test/common/test_metadata_es_filter.py` — 35 passed.
- `pytest test/unit_test/common/test_metadata_filter_operators.py` — 14
passed.
- `ruff check` clean on every modified file.
- Reviewer please validate the ES query shapes against a live cluster —
particularly `case_insensitive` on `wildcard` and `prefix` (requires ES
7.10+) and the `exists` + `must_not` pairing for `≠`.
**Notes**
- The first cut caps each push-down request at 10000 results, matching
the existing `get_flatted_meta_by_kbs` limit, and logs a warning when
the cap is hit. A `search_after` follow-up would let us drop the cap
entirely once the push-down path is validated.
- Operator parity with the in-memory path is exact for the canonical
unicode operators (`≥`, `≤`, `≠`) used internally; the ASCII aliases
(`>=`, `<=`, `!=`) are normalised by `convert_conditions` before they
reach the translator.
### Type of change
- [x] Performance Improvement
---------
Co-authored-by: sxxtony <sxxtony@users.noreply.github.com>
2026-05-07 16:23:43 +03:00
doc_ids = await apply_meta_data_filter (
meta_data_filter ,
None ,
question ,
chat_mdl ,
doc_ids ,
kb_ids = kb_ids ,
metas_loader = lambda : DocMetadataService . get_flatted_meta_by_kbs ( kb_ids ) ,
)
2025-08-19 17:25:44 +08:00
2026-01-15 12:28:49 +08:00
ranks = await settings . retriever . retrieval (
2025-08-19 17:25:44 +08:00
question = question ,
embd_mdl = embd_mdl ,
tenant_ids = tenant_ids ,
kb_ids = kb_ids ,
page = 1 ,
page_size = 12 ,
similarity_threshold = search_config . get ( " similarity_threshold " , 0.2 ) ,
vector_similarity_weight = search_config . get ( " vector_similarity_weight " , 0.3 ) ,
top = search_config . get ( " top_k " , 1024 ) ,
doc_ids = doc_ids ,
aggs = False ,
rerank_mdl = rerank_mdl ,
rank_feature = label_question ( question , kbs ) ,
)
mindmap = MindMapExtractor ( chat_mdl )
2025-12-11 09:47:44 +08:00
mind_map = await mindmap ( [ c [ " content_with_weight " ] for c in ranks [ " chunks " ] ] )
2025-09-04 16:51:13 +08:00
return mind_map . output