From 2e4c2328071222ac5e781bcacd6326cc870e2862 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:21:59 +0200 Subject: [PATCH] perf: update chat tags via the meta column instead of round-tripping the blob (#27382) update_chat_tags_by_id runs at the end of every completion when tag generation is enabled (the default). It loaded the full chat row including the multi-megabyte blob, mutated only meta.tags, committed, then refreshed the row, which re-fetched and re-parsed the entire blob a second time, and finally validated the whole thing into a ChatModel that its only caller (the auto-tagging handler) discards. add_chat_tag_by_id_and_user_id_and_tag_name had the same shape for a one-tag append, and orphan cleanup issued one COUNT query per removed tag. Both tag writers now select only the meta column and issue a column-level UPDATE, never touching the blob; the single-tag path also skips the write entirely when the tag is already present. Orphan detection batches all per-tag counts into one round trip using one scalar subquery per tag with the exact same dialect-specific EXISTS filters as before; the existing single-tag count delegates to the batch helper so there is one implementation. Benchmark (real SQLite DB, 200-message chat, ~600 KB blob): | metric | before | after | | --- | --- | --- | | auto-tag update, 3 tags replaced | 12.85 ms | 7.36 ms | The absolute saving grows with chat size since the blob no longer gets fetched, parsed, re-fetched and validated at all. Functionally verified against a fresh database: tag replacement normalizes and filters the none placeholder, creates missing tag rows and leaves the blob untouched; orphaned tags are deleted while tags still referenced by other chats survive; single-tag add is idempotent; batch counts agree with the single count including unknown tags; unknown chat ids return None. --- backend/open_webui/models/chats.py | 96 ++++++++++++++++++------------ 1 file changed, 58 insertions(+), 38 deletions(-) diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index aae292e0ad..f42af04dd5 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -722,30 +722,32 @@ class ChatTable: except Exception: return None - async def update_chat_tags_by_id(self, id: str, tags: list[str], user) -> ChatModel | None: + async def update_chat_tags_by_id(self, id: str, tags: list[str], user) -> None: + """Replace a chat's tags. Runs after every completion with tag + generation enabled, so only the meta column is read and written, + never the chat blob.""" async with get_async_db_context() as session: - chat = await session.get(Chat, id) - if chat is None: + row = (await session.execute(select(Chat.meta).filter_by(id=id))).one_or_none() + if row is None: return None - old_tags = chat.meta.get('tags', []) + meta = row[0] or {} + old_tags = meta.get('tags', []) new_tags = [t for t in tags if t.replace(' ', '_').lower() != 'none'] new_tag_ids = [t.replace(' ', '_').lower() for t in new_tags] # Single meta update - chat.meta = {**chat.meta, 'tags': new_tag_ids} + await session.execute(update(Chat).filter_by(id=id).values(meta={**meta, 'tags': new_tag_ids})) await session.commit() # Batch-create any missing tag rows await Tags.ensure_tags_exist(new_tags, user.id, db=session) - # Clean up orphaned old tags in one query + # Clean up orphaned old tags removed = set(old_tags) - set(new_tag_ids) if removed: await self.delete_orphan_tags_for_user(list(removed), user.id, db=session) - return ChatModel.model_validate(chat) - async def get_chat_title_by_id(self, id: str) -> str | None: async with get_async_db_context() as session: result = await session.execute(select(Chat.title).filter_by(id=id)) @@ -2075,45 +2077,66 @@ class ChatTable: async def add_chat_tag_by_id_and_user_id_and_tag_name( self, id: str, user_id: str, tag_name: str, db: AsyncSession | None = None - ) -> ChatModel | None: + ) -> None: + """Add one tag to a chat's meta. Meta-column-only, never the blob.""" tag_id = tag_name.replace(' ', '_').lower() await Tags.ensure_tags_exist([tag_name], user_id, db=db) try: async with get_async_db_context(db) as session: - chat = await session.get(Chat, id) - if tag_id not in chat.meta.get('tags', []): - chat.meta = { - **chat.meta, - 'tags': list(set(chat.meta.get('tags', []) + [tag_id])), - } - await session.commit() - return ChatModel.model_validate(chat) + row = (await session.execute(select(Chat.meta).filter_by(id=id))).one_or_none() + if row is None: + return None + + meta = row[0] or {} + if tag_id not in meta.get('tags', []): + await session.execute( + update(Chat) + .filter_by(id=id) + .values(meta={**meta, 'tags': list(set(meta.get('tags', []) + [tag_id]))}) + ) + await session.commit() except Exception: return None async def count_chats_by_tag_name_and_user_id( self, tag_name: str, user_id: str, db: AsyncSession | None = None ) -> int: - async with get_async_db_context(db) as session: - stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, archived=False) - stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True)) - tag_id = tag_name.replace(' ', '_').lower() + tag_id = tag_name.replace(' ', '_').lower() + counts = await self.count_chats_by_tag_ids_and_user_id([tag_id], user_id, db=db) + return counts.get(tag_id, 0) + async def count_chats_by_tag_ids_and_user_id( + self, tag_ids: list[str], user_id: str, db: AsyncSession | None = None + ) -> dict[str, int]: + """Per-tag chat counts in one round trip (one scalar subquery per tag).""" + if not tag_ids: + return {} + async with get_async_db_context(db) as session: bind = await session.connection() dialect_name = bind.dialect.name - if dialect_name == 'sqlite': - stmt = stmt.filter( - text("EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)") - ).params(tag_id=tag_id) - elif dialect_name == 'postgresql': - stmt = stmt.filter( - text("EXISTS (SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') elem WHERE elem = :tag_id)") - ).params(tag_id=tag_id) - else: - raise NotImplementedError(f'Unsupported dialect: {dialect_name}') - result = await session.execute(stmt) - return result.scalar() + columns = [] + for index, tag_id in enumerate(tag_ids): + tag_id = tag_id.replace(' ', '_').lower() + stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, archived=False) + stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True)) + param = f'tag_id_{index}' + if dialect_name == 'sqlite': + stmt = stmt.filter( + text(f"EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :{param})") + ).params(**{param: tag_id}) + elif dialect_name == 'postgresql': + stmt = stmt.filter( + text( + f"EXISTS (SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') elem WHERE elem = :{param})" + ) + ).params(**{param: tag_id}) + else: + raise NotImplementedError(f'Unsupported dialect: {dialect_name}') + columns.append(stmt.scalar_subquery().label(f'count_{index}')) + + row = (await session.execute(select(*columns))).one() + return dict(zip(tag_ids, row)) async def delete_orphan_tags_for_user( self, @@ -2133,11 +2156,8 @@ class ChatTable: if not tag_ids: return async with get_async_db_context(db) as session: - orphans = [] - for tag_id in tag_ids: - count = await self.count_chats_by_tag_name_and_user_id(tag_id, user_id, db=session) - if count <= threshold: - orphans.append(tag_id) + counts = await self.count_chats_by_tag_ids_and_user_id(tag_ids, user_id, db=session) + orphans = [tag_id for tag_id in tag_ids if counts.get(tag_id, 0) <= threshold] await Tags.delete_tags_by_ids_and_user_id(orphans, user_id, db=session) async def count_chats_by_folder_id_and_user_id(