Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -2439,6 +2439,11 @@ def get_local_permission_defaults(system: str | None = None) -> dict:
"options": ["auto", "always", "never"],
"hint": "控制是否在 OpenAI 兼容 Embedding 请求中发送 dimensions 参数。auto 会仅对官方 OpenAI embedding-3 模型自动发送;第三方兼容 API 如需该参数可改为 always,报错时改为 never。",
},
"embedding_max_batch_items": {
"description": "单次请求最大文本数",
"type": "int",
"hint": "留空则自动判断。当嵌入服务限制单次请求可携带的文本数量时(如百炼 text-embedding-v3/v4 最多 10 条),填写该上限可避免请求被拒绝;已知上限的服务无需填写。",
},
"embedding_model": {
"description": "嵌入模型",
"type": "string",
Expand Down
40 changes: 33 additions & 7 deletions astrbot/core/db/vec_db/faiss_impl/vec_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from astrbot import logger
from astrbot.core.exceptions import KnowledgeBaseUploadError
from astrbot.core.provider.provider import EmbeddingProvider, RerankProvider
from astrbot.core.utils.error_redaction import redact_sensitive_text

from ..base import BaseVecDB, Result
from .document_storage import DocumentStorage
Expand Down Expand Up @@ -125,13 +126,38 @@ async def insert_batch(

start = time.time()
logger.debug(f"Generating embeddings for {len(contents)} contents...")
vectors = await self.embedding_provider.get_embeddings_batch(
embedding_contents,
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
progress_callback=progress_callback,
)
try:
vectors = await self.embedding_provider.get_embeddings_batch(
embedding_contents,
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
progress_callback=progress_callback,
)
except KnowledgeBaseUploadError:
raise
except Exception as exc:
# Attach the embedding stage here: otherwise the raw provider error
# escapes unlabelled and gets reported downstream as a storage
# failure, hiding the real cause (see kb_helper's stage mapping).
# Provider errors can quote the request they failed on, API key
# included ("Incorrect API key provided: sk-..."), and this text
# reaches the upload log and the dashboard's failure list, so redact
# it first. The summary shown to users is bounded; the log keeps the
# redacted error in full.
cause = redact_sensitive_text(str(exc)).strip() or type(exc).__name__
summary = cause if len(cause) <= 300 else cause[:300] + "…"
raise KnowledgeBaseUploadError(
stage="embedding",
user_message=f"向量化失败:调用嵌入模型时出错。原因:{summary}",
details={
"cause": cause,
"error_type": type(exc).__name__,
"provider": type(self.embedding_provider).__name__,
"content_count": content_count,
"batch_size": batch_size,
},
) from exc
end = time.time()
logger.debug(
f"Generated embeddings for {len(contents)} contents in {end - start:.2f} seconds.",
Expand Down
5 changes: 4 additions & 1 deletion astrbot/core/knowledge_base/kb_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from astrbot.core.provider.provider import (
Provider as LLMProvider,
)
from astrbot.core.utils.error_redaction import redact_sensitive_text

from .chunking.base import BaseChunker
from .chunking.markdown import MarkdownChunker
Expand Down Expand Up @@ -432,7 +433,9 @@ async def embedding_progress_callback(current, total) -> None:
details={
"file_name": file_name,
"doc_id": doc_id,
"cause": str(exc),
# Redacted: the cause is logged, and a provider error can
# quote the request it failed on, API key included.
"cause": redact_sensitive_text(str(exc)),
},
) from exc

Expand Down
85 changes: 85 additions & 0 deletions astrbot/core/provider/embedding_batch_limits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Known per-request input limits of embedding services.

Every embedding request in AstrBot funnels through
``EmbeddingProvider.get_embeddings_batch``. Some services reject a request that
carries more than N inputs with a deterministic client error, which retrying can
never overcome; the limit therefore has to be known *before* the request is
built. This module holds the small amount of metadata AstrBot actually knows
about those limits, so adapters can declare it cheaply instead of each carrying
its own copy.

Kept dependency-free on purpose: ``provider/sources/dashscope_embedding_source``
imports the dashscope SDK and registers an adapter at import time, which makes it
a poor place to share a plain lookup table from.
"""

# Aliyun Model Studio (DashScope) caps the number of inputs accepted by one
# native embedding request. Exceeding it returns HTTP 400
# "batch size is invalid, it should not be larger than 10."
_DASHSCOPE_MAX_ITEMS: dict[str, int] = {
"text-embedding-v1": 25,
"text-embedding-v2": 25,
"text-embedding-v3": 10,
"text-embedding-v4": 10,
}
# Conservative fallback for DashScope embedding models whose limit is not
# documented per generation (e.g. the multimodal ones). Under-estimating only
# costs extra requests; over-estimating reproduces the failure.
DASHSCOPE_DEFAULT_MAX_ITEMS = 10

# Model families served by DashScope that are embeddings but carry no per-model
# entry above. Matched on substrings because these names carry version suffixes.
_DASHSCOPE_EMBEDDING_NAME_HINTS = (
"multimodal-embedding",
"vl-embedding",
"tongyi-embedding-vision",
)

# Hosts serving the DashScope API, including the OpenAI-compatible mode which
# generic OpenAI endpoints are commonly pointed at.
_DASHSCOPE_HOSTS = frozenset(
{
"dashscope.aliyuncs.com",
"dashscope-intl.aliyuncs.com",
}
)


def dashscope_max_batch_items(model: str | None) -> int | None:
"""Return the DashScope input limit for ``model``, or None if not applicable.

None means "this is not a DashScope embedding model" -- deliberately not
"an embedding model with an unknown limit", so that a model name belonging
to another vendor is never clamped by this table.
"""
if not model:
return None
# Deployment prefixes are common ("models/text-embedding-v4").
name = str(model).strip().lower().rsplit("/", 1)[-1]
if name in _DASHSCOPE_MAX_ITEMS:
return _DASHSCOPE_MAX_ITEMS[name]
if name.startswith("text-embedding-v"):
# A text-embedding generation newer than the table: assume the current
# (stricter) generation's limit rather than the legacy 25.
return DASHSCOPE_DEFAULT_MAX_ITEMS
if any(hint in name for hint in _DASHSCOPE_EMBEDDING_NAME_HINTS):
return DASHSCOPE_DEFAULT_MAX_ITEMS
return None


def is_dashscope_host(hostname: str | None) -> bool:
"""Whether ``hostname`` points at the DashScope API."""
if not hostname:
return False
return hostname.strip().lower() in _DASHSCOPE_HOSTS


def combine_caps(*caps: int | None) -> int | None:
"""Combine declared limits into a single effective limit.

Only known limits participate, and the result is their minimum: a limit
declared by the adapter can never be raised by a looser config value or by
a second detection path.
"""
known = [cap for cap in caps if isinstance(cap, int) and cap > 0]
return min(known) if known else None
Loading